audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops

This commit is contained in:
Gmer4Lfe
2026-06-14 12:40:15 -04:00
parent 4c37ab16fd
commit 3964f6fb46
1010 changed files with 377767 additions and 132 deletions
@@ -0,0 +1,312 @@
#!/bin/bash
# ==============================================================================================
# =========================== Intermediate Sync Maintenance ====================================
# ==============================================================================================
# 4-hour orchestrator — arr library reconciliation, artwork fetching, and optional rsync.
# Schedule: 0 */4 * * * (every 4 hours)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
# 2. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
# 3. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
#
# ── WHY A SEPARATE ORCHESTRATOR ───────────────────────────────────────────────────────────────
# arr libraries need to converge more frequently than once a day. If a remote node adds
# something at 2am, the next daily window is 23 hours away — remote arrs search for content
# they don't know is already owned. Running every 4 hours closes that gap.
#
# lidarr_missing_art.sh is idempotent — skips existing files, runs fast after initial fill.
# Pairing it here means artwork catches up within 4 hours of a new album landing.
#
# Rsync is optional — INTERMEDIATE_SYNC_SHARES empty by default. Add shares to the config
# if a subset of data needs mid-day propagation (e.g. watch state, metadata). Full media
# share sync stays in the daily window.
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID and aliases HOST*_INTERMEDIATE_SYNC_SHARES → INTERMEDIATE_SYNC_SHARES.
# Each server can have a different set of mid-day shares — configure in host*.conf.
# Each script in INTERMEDIATE_MAINTENANCE_SCRIPTS handles its own host logic.
#
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
# Same as daily_sync_maintenance.sh:
# exit 1 = temp WARN — skip this share, continue to next
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — scripts called here require root
# acquire_lock — prevents concurrent intermediate windows
# check_connectivity — verified before any rsync (skipped if no shares)
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
# Silent on success — runs 4x/day, only failures warrant notification
#
# ── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────
# host*.conf: HOST*_INTERMEDIATE_SYNC_SHARES — shares synced mid-day (empty = rsync skipped)
# master.conf: INTERMEDIATE_RSYNC_ENABLED — enable/disable rsync section (default: true)
# master.conf: INTERMEDIATE_MAINTENANCE_SCRIPTS — jobs run after rsync
# master.conf: ARR_SYNC_ENABLED — toggle inside arr_sync.sh
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# intermediate_sync_maintenance.sh — normal run
# intermediate_sync_maintenance.sh --dry-run — preview without changes
# intermediate_sync_maintenance.sh --log — verbose per-job output
# intermediate_sync_maintenance.sh --status — show configured shares/jobs and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
warn "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Interm. enabled: ${INTERMEDIATE_RSYNC_ENABLED:-true}"
echo "$ICON_GEAR Arr sync: ${ARR_SYNC_ENABLED:-true}"
echo ""
echo "━━━ Intermediate Sync Shares ━━━"
if [[ ${#INTERMEDIATE_SYNC_SHARES[@]} -eq 0 ]]; then
echo " None configured — add to INTERMEDIATE_SYNC_SHARES in master.conf to enable"
else
for share in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Intermediate Maintenance Scripts ━━━"
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
WINDOW_START=$(date +%s)
JOB_PASS=()
JOB_FAIL=()
PASS=()
FAIL=()
SHARE_TIMES=()
echo ""
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
# ==============================================================================================
# ━━━ Arr Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
JOB_FAIL+=("arr_sync.sh")
else
_arr_sync_args=()
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
echo "Arr sync complete ✅"
JOB_PASS+=("arr_sync.sh")
else
warn "Arr sync completed with errors — continuing"
JOB_FAIL+=("arr_sync.sh")
fi
unset _arr_sync_args
fi
# ==============================================================================================
# ━━━ Rsync (optional) ━━━
# ==============================================================================================
SHARE_COUNT=${#INTERMEDIATE_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Mid-day Share Sync — $SHARE_COUNT share(s) ━━━"
TOTAL_START=$(date +%s)
SHARE_INDEX=0
ABORT_ALL_SYNCS=false
if [[ "$SHARE_COUNT" -eq 0 ]]; then
echo "No INTERMEDIATE_SYNC_SHARES configured — skipping"
echo "Add shares to INTERMEDIATE_SYNC_SHARES in master.conf to enable mid-day sync"
elif ! check_rsync_enabled "INTERMEDIATE"; then
warn "Intermediate rsync disabled — skipping all $SHARE_COUNT share sync(s)"
else
check_connectivity
check_remote_rootfs
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for SHARE in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
(( SHARE_INDEX++ ))
SHARE_NAME=$(basename "$SHARE")
SHARE_START=$(date +%s)
echo ""
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
FAIL+=("$SHARE_NAME:temp-critical")
continue
fi
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
RSYNC_EXIT=$?
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
case "$RSYNC_EXIT" in
0)
PASS+=("$SHARE_NAME")
log "$SHARE_NAME — done ✅"
;;
1)
FAIL+=("$SHARE_NAME:temp-warn")
warn "$SHARE_NAME skipped — drive temps too high"
;;
2)
FAIL+=("$SHARE_NAME:temp-critical")
ABORT_ALL_SYNCS=true
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
notify "Intermediate sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
"Intermediate Sync" "warning"
;;
*)
FAIL+=("$SHARE_NAME")
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
;;
esac
done
fi
TOTAL_END=$(date +%s)
# ==============================================================================================
# ━━━ Maintenance Jobs ━━━
# ==============================================================================================
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Maintenance Jobs ━━━"
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
if [[ "$SHARE_COUNT" -gt 0 ]]; then
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
sname="${entry%%:*}"
sdur="${entry##*:}"
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
else
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
fi
done
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
echo ""
fi
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "$ICON_GEAR Jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo ""
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Intermediate Sync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,335 @@
#!/bin/bash
# ==============================================================================================
# =========================== Intermediate Sync Maintenance ====================================
# ==============================================================================================
# 4-hour orchestrator — arr library reconciliation, artwork fetching, and optional rsync.
# Schedule: 0 */4 * * * (every 4 hours)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. conf_sync.sh --pull-only — refresh partner conf cache in RAM (/tmp/.vv/)
# 2. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
# 3. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
# 4. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
#
# ── WHY A SEPARATE ORCHESTRATOR ───────────────────────────────────────────────────────────────
# arr libraries need to converge more frequently than once a day. If a remote node adds
# something at 2am, the next daily window is 23 hours away — remote arrs search for content
# they don't know is already owned. Running every 4 hours closes that gap.
#
# lidarr_missing_art.sh is idempotent — skips existing files, runs fast after initial fill.
# Pairing it here means artwork catches up within 4 hours of a new album landing.
#
# Rsync is optional — INTERMEDIATE_SYNC_SHARES empty by default. Add shares to the config
# if a subset of data needs mid-day propagation (e.g. watch state, metadata). Full media
# share sync stays in the daily window.
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID and aliases HOST*_INTERMEDIATE_SYNC_SHARES → INTERMEDIATE_SYNC_SHARES.
# Each server can have a different set of mid-day shares — configure in host*.conf.
# Each script in INTERMEDIATE_MAINTENANCE_SCRIPTS handles its own host logic.
#
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
# Same as daily_sync_maintenance.sh:
# exit 1 = temp WARN — skip this share, continue to next
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — scripts called here require root
# acquire_lock — prevents concurrent intermediate windows
# check_connectivity — verified before any rsync (skipped if no shares)
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
# Silent on success — runs 4x/day, only failures warrant notification
#
# ── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────
# host*.conf: HOST*_INTERMEDIATE_SYNC_SHARES — shares synced mid-day (empty = rsync skipped)
# master.conf: INTERMEDIATE_RSYNC_ENABLED — enable/disable rsync section (default: true)
# master.conf: INTERMEDIATE_MAINTENANCE_SCRIPTS — jobs run after rsync
# master.conf: ARR_SYNC_ENABLED — toggle inside arr_sync.sh
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# intermediate_sync_maintenance.sh — normal run
# intermediate_sync_maintenance.sh --dry-run — preview without changes
# intermediate_sync_maintenance.sh --log — verbose per-job output
# intermediate_sync_maintenance.sh --status — show configured shares/jobs and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
warn "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Interm. enabled: ${INTERMEDIATE_RSYNC_ENABLED:-true}"
echo "$ICON_GEAR Arr sync: ${ARR_SYNC_ENABLED:-true}"
echo ""
echo "━━━ Intermediate Sync Shares ━━━"
if [[ ${#INTERMEDIATE_SYNC_SHARES[@]} -eq 0 ]]; then
echo " None configured — add to INTERMEDIATE_SYNC_SHARES in master.conf to enable"
else
for share in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Intermediate Maintenance Scripts ━━━"
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
WINDOW_START=$(date +%s)
JOB_PASS=()
JOB_FAIL=()
PASS=()
FAIL=()
SHARE_TIMES=()
echo ""
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
# ==============================================================================================
# ━━━ Conf Pull ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Conf Pull ━━━"
CONF_SYNC_SCRIPT="$SCRIPTS_ROOT/System_Essentials/conf_sync.sh"
if [[ ! -f "$CONF_SYNC_SCRIPT" ]]; then
warn "conf_sync.sh not found — skipping partner conf refresh"
else
_conf_args=("--pull-only")
[[ "$DRY_RUN" == true ]] && _conf_args+=("--dry-run")
if bash "$CONF_SYNC_SCRIPT" "${_conf_args[@]}"; then
log "Partner conf cache refreshed ✅"
JOB_PASS+=("conf_sync.sh --pull-only")
else
warn "Partner conf pull failed — cache may be stale"
JOB_FAIL+=("conf_sync.sh --pull-only")
fi
unset _conf_args
fi
# ==============================================================================================
# ━━━ Arr Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
JOB_FAIL+=("arr_sync.sh")
else
_arr_sync_args=()
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
echo "Arr sync complete ✅"
JOB_PASS+=("arr_sync.sh")
else
warn "Arr sync completed with errors — continuing"
JOB_FAIL+=("arr_sync.sh")
fi
unset _arr_sync_args
fi
# ==============================================================================================
# ━━━ Rsync (optional) ━━━
# ==============================================================================================
SHARE_COUNT=${#INTERMEDIATE_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Mid-day Share Sync — $SHARE_COUNT share(s) ━━━"
TOTAL_START=$(date +%s)
SHARE_INDEX=0
ABORT_ALL_SYNCS=false
if [[ "$SHARE_COUNT" -eq 0 ]]; then
echo "No INTERMEDIATE_SYNC_SHARES configured — skipping"
echo "Add shares to INTERMEDIATE_SYNC_SHARES in master.conf to enable mid-day sync"
elif ! check_rsync_enabled "INTERMEDIATE"; then
warn "Intermediate rsync disabled — skipping all $SHARE_COUNT share sync(s)"
else
check_connectivity
check_remote_rootfs
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for SHARE in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
(( SHARE_INDEX++ ))
SHARE_NAME=$(basename "$SHARE")
SHARE_START=$(date +%s)
echo ""
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
FAIL+=("$SHARE_NAME:temp-critical")
continue
fi
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
RSYNC_EXIT=$?
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
case "$RSYNC_EXIT" in
0)
PASS+=("$SHARE_NAME")
log "$SHARE_NAME — done ✅"
;;
1)
FAIL+=("$SHARE_NAME:temp-warn")
warn "$SHARE_NAME skipped — drive temps too high"
;;
2)
FAIL+=("$SHARE_NAME:temp-critical")
ABORT_ALL_SYNCS=true
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
notify "Intermediate sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
"Intermediate Sync" "warning"
;;
*)
FAIL+=("$SHARE_NAME")
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
;;
esac
done
fi
TOTAL_END=$(date +%s)
# ==============================================================================================
# ━━━ Maintenance Jobs ━━━
# ==============================================================================================
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Maintenance Jobs ━━━"
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
if [[ "$SHARE_COUNT" -gt 0 ]]; then
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
sname="${entry%%:*}"
sdur="${entry##*:}"
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
else
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
fi
done
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
echo ""
fi
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "$ICON_GEAR Jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo ""
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Intermediate Sync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
---
name: feedback_dev_vs_prod
description: User had a bad week where dev and production folders got mixed up — always confirm we are working in the single authoritative workspace
metadata:
node_type: memory
type: feedback
originSessionId: d6d51683-5f05-41b4-ad5d-afbe06985ad9
---
Always work exclusively in `/boot/config/plugins/varaverk` (the workspace). Never touch `/mnt/user/Important Shit/Git/Development/Varaverk` — that is a stale dev folder.
**Why:** User spent a week dealing with confusion between dev folder, installed dir, and workspace all drifting out of sync. This caused significant lost work and frustration. The `.plg` now symlinks the installed location directly to the workspace, so there is only one copy.
**How to apply:** If asked to edit any Varaverk file, always use the path under `/boot/config/plugins/varaverk/`. If the dev folder ever comes up, flag that it is stale and should be ignored.
@@ -0,0 +1,42 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
$_action = trim($_GET['action'] ?? '');
if ($_action === 'refresh_remote') {
$host = strtolower(trim($_GET['host'] ?? ''));
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
}
$script = SCRIPTS_DIR . '/Tools/remote_arr_cache_writer.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
}
set_time_limit(30);
$out = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
$node = null;
if (file_exists($cacheFile)) {
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
if ($node) {
$node['cached'] = true;
$node['cache_age'] = time() - (int)filemtime($cacheFile);
}
}
// Bust the main arrs cache so next poll gets fresh data
@unlink(VV_CACHE_DIR . '/arrs.json');
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
exit;
}
unset($_action);
// ── Normal data load ──────────────────────────────────────────────────────────
$_vv_cached = vv_cache_read('arrs', 300);
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
unset($_vv_cached);
require_once dirname(__DIR__) . '/include/arrs.php';
echo json_encode(vv_arrs_all());
@@ -0,0 +1,42 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
$_action = trim($_GET['action'] ?? '');
if ($_action === 'refresh_remote') {
$host = strtolower(trim($_GET['host'] ?? ''));
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
}
$script = dirname(__DIR__) . '/tools/remote_arr_cache_writer.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
}
set_time_limit(30);
$out = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
$node = null;
if (file_exists($cacheFile)) {
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
if ($node) {
$node['cached'] = true;
$node['cache_age'] = time() - (int)filemtime($cacheFile);
}
}
// Bust the main arrs cache so next poll gets fresh data
@unlink(VV_CACHE_DIR . '/arrs.json');
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
exit;
}
unset($_action);
// ── Normal data load ──────────────────────────────────────────────────────────
$_vv_cached = vv_cache_read('arrs', 300);
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
unset($_vv_cached);
require_once dirname(__DIR__) . '/include/arrs.php';
echo json_encode(vv_arrs_all());
@@ -0,0 +1,42 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
$_action = trim($_GET['action'] ?? '');
if ($_action === 'refresh_remote') {
$host = strtolower(trim($_GET['host'] ?? ''));
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
}
$script = dirname(__DIR__) . '/Tools/remote_arr_cache_writer.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
}
set_time_limit(30);
$out = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
$node = null;
if (file_exists($cacheFile)) {
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
if ($node) {
$node['cached'] = true;
$node['cache_age'] = time() - (int)filemtime($cacheFile);
}
}
// Bust the main arrs cache so next poll gets fresh data
@unlink(VV_CACHE_DIR . '/arrs.json');
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
exit;
}
unset($_action);
// ── Normal data load ──────────────────────────────────────────────────────────
$_vv_cached = vv_cache_read('arrs', 300);
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
unset($_vv_cached);
require_once dirname(__DIR__) . '/include/arrs.php';
echo json_encode(vv_arrs_all());
@@ -0,0 +1,251 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN [
<!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.31">
<!ENTITY sha256 "d588470d6cc7f284601cb56039d5dfea6fb5bb1ee2c800657438a8edacafbd01">
<!ENTITY launch "varaverk/monitor">
<!ENTITY github "https://github.com/FailedProxy/Varaverk">
<!ENTITY branch "main">
<!ENTITY cfgdir "/boot/config/plugins/varaverk">
<!ENTITY plugdir "/usr/local/emhttp/plugins/varaverk">
<!ENTITY pkg "varaverk-&version;-noarch-1.txz">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
support="https://github.com/FailedProxy/Varaverk/issues"
icon="/plugins/varaverk/icons/varaverk.png">
<CHANGES>
###2026.05.31
- Packaged release: web files now ship as a .txz that Unraid reinstalls to RAM on every boot
- Survives reboots with zero manual steps (no symlink, no go script) — fixes plugin vanishing after OS upgrades
- Scripts are git-cloned to appdata on first install; web files stay on flash (~200KB)
- Updates handled in-UI (git pull); the plugin no longer pulls on every boot
###2026.05.30
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates
- Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
- Partnership tab: Onboard button highlighted on arrival from wizard; disabled until partner is configured
- HOST2 install paths: state-file pull, master.conf push detection, conf-only flow
- Onboard Step 9: master.conf automatically pushed to all listed hosts on onboard completion
- Graceful pre-onboard state: neutral banners instead of error warnings before SSH is configured
- GitHub link in tab bar and Settings page; Community Apps support URL
###2026.05.28
- Initial release: Monitor, Scheduler, Docker, Watchdog, Partnership, Fallback, Arrs tabs
- Mutual container fallback with tiered escalation and strike-confirmed handback
- Partnership lifecycle: onboard, offboard, transfer
- Rsync profile system with per-share container stops and writeback
- Watchdog: Tier 1 (explicit) + Tier 2 (global scan) container monitoring
- master.conf push-on-save to all configured partners via SSH
</CHANGES>
<!--
── 1. Web files package (runs on every boot) ──────────────────────────────────
unRAID runs `plugin install` on every .plg at boot, BEFORE the array mounts.
The .txz lives on flash, so this installs the PHP/JS UI to RAM in time — and the
disks_mounted event hooks inside it are then present when the array starts, so
cron is rebuilt automatically. No Method attr = treated as "install" = every boot.
Download is skipped when the .txz is already on flash with a matching SHA256, so
this also works offline / for a local install (copy the .txz into &cfgdir;).
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Name="&cfgdir;/&pkg;" Run="/sbin/upgradepkg --install-new --reinstall">
<URL>https://github.com/FailedProxy/Varaverk/releases/download/&version;/&pkg;</URL>
<SHA256>&sha256;</SHA256>
</FILE>
<!--
── 2. Scripts bootstrap (first install only) ──────────────────────────────────
Clones the repo directly into the plugin config dir on flash (/boot/config/plugins/varaverk).
No array dependency — scripts live on flash (64GB NVMe) and are available at boot.
Uses git init+fetch+reset so the clone works into the non-empty cfgdir (varaverk.cfg,
varaverk-*.txz etc. are already there). Never auto-pulls — updates via the UI git pull.
Clone source priority:
1. Gitea (internal) — reads settings from varaverk.cfg; detects container IP at runtime
2. GitHub (public) — HTTPS fallback if Gitea is unreachable
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Run="/bin/bash" Method="install">
<INLINE>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg"
GITHUB="https://github.com/FailedProxy/Varaverk"
BRANCH="main"
LOG="$CFG_DIR/install.log"
mkdir -p "$CFG_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
# Scripts live in the plugin dir on flash — no array needed.
SCRIPTS_DIR="$CFG_DIR"
CONF_DIR="$SCRIPTS_DIR/Configurations"
# ── Boot device check ─────────────────────────────────────────────────────────
# Warn if /boot is on a USB/removable device. Varaverk is designed for internal
# NVMe/SSD boot — git repo + state files + data writes on USB will wear it out
# fast and may run out of space. Install proceeds but user is warned.
_boot_dev=$(df /boot --output=source 2>/dev/null | tail -1)
_boot_base=$(lsblk -no pkname "$_boot_dev" 2>/dev/null || basename "${_boot_dev%[0-9p]*}")
_removable=$(cat "/sys/block/${_boot_base}/removable" 2>/dev/null || echo "0")
if [[ "$_removable" == "1" ]]; then
log "WARNING: /boot is on a removable/USB device ($_boot_dev)"
log "WARNING: Varaverk is designed for internal NVMe/SSD boot."
log "WARNING: Running from USB risks drive wear and space exhaustion."
log "WARNING: Strongly recommend migrating boot to an internal NVMe/SSD drive."
fi
unset _boot_dev _boot_base _removable
# Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
# Requires internal NVMe/SSD boot — scripts live on flash, available before array mounts.
if [[ ! -f "$CFG_FILE" ]]; then
cat > "$CFG_FILE" <<'CFGEOF'
SCRIPTS_DIR="/boot/config/plugins/varaverk"
GITEA_CONTAINER="Gitea"
GITEA_REPO_PATH="FailedProxy/Varaverk.git"
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
SSH_PORT="221"
CFGEOF
log "seeded varaverk.cfg"
fi
# Read Gitea settings from varaverk.cfg (allows override without editing .plg).
_read_cfg() { grep -oP "(?<=^${1}=\")[^\"]*" "$CFG_FILE" 2>/dev/null || echo "${2}"; }
GITEA_CONTAINER=$(_read_cfg GITEA_CONTAINER "Gitea")
GITEA_REPO_PATH=$(_read_cfg GITEA_REPO_PATH "FailedProxy/Varaverk.git")
GITEA_SSH_KEY=$(_read_cfg GITEA_SSH_KEY "/root/.ssh/unraid_gitea")
SSH_PORT=$(_read_cfg SSH_PORT "221")
# Clone on first install only; never auto-pull (updates via the UI git pull).
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
# Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
GITEA_IP=""
if command -v docker >/dev/null 2>&1 && \
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
GITEA_IP=$(hostname -I | awk '{print $1}')
log "Gitea running locally — using $GITEA_IP"
elif command -v tailscale >/dev/null 2>&1; then
# Try each known peer until we find one hosting Gitea
while IFS= read -r peer_ip; do
if ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \
-o ConnectTimeout=3 -o StrictHostKeyChecking=no \
-o BatchMode=yes "git@${peer_ip}" info 2>/dev/null | grep -q "varaverk\|Gitea\|gitea"; then
GITEA_IP="$peer_ip"
log "Gitea found on Tailscale peer $GITEA_IP"
break
fi
done < <(tailscale status --json 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); \
[print(v['TailscaleIPs'][0]) for v in d.get('Peer',{}).values() \
if v.get('TailscaleIPs')]" 2>/dev/null)
fi
# init-in-place — git clone would fail because the dir already has files.
git -C "$SCRIPTS_DIR" init >> "$LOG" 2>&1
CLONED=false
if [[ -n "$GITEA_IP" && -f "$GITEA_SSH_KEY" ]]; then
GITEA_URL="ssh://git@${GITEA_IP}:${SSH_PORT}/${GITEA_REPO_PATH}"
log "trying Gitea: $GITEA_URL"
git -C "$SCRIPTS_DIR" remote add origin "$GITEA_URL" >> "$LOG" 2>&1
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no" \
GIT_TERMINAL_PROMPT=0 \
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
log "scripts installed from Gitea ($GITEA_IP)"
CLONED=true
else
log "Gitea fetch failed — falling back to GitHub"
git -C "$SCRIPTS_DIR" remote remove origin >> "$LOG" 2>&1 || true
fi
fi
if [[ "$CLONED" == false ]]; then
log "trying GitHub: $GITHUB"
git -C "$SCRIPTS_DIR" remote add origin "$GITHUB" >> "$LOG" 2>&1
if GIT_TERMINAL_PROMPT=0 \
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
log "scripts installed from GitHub"
CLONED=true
else
log "WARNING: both Gitea and GitHub failed — scripts not installed, retry when network is up"
exit 0
fi
fi
else
log "repo present — leaving scripts untouched (update from the UI)"
fi
# Seed master.conf from template if absent.
mkdir -p "$CONF_DIR"
if [[ ! -f "$CONF_DIR/master.conf" && -f "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" ]]; then
cp "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" "$CONF_DIR/master.conf"
log "seeded master.conf from template"
fi
log "install step complete"
]]>
</INLINE>
</FILE>
<!--
── 3. Remove ───────────────────────────────────────────────────────────────────
Stops background scripts, removes cron, the installed package, and flash config.
Scripts/conf in appdata are left intact (delete manually for a full wipe).
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg"
CRON_FILE="$CFG_DIR/varaverk.cron"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
log() { echo "[Varaverk remove] $*"; }
[[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
SCRIPTS_DIR="${_sd:-$CFG_DIR}"
# Stop continuous background scripts.
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
fi
pkill -f "run_job.sh" 2>/dev/null || true
pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true
# Remove cron entries.
if [[ -f "$CRON_FILE" ]]; then
rm -f "$CRON_FILE"
/usr/local/sbin/update_cron 2>/dev/null || true
log "cron removed"
fi
rm -f /etc/cron.d/varaverk
# Remove the installed package (and its RAM files).
removepkg "$PLUGIN" 2>/dev/null || true
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
log "web files removed"
# Remove flash config (incl. cached .txz).
rm -rf "$CFG_DIR"
log "flash config removed"
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
]]>
</INLINE>
</FILE>
</PLUGIN>
@@ -0,0 +1,256 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN [
<!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.31">
<!ENTITY sha256 "d588470d6cc7f284601cb56039d5dfea6fb5bb1ee2c800657438a8edacafbd01">
<!ENTITY launch "varaverk/monitor">
<!ENTITY github "https://github.com/FailedProxy/Varaverk">
<!ENTITY branch "main">
<!ENTITY cfgdir "/boot/config/plugins/varaverk">
<!ENTITY plugdir "/usr/local/emhttp/plugins/varaverk">
<!ENTITY pkg "varaverk-&version;-noarch-1.txz">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
support="https://github.com/FailedProxy/Varaverk/issues"
icon="/plugins/varaverk/icons/varaverk.png">
<CHANGES>
###2026.05.31
- Packaged release: web files now ship as a .txz that Unraid reinstalls to RAM on every boot
- Survives reboots with zero manual steps (no symlink, no go script) — fixes plugin vanishing after OS upgrades
- Scripts are git-cloned to appdata on first install; web files stay on flash (~200KB)
- Updates handled in-UI (git pull); the plugin no longer pulls on every boot
###2026.05.30
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates
- Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
- Partnership tab: Onboard button highlighted on arrival from wizard; disabled until partner is configured
- HOST2 install paths: state-file pull, master.conf push detection, conf-only flow
- Onboard Step 9: master.conf automatically pushed to all listed hosts on onboard completion
- Graceful pre-onboard state: neutral banners instead of error warnings before SSH is configured
- GitHub link in tab bar and Settings page; Community Apps support URL
###2026.05.28
- Initial release: Monitor, Scheduler, Docker, Watchdog, Partnership, Fallback, Arrs tabs
- Mutual container fallback with tiered escalation and strike-confirmed handback
- Partnership lifecycle: onboard, offboard, transfer
- Rsync profile system with per-share container stops and writeback
- Watchdog: Tier 1 (explicit) + Tier 2 (global scan) container monitoring
- master.conf push-on-save to all configured partners via SSH
</CHANGES>
<!--
── 1. Web files symlink (runs on every boot) ──────────────────────────────────
Instead of extracting a .txz, we symlink the installed plugin web dir directly
to the workspace on flash. Changes to Plugin/unraid/ are live instantly — no
package build, no sync step. /boot is always mounted before this runs.
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
#!/bin/bash
WEB_DIR="/usr/local/emhttp/plugins/varaverk"
SRC="/boot/config/plugins/varaverk/Plugin/unraid"
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
ln -sf "$SRC" "$WEB_DIR"
echo "[Varaverk] web dir symlinked → $SRC"
]]>
</INLINE>
</FILE>
<!--
── 2. Scripts bootstrap (first install only) ──────────────────────────────────
Clones the repo directly into the plugin config dir on flash (/boot/config/plugins/varaverk).
No array dependency — scripts live on flash (64GB NVMe) and are available at boot.
Uses git init+fetch+reset so the clone works into the non-empty cfgdir (varaverk.cfg,
varaverk-*.txz etc. are already there). Never auto-pulls — updates via the UI git pull.
Clone source priority:
1. Gitea (internal) — reads settings from varaverk.cfg; detects container IP at runtime
2. GitHub (public) — HTTPS fallback if Gitea is unreachable
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Run="/bin/bash" Method="install">
<INLINE>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg"
GITHUB="https://github.com/FailedProxy/Varaverk"
BRANCH="main"
LOG="$CFG_DIR/install.log"
mkdir -p "$CFG_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
# Scripts live in the plugin dir on flash — no array needed.
SCRIPTS_DIR="$CFG_DIR"
CONF_DIR="$SCRIPTS_DIR/Configurations"
# ── Boot device check ─────────────────────────────────────────────────────────
# Warn if /boot is on a USB/removable device. Varaverk is designed for internal
# NVMe/SSD boot — git repo + state files + data writes on USB will wear it out
# fast and may run out of space. Install proceeds but user is warned.
_boot_dev=$(df /boot --output=source 2>/dev/null | tail -1)
_boot_base=$(lsblk -no pkname "$_boot_dev" 2>/dev/null || basename "${_boot_dev%[0-9p]*}")
_removable=$(cat "/sys/block/${_boot_base}/removable" 2>/dev/null || echo "0")
if [[ "$_removable" == "1" ]]; then
log "WARNING: /boot is on a removable/USB device ($_boot_dev)"
log "WARNING: Varaverk is designed for internal NVMe/SSD boot."
log "WARNING: Running from USB risks drive wear and space exhaustion."
log "WARNING: Strongly recommend migrating boot to an internal NVMe/SSD drive."
fi
unset _boot_dev _boot_base _removable
# Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
# Requires internal NVMe/SSD boot — scripts live on flash, available before array mounts.
if [[ ! -f "$CFG_FILE" ]]; then
cat > "$CFG_FILE" <<'CFGEOF'
SCRIPTS_DIR="/boot/config/plugins/varaverk"
GITEA_CONTAINER="Gitea"
GITEA_REPO_PATH="FailedProxy/Varaverk.git"
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
SSH_PORT="221"
CFGEOF
log "seeded varaverk.cfg"
fi
# Read Gitea settings from varaverk.cfg (allows override without editing .plg).
_read_cfg() { grep -oP "(?<=^${1}=\")[^\"]*" "$CFG_FILE" 2>/dev/null || echo "${2}"; }
GITEA_CONTAINER=$(_read_cfg GITEA_CONTAINER "Gitea")
GITEA_REPO_PATH=$(_read_cfg GITEA_REPO_PATH "FailedProxy/Varaverk.git")
GITEA_SSH_KEY=$(_read_cfg GITEA_SSH_KEY "/root/.ssh/unraid_gitea")
SSH_PORT=$(_read_cfg SSH_PORT "221")
# Clone on first install only; never auto-pull (updates via the UI git pull).
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
# Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
GITEA_IP=""
if command -v docker >/dev/null 2>&1 && \
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
GITEA_IP=$(hostname -I | awk '{print $1}')
log "Gitea running locally — using $GITEA_IP"
elif command -v tailscale >/dev/null 2>&1; then
# Try each known peer until we find one hosting Gitea
while IFS= read -r peer_ip; do
if ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \
-o ConnectTimeout=3 -o StrictHostKeyChecking=no \
-o BatchMode=yes "git@${peer_ip}" info 2>/dev/null | grep -q "varaverk\|Gitea\|gitea"; then
GITEA_IP="$peer_ip"
log "Gitea found on Tailscale peer $GITEA_IP"
break
fi
done < <(tailscale status --json 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); \
[print(v['TailscaleIPs'][0]) for v in d.get('Peer',{}).values() \
if v.get('TailscaleIPs')]" 2>/dev/null)
fi
# init-in-place — git clone would fail because the dir already has files.
git -C "$SCRIPTS_DIR" init >> "$LOG" 2>&1
CLONED=false
if [[ -n "$GITEA_IP" && -f "$GITEA_SSH_KEY" ]]; then
GITEA_URL="ssh://git@${GITEA_IP}:${SSH_PORT}/${GITEA_REPO_PATH}"
log "trying Gitea: $GITEA_URL"
git -C "$SCRIPTS_DIR" remote add origin "$GITEA_URL" >> "$LOG" 2>&1
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no" \
GIT_TERMINAL_PROMPT=0 \
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
log "scripts installed from Gitea ($GITEA_IP)"
CLONED=true
else
log "Gitea fetch failed — falling back to GitHub"
git -C "$SCRIPTS_DIR" remote remove origin >> "$LOG" 2>&1 || true
fi
fi
if [[ "$CLONED" == false ]]; then
log "trying GitHub: $GITHUB"
git -C "$SCRIPTS_DIR" remote add origin "$GITHUB" >> "$LOG" 2>&1
if GIT_TERMINAL_PROMPT=0 \
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
log "scripts installed from GitHub"
CLONED=true
else
log "WARNING: both Gitea and GitHub failed — scripts not installed, retry when network is up"
exit 0
fi
fi
else
log "repo present — leaving scripts untouched (update from the UI)"
fi
# Seed master.conf from template if absent.
mkdir -p "$CONF_DIR"
if [[ ! -f "$CONF_DIR/master.conf" && -f "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" ]]; then
cp "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" "$CONF_DIR/master.conf"
log "seeded master.conf from template"
fi
log "install step complete"
]]>
</INLINE>
</FILE>
<!--
── 3. Remove ───────────────────────────────────────────────────────────────────
Stops background scripts, removes cron, the installed package, and flash config.
Scripts/conf in appdata are left intact (delete manually for a full wipe).
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg"
CRON_FILE="$CFG_DIR/varaverk.cron"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
log() { echo "[Varaverk remove] $*"; }
[[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
SCRIPTS_DIR="${_sd:-$CFG_DIR}"
# Stop continuous background scripts.
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
fi
pkill -f "run_job.sh" 2>/dev/null || true
pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true
# Remove cron entries.
if [[ -f "$CRON_FILE" ]]; then
rm -f "$CRON_FILE"
/usr/local/sbin/update_cron 2>/dev/null || true
log "cron removed"
fi
rm -f /etc/cron.d/varaverk
# Remove the installed package (and its RAM files).
removepkg "$PLUGIN" 2>/dev/null || true
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
log "web files removed"
# Remove flash config (incl. cached .txz).
rm -rf "$CFG_DIR"
log "flash config removed"
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
]]>
</INLINE>
</FILE>
</PLUGIN>
@@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
$logDir = '/var/log/varaverk';
$runs = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($ri as $file) {
if ($file->getExtension() !== 'json') continue;
$d = @json_decode(@file_get_contents($file->getPathname()), true);
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
if ($d['status'] === 'running') continue;
$id = (string)($d['id'] ?? '');
$runs[] = [
'id' => $id,
'label' => basename(str_replace('.sh', '', $id)),
'status' => $d['status'],
'start' => (int)$d['start'],
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
];
}
} catch (Exception $e) {}
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$logDir = LOG_DIR;
$runs = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($ri as $file) {
if ($file->getExtension() !== 'json') continue;
$d = @json_decode(@file_get_contents($file->getPathname()), true);
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
if ($d['status'] === 'running') continue;
$id = (string)($d['id'] ?? '');
$runs[] = [
'id' => $id,
'label' => basename(str_replace('.sh', '', $id)),
'status' => $d['status'],
'start' => (int)$d['start'],
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
];
}
} catch (Exception $e) {}
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
@@ -0,0 +1,228 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Auto-Populate =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Reads credentials and settings from locally running services and writes
# them into the local host conf. Safe to run multiple times — only populates
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
#
# After populating, pushes the updated conf to all partners via conf_sync.sh
# so they have the fresh keys in their /tmp/.vv/ cache immediately.
#
# ==============================================================================================
# AUTO-DETECTED FIELDS
# ==============================================================================================
#
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
# HOSTN_SONARR_API_KEY from Sonarr config.xml
# HOSTN_LIDARR_API_KEY from Lidarr config.xml
# HOSTN_SLSKD_API_KEY from slskd config.yml
# HOSTN_SABNZBD_API_KEY from sabnzbd.ini
# HOSTN_EMBY_CONTAINER fuzzy match from docker ps
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
# HOSTN_RADARR_MOVIE_ROOT from Radarr rootFolder API
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
# HOSTN_LIDARR_MUSIC_ROOT from Lidarr rootFolder API
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_populate.sh Populate empty fields only
# conf_populate.sh --overwrite Overwrite all detected fields (re-sync after arr key rotation)
# conf_populate.sh --dry-run Show what would be written, no changes
# conf_populate.sh --log Verbose output
# conf_populate.sh --no-push Skip pushing to partners after update
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
OVERWRITE=false
NO_PUSH=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--overwrite) OVERWRITE=true ;;
--no-push) NO_PUSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
UPDATED=0
SKIPPED=0
echo ""
echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
_set_conf_var() {
local var_name="$1" value="$2" label="$3"
[[ -z "$value" ]] && return
# Check current value in conf
local current
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
log "$label: already set (${current:0:8}…) — skipping"
(( SKIPPED++ ))
return
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set $var_name = ${value:0:8}…"
return
fi
# Update or append the var line
if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
else
printf '\n %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
fi
info "$label: set ✅"
(( UPDATED++ ))
}
# ── Helper: find arr config dir via docker volume mount ───────────────────────
# Looks for a container matching the pattern, then reads its /config volume path.
# Falls back to DOCKER_APPDATA_BASE/<ContainerName> if volume not found.
_arr_config_dir() {
local pattern="$1"
local container_name
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | \
grep -im1 "^${pattern}")
[[ -z "$container_name" ]] && return 1
local config_path
config_path=$(docker inspect "$container_name" 2>/dev/null | \
jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
[[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"
[[ -d "$config_path" ]] && echo "$config_path" || return 1
}
# ── Helper: read XML tag value ────────────────────────────────────────────────
_xml_val() {
local file="$1" tag="$2"
grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
}
# ==============================================================================================
# ── Arr API keys + root paths ─────────────────────────────────────────────────────────────────
# ==============================================================================================
for arr in radarr sonarr lidarr; do
arr_upper="${arr^^}"
config_dir=$(_arr_config_dir "$arr") || {
log "${arr_upper}: no running container found — skipping"
continue
}
config_xml="${config_dir}/config.xml"
if [[ ! -f "$config_xml" ]]; then
log "${arr_upper}: config.xml not found at $config_xml — skipping"
continue
fi
key=$(_xml_val "$config_xml" "ApiKey")
port=$(_xml_val "$config_xml" "Port")
url_base="http://localhost:${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
_set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key" "${arr_upper} API key"
# Root paths from arr's own rootFolder API
if [[ -n "$key" ]]; then
local api_ver; case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
root_json=$(curl -sf --max-time 5 \
-H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
case "$arr" in
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
esac
fi
done
# ==============================================================================================
# ── SABnzbd API key ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
sab_dir=$(_arr_config_dir "sabnzbd") && {
sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
if [[ -f "$sab_ini" ]]; then
sab_key=$(grep -oP '(?<=^api_key\s*=\s*)\S+' "$sab_ini" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
fi
}
# ==============================================================================================
# ── slskd API key ─────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
slskd_dir=$(_arr_config_dir "slskd") && {
slskd_yml=$(find "$slskd_dir" -maxdepth 2 -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -1)
if [[ -f "$slskd_yml" ]]; then
slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
[[ -z "$slskd_key" ]] && \
slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
fi
}
# ==============================================================================================
# ── Container names ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
for pattern in "emby" "jellyfin"; do
container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
[[ -z "$container" ]] && continue
case "$pattern" in
emby) _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name" ;;
jellyfin) _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container" "Jellyfin container name" ;;
esac
done
# ==============================================================================================
# ── Network interface ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
# ==============================================================================================
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
echo " Updated: $UPDATED field(s)"
echo " Skipped: $SKIPPED already set"
echo " Conf: $CONF_FILE"
echo "━━━━━━━━━━━━━━━━━━━━━"
if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
echo ""
info "Pushing updated conf to partners..."
bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
fi
@@ -0,0 +1,229 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Auto-Populate =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Reads credentials and settings from locally running services and writes
# them into the local host conf. Safe to run multiple times — only populates
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
#
# After populating, pushes the updated conf to all partners via conf_sync.sh
# so they have the fresh keys in their /tmp/.vv/ cache immediately.
#
# ==============================================================================================
# AUTO-DETECTED FIELDS
# ==============================================================================================
#
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
# HOSTN_SONARR_API_KEY from Sonarr config.xml
# HOSTN_LIDARR_API_KEY from Lidarr config.xml
# HOSTN_SLSKD_API_KEY from slskd config.yml
# HOSTN_SABNZBD_API_KEY from sabnzbd.ini
# HOSTN_EMBY_CONTAINER fuzzy match from docker ps
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
# HOSTN_RADARR_MOVIE_ROOT from Radarr rootFolder API
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
# HOSTN_LIDARR_MUSIC_ROOT from Lidarr rootFolder API
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_populate.sh Populate empty fields only
# conf_populate.sh --overwrite Overwrite all detected fields (re-sync after arr key rotation)
# conf_populate.sh --dry-run Show what would be written, no changes
# conf_populate.sh --log Verbose output
# conf_populate.sh --no-push Skip pushing to partners after update
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
SCRIPTS_ROOT="$SCRIPTS_DIR"
OVERWRITE=false
NO_PUSH=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--overwrite) OVERWRITE=true ;;
--no-push) NO_PUSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
UPDATED=0
SKIPPED=0
echo ""
echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
_set_conf_var() {
local var_name="$1" value="$2" label="$3"
[[ -z "$value" ]] && return
# Check current value in conf
local current
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
log "$label: already set (${current:0:8}…) — skipping"
(( SKIPPED++ ))
return
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set $var_name = ${value:0:8}…"
return
fi
# Update or append the var line
if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
else
printf '\n %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
fi
info "$label: set ✅"
(( UPDATED++ ))
}
# ── Helper: find arr config dir via docker volume mount ───────────────────────
# Looks for a container matching the pattern, then reads its /config volume path.
# Falls back to DOCKER_APPDATA_BASE/<ContainerName> if volume not found.
_arr_config_dir() {
local pattern="$1"
local container_name
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | \
grep -im1 "^${pattern}")
[[ -z "$container_name" ]] && return 1
local config_path
config_path=$(docker inspect "$container_name" 2>/dev/null | \
jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
[[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"
[[ -d "$config_path" ]] && echo "$config_path" || return 1
}
# ── Helper: read XML tag value ────────────────────────────────────────────────
_xml_val() {
local file="$1" tag="$2"
grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
}
# ==============================================================================================
# ── Arr API keys + root paths ─────────────────────────────────────────────────────────────────
# ==============================================================================================
for arr in radarr sonarr lidarr; do
arr_upper="${arr^^}"
config_dir=$(_arr_config_dir "$arr") || {
log "${arr_upper}: no running container found — skipping"
continue
}
config_xml="${config_dir}/config.xml"
if [[ ! -f "$config_xml" ]]; then
log "${arr_upper}: config.xml not found at $config_xml — skipping"
continue
fi
key=$(_xml_val "$config_xml" "ApiKey")
port=$(_xml_val "$config_xml" "Port")
url_base="http://localhost:${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
_set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key" "${arr_upper} API key"
# Root paths from arr's own rootFolder API
if [[ -n "$key" ]]; then
local api_ver; case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
root_json=$(curl -sf --max-time 5 \
-H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
case "$arr" in
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
esac
fi
done
# ==============================================================================================
# ── SABnzbd API key ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
sab_dir=$(_arr_config_dir "sabnzbd") && {
sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
if [[ -f "$sab_ini" ]]; then
sab_key=$(grep -oP '(?<=^api_key\s*=\s*)\S+' "$sab_ini" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
fi
}
# ==============================================================================================
# ── slskd API key ─────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
slskd_dir=$(_arr_config_dir "slskd") && {
slskd_yml=$(find "$slskd_dir" -maxdepth 2 -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -1)
if [[ -f "$slskd_yml" ]]; then
slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
[[ -z "$slskd_key" ]] && \
slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
fi
}
# ==============================================================================================
# ── Container names ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
for pattern in "emby" "jellyfin"; do
container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
[[ -z "$container" ]] && continue
case "$pattern" in
emby) _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name" ;;
jellyfin) _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container" "Jellyfin container name" ;;
esac
done
# ==============================================================================================
# ── Network interface ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
# ==============================================================================================
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
echo " Updated: $UPDATED field(s)"
echo " Skipped: $SKIPPED already set"
echo " Conf: $CONF_FILE"
echo "━━━━━━━━━━━━━━━━━━━━━"
if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
echo ""
info "Pushing updated conf to partners..."
bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
fi
@@ -0,0 +1,879 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Onboard ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs once on both servers to establish a new partnership. Role is detected
# automatically via detect_hosts() — no flags needed to declare which side you are.
# Run on the mirror first (generates its SSH key), then on the owner to complete
# setup remotely.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# MIRROR PATH (1 step)
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
# Owner completes the rest remotely. Mirror is done.
#
# OWNER PATH (8 steps)
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 2: Plugin install — FolderView3 and required plugins on mirror
# Step 3: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 4: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
# Step 5: Stop mirror arr — stop mirror's existing arr containers before replacing
# Step 6: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
# Step 7: Partnership onboard — configure WebUIs → owner IP, write state, FolderView3, Emby
# Step 8: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 9: Conf push — push master.conf + setup state to all listed hosts
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials never in SSH command strings
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
# locally, SCPed to the remote, and executed there. Command-line args are never used
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
#
# XML templates are the single source of truth for deployed containers
# The owner's templates-user/ XMLs define every container deployed on the mirror.
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
# Docker Manager can manage the containers after onboard without additional config.
#
# Dependency ordering in the auth stack is owner-enforced
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
# the script waits for the container to be healthy before continuing. This is a remote
# health check — the container must be running (or report healthy) before the next
# dependent is deployed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check
# All operations run as root — SSH key management, docker operations, conf updates.
#
# SSH timeout on all remote calls
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
# slow or unreachable mirror.
#
# --dry-run shows exact actions without executing
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
# all dry-run safe.
#
# Step skip flags for partial re-runs
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
# resuming after a partial failure without re-running completed steps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# XML filenames (from this server's templates-user/) to push and deploy on the
# mirror as its auth stack. Order matters: database deps before Authelia.
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
#
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
# Containers to stop on the mirror before deploying the auth stack.
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_ARR_STACK
# XML filenames to push and deploy on the mirror as its arr stack.
# Leave empty to skip arr stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
#
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
# Arr containers to stop on the mirror before deploying the arr stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_onboard.sh
# Full onboard — role detected automatically
#
# Partnership/partnership_onboard.sh --dry-run
# Preview all steps without making changes
#
# Partnership/partnership_onboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_onboard.sh --skip-ssh
# Skip SSH key setup (key already in place)
#
# Partnership/partnership_onboard.sh --skip-auth-stack
# Skip auth stack stop + deploy (Steps 3-4)
#
# Partnership/partnership_onboard.sh --skip-arr-stack
# Skip arr stack stop + deploy (Steps 5-6)
#
# Partnership/partnership_onboard.sh --skip-arr-sync
# Skip arr library bootstrap (Step 8)
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
SKIP_AUTH_STACK=false
SKIP_ARR_STACK=false
SKIP_ARR_SYNC=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--skip-ssh) SKIP_SSH=true ;;
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
--skip-arr-stack) SKIP_ARR_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key.
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
write_onboard_phase() {
local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE"
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
command -v php &>/dev/null && \
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: deploy a container from a local Unraid template XML to a remote host ─────────────
#
# Parses Port / Path / Variable Config entries from the XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
local xml_file="$1" remote_ip="$2" ssh_key="$3"
local xml_name
xml_name=$(basename "$xml_file")
# Extract top-level fields
local name repo network extra privileged
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
if [[ -z "$name" || -z "$repo" ]]; then
warn " Cannot parse Name/Repository from $xml_name — skipping"
return 1
fi
log "Deploying $name..."
# SCP the XML so Unraid Docker Manager recognises and can manage the container
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
warn " SCP failed for $xml_name — skipping $name"
return 1
}
else
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
fi
# Build a self-contained deploy script locally.
# Writing to a temp file keeps credentials out of SSH command strings.
local tmp_script
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
chmod 600 "$tmp_script"
{
echo "#!/bin/bash"
echo "set -e"
echo ""
printf "docker pull %q 2>/dev/null || true\n" "$repo"
printf "docker stop %q 2>/dev/null || true\n" "$name"
printf "docker rm %q 2>/dev/null || true\n" "$name"
echo ""
printf "docker create --name %q --restart=unless-stopped" "$name"
[[ -n "$network" ]] && printf " --network=%q" "$network"
[[ "$privileged" == "true" ]] && printf " --privileged"
[[ -n "$extra" ]] && printf " %s" "$extra"
# Port mappings → -p host:container/proto
awk '/Type="Port"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
proto = (m[1] == "udp") ? "udp" : "tcp"
printf " -p %s:%s/%s", v[1], t[1], proto
}
}' "$xml_file"
# Volume mappings → -v 'host:container:mode'
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
mode = (m[1] == "ro") ? "ro" : "rw"
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
}
}' "$xml_file"
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
match($0, /Target="([^"]+)"/, t)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
printf " -e %s%s=%s%s", q, t[1], v[1], q
}
}' "$xml_file"
printf " %q\n" "$repo"
echo ""
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
} > "$tmp_script"
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would deploy $name on $MIRROR"
rm -f "$tmp_script"
return 0
fi
# SCP deploy script → remote, execute, clean up both sides
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
grep -q "deployed:${name}"; then
log " $name deployed ✅"
rm -f "$tmp_script"
return 0
else
warn " $name deployment failed — check $MIRROR manually"
rm -f "$tmp_script"
return 1
fi
}
# ==============================================================================================
# ── HELPER: wait for a container on the remote to be healthy/running ─────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state for containers with no healthcheck. Non-fatal after timeout
# — Authelia may take time to fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
local name="$1" remote_ip="$2" ssh_key="$3"
local max_wait=60 interval=5 elapsed=0
[[ "$DRY_RUN" == true ]] && return 0
log " Waiting for $name to be ready..."
while (( elapsed < max_wait )); do
local status
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
echo \${h:-\$r}" 2>/dev/null)
case "$status" in
healthy|true)
log " $name ready ✅"
return 0
;;
starting|unhealthy|false|"")
sleep "$interval"
(( elapsed += interval ))
;;
*)
sleep "$interval"
(( elapsed += interval ))
;;
esac
done
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
return 0
}
# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
# use the same convention), and reads the named config array from the mirror's own conf.
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
#
# deploy_container_from_xml() already stops/removes containers with the same name as what's
# being deployed. This step handles containers with DIFFERENT names that conflict.
# ==============================================================================================
stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
return 0
fi
log "Stopping $label on $MIRROR: ${to_stop[*]}"
for container in "${to_stop[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
log " $container removed ✅" || \
log " $container not found on $MIRROR — skipping"
done
}
# ==============================================================================================
# ── HELPER: deploy a stack of XMLs to the mirror, health-checking db deps between batches ────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# This avoids the process-substitution capture problem: warn() writes to stdout, so any
# read -r X Y < <(func) would capture warn output as the count values.
# ==============================================================================================
_STACK_DEPLOYED=0
_STACK_FAILED=0
deploy_xml_stack() {
local -n xml_array_ref="$1"
_STACK_DEPLOYED=0
_STACK_FAILED=0
for xml_name in "${xml_array_ref[@]}"; do
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
(( _STACK_FAILED++ ))
continue
fi
# Extract container name to use for health-wait matching
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
(( _STACK_DEPLOYED++ ))
# Health-check database deps before continuing — they must be ready before
# Authelia/app containers that depend on them can start cleanly.
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
fi
else
(( _STACK_FAILED++ ))
fi
done
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
echo ""
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH key ready ✅"
else
error "SSH key setup failed"
exit 1
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
echo ""
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
PHASE2_TRIGGERED=false
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
log "Phase 2 triggered on $OWNER ✅"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
PHASE2_TRIGGERED=true
else
warn "Could not auto-trigger Phase 2 on $OWNER"
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
if [[ "$PHASE2_TRIGGERED" == false ]]; then
echo ""
echo " Run manually on $OWNER:"
echo " bash Partnership/partnership_onboard.sh --phase2-only"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
echo ""
STEP_SSH_OK=false
STEP_PLUGINS_OK=true
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
AUTH_FAILED=0
STEP_STOP_ARR_OK=true
STEP_ARR_OK=true
ARR_DEPLOYED=0
ARR_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
MASTER_PUSH_OK=false
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
# Skipped when --phase2-only (SSH was already done in Phase 1).
echo "━━━ Step 1 — SSH Key Setup ━━━"
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping (--skip-ssh)"
STEP_SSH_OK=true
elif [[ "$PHASE1_ONLY" == true ]]; then
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
# hanging for a password prompt with no TTY.
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
log "SSH to $MIRROR already works ✅ — skipping key install"
STEP_SSH_OK=true
else
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
# Soft-fail: generate key locally if not present, then tell user to install manually
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
if [[ -f "$SSH_KEY" ]]; then
log "Local key exists at: $SSH_KEY"
else
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
fi
if [[ -f "${SSH_KEY}.pub" ]]; then
echo ""
echo " Install this key on $MIRROR to complete SSH setup:"
echo " ┌─────────────────────────────────────────────────────"
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
echo " └─────────────────────────────────────────────────────"
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
}
fi
STEP_SSH_OK=false
fi
fi
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
error "SSH key setup failed — aborting"
error "Re-run or use --skip-ssh if key is already set up"
exit 1
fi
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
if [[ "$PHASE1_ONLY" == true ]]; then
if [[ "$STEP_SSH_OK" == false ]]; then
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — FolderView3 may need manual setup"
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in Partnership tab, or run:"
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Phase 1 — Conf Push ━━━"
CONF_PUSH_OK=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf + state file to $MIRROR"
CONF_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
log "Conf push complete ✅"
CONF_PUSH_OK=true
else
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — FolderView3 may need manual setup"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && [[ -n "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
FV3_PRESENT=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)
if [[ "$FV3_PRESENT" == "yes" ]]; then
log "FolderView3 already installed on $MIRROR ✅"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would install FolderView3 on $MIRROR"
else
log "Installing FolderView3 on $MIRROR..."
timeout 60 ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
2>/dev/null | grep -q installed && \
log "FolderView3 installed ✅" || {
warn "FolderView3 install failed — install manually from Community Applications"
STEP_PLUGINS_OK=false
}
fi
else
log "FolderView3 not configured — skipping"
fi
# ── Step 3: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 3 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
else
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
STEP_AUTH_OK=false
else
deploy_xml_stack PARTNERSHIP_AUTH_STACK
AUTH_DEPLOYED=$_STACK_DEPLOYED
AUTH_FAILED=$_STACK_FAILED
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
SKIP_ARR_STACK=true
else
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
fi
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
else
deploy_xml_stack PARTNERSHIP_ARR_STACK
ARR_DEPLOYED=$_STACK_DEPLOYED
ARR_FAILED=$_STACK_FAILED
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
fi
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
ONBOARD_OK=true
else
error "Partnership onboard failed"
ONBOARD_OK=false
fi
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 8 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
fi
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
# SSH is now established and all partners have the plugin installed.
# Push the authoritative master.conf so every listed host is in sync immediately.
echo ""
echo "━━━ $ICON_GEAR Step 9 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf to all listed hosts"
MASTER_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
echo "master.conf sync complete ✅"
MASTER_PUSH_OK=true
else
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 2 — Plugins: $(_ok "$STEP_PLUGINS_OK")"
echo " Step 3 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 4 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 5 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
echo " Step 6 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
echo " Step 7 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 8 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 9 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
echo "$ICON_DONE DONE — partnership established ✅"
echo "Verify with: Partnership/partnership_manager.sh --status"
else
error "Setup incomplete — resolve errors above and re-run"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ONBOARD_OK" == false ]] && exit 1
exit 0
@@ -0,0 +1,659 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Onboard ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs once on both servers to establish a new partnership. Role is detected
# automatically via detect_hosts() — no flags needed to declare which side you are.
# Run on the mirror first (generates its SSH key), then on the owner to complete
# setup remotely.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# MIRROR PATH (1 step)
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
# Owner completes the rest remotely. Mirror is done.
#
# OWNER PATH (8 steps)
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 2: Plugin install — FolderView3 and required plugins on mirror
# Step 3: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 4: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
# Step 5: Stop mirror arr — stop mirror's existing arr containers before replacing
# Step 6: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
# Step 7: Partnership onboard — configure WebUIs → owner IP, write state, FolderView3, Emby
# Step 8: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 9: Conf push — push master.conf + setup state to all listed hosts
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials never in SSH command strings
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
# locally, SCPed to the remote, and executed there. Command-line args are never used
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
#
# XML templates are the single source of truth for deployed containers
# The owner's templates-user/ XMLs define every container deployed on the mirror.
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
# Docker Manager can manage the containers after onboard without additional config.
#
# Dependency ordering in the auth stack is owner-enforced
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
# the script waits for the container to be healthy before continuing. This is a remote
# health check — the container must be running (or report healthy) before the next
# dependent is deployed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check
# All operations run as root — SSH key management, docker operations, conf updates.
#
# SSH timeout on all remote calls
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
# slow or unreachable mirror.
#
# --dry-run shows exact actions without executing
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
# all dry-run safe.
#
# Step skip flags for partial re-runs
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
# resuming after a partial failure without re-running completed steps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# XML filenames (from this server's templates-user/) to push and deploy on the
# mirror as its auth stack. Order matters: database deps before Authelia.
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
#
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
# Containers to stop on the mirror before deploying the auth stack.
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_ARR_STACK
# XML filenames to push and deploy on the mirror as its arr stack.
# Leave empty to skip arr stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
#
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
# Arr containers to stop on the mirror before deploying the arr stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_onboard.sh
# Full onboard — role detected automatically
#
# Partnership/partnership_onboard.sh --dry-run
# Preview all steps without making changes
#
# Partnership/partnership_onboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_onboard.sh --skip-ssh
# Skip SSH key setup (key already in place)
#
# Partnership/partnership_onboard.sh --skip-auth-stack
# Skip auth stack stop + deploy (Steps 3-4)
#
# Partnership/partnership_onboard.sh --skip-arr-stack
# Skip arr stack stop + deploy (Steps 5-6)
#
# Partnership/partnership_onboard.sh --skip-arr-sync
# Skip arr library bootstrap (Step 8)
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
SKIP_AUTH_STACK=false
SKIP_ARR_STACK=false
SKIP_ARR_SYNC=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--skip-ssh) SKIP_SSH=true ;;
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
--skip-arr-stack) SKIP_ARR_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key.
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
write_onboard_phase() {
local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE"
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
command -v php &>/dev/null && \
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
# use the same convention), and reads the named config array from the mirror's own conf.
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
#
# deploy_container_from_xml() already stops/removes containers with the same name as what's
# being deployed. This step handles containers with DIFFERENT names that conflict.
# ==============================================================================================
stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
return 0
fi
log "Stopping $label on $MIRROR: ${to_stop[*]}"
for container in "${to_stop[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
log " $container removed ✅" || \
log " $container not found on $MIRROR — skipping"
done
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
echo ""
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH key ready ✅"
else
error "SSH key setup failed"
exit 1
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
echo ""
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
PHASE2_TRIGGERED=false
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
log "Phase 2 triggered on $OWNER ✅"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
PHASE2_TRIGGERED=true
else
warn "Could not auto-trigger Phase 2 on $OWNER"
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
if [[ "$PHASE2_TRIGGERED" == false ]]; then
echo ""
echo " Run manually on $OWNER:"
echo " bash Partnership/partnership_onboard.sh --phase2-only"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
echo ""
STEP_SSH_OK=false
STEP_PLUGINS_OK=true
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
AUTH_FAILED=0
STEP_STOP_ARR_OK=true
STEP_ARR_OK=true
ARR_DEPLOYED=0
ARR_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
MASTER_PUSH_OK=false
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
# Skipped when --phase2-only (SSH was already done in Phase 1).
echo "━━━ Step 1 — SSH Key Setup ━━━"
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping (--skip-ssh)"
STEP_SSH_OK=true
elif [[ "$PHASE1_ONLY" == true ]]; then
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
# hanging for a password prompt with no TTY.
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
log "SSH to $MIRROR already works ✅ — skipping key install"
STEP_SSH_OK=true
else
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
# Soft-fail: generate key locally if not present, then tell user to install manually
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
if [[ -f "$SSH_KEY" ]]; then
log "Local key exists at: $SSH_KEY"
else
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
fi
if [[ -f "${SSH_KEY}.pub" ]]; then
echo ""
echo " Install this key on $MIRROR to complete SSH setup:"
echo " ┌─────────────────────────────────────────────────────"
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
echo " └─────────────────────────────────────────────────────"
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
}
fi
STEP_SSH_OK=false
fi
fi
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
error "SSH key setup failed — aborting"
error "Re-run or use --skip-ssh if key is already set up"
exit 1
fi
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
if [[ "$PHASE1_ONLY" == true ]]; then
if [[ "$STEP_SSH_OK" == false ]]; then
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — FolderView3 may need manual setup"
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in Partnership tab, or run:"
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Phase 1 — Conf Push ━━━"
CONF_PUSH_OK=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf + state file to $MIRROR"
CONF_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
log "Conf push complete ✅"
CONF_PUSH_OK=true
else
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — FolderView3 may need manual setup"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"
platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false
# ── Step 3: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 3 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
else
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
STEP_AUTH_OK=false
else
deploy_xml_stack PARTNERSHIP_AUTH_STACK
AUTH_DEPLOYED=$_STACK_DEPLOYED
AUTH_FAILED=$_STACK_FAILED
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
SKIP_ARR_STACK=true
else
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
fi
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
else
deploy_xml_stack PARTNERSHIP_ARR_STACK
ARR_DEPLOYED=$_STACK_DEPLOYED
ARR_FAILED=$_STACK_FAILED
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
fi
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
ONBOARD_OK=true
else
error "Partnership onboard failed"
ONBOARD_OK=false
fi
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 8 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
fi
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
# SSH is now established and all partners have the plugin installed.
# Push the authoritative master.conf so every listed host is in sync immediately.
echo ""
echo "━━━ $ICON_GEAR Step 9 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf to all listed hosts"
MASTER_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
echo "master.conf sync complete ✅"
MASTER_PUSH_OK=true
else
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 2 — Plugins: $(_ok "$STEP_PLUGINS_OK")"
echo " Step 3 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 4 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 5 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
echo " Step 6 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
echo " Step 7 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 8 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 9 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
echo "$ICON_DONE DONE — partnership established ✅"
echo "Verify with: Partnership/partnership_manager.sh --status"
else
error "Setup incomplete — resolve errors above and re-run"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ONBOARD_OK" == false ]] && exit 1
exit 0
@@ -0,0 +1,650 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Onboard ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs once on both servers to establish a new partnership. Role is detected
# automatically via detect_hosts() — no flags needed to declare which side you are.
# Run on the mirror first (generates its SSH key), then on the owner to complete
# setup remotely.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# MIRROR PATH (1 step)
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
# Owner completes the rest remotely. Mirror is done.
#
# OWNER PATH (8 steps)
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
# Step 4: Stop mirror arr — stop mirror's existing arr containers before replacing
# Step 5: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
# Step 6: Partnership onboard — configure WebUIs → owner IP, write state, Emby
# Step 7: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 8: Conf push — push master.conf + setup state to all listed hosts
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials never in SSH command strings
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
# locally, SCPed to the remote, and executed there. Command-line args are never used
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
#
# XML templates are the single source of truth for deployed containers
# The owner's templates-user/ XMLs define every container deployed on the mirror.
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
# Docker Manager can manage the containers after onboard without additional config.
#
# Dependency ordering in the auth stack is owner-enforced
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
# the script waits for the container to be healthy before continuing. This is a remote
# health check — the container must be running (or report healthy) before the next
# dependent is deployed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check
# All operations run as root — SSH key management, docker operations, conf updates.
#
# SSH timeout on all remote calls
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
# slow or unreachable mirror.
#
# --dry-run shows exact actions without executing
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
# all dry-run safe.
#
# Step skip flags for partial re-runs
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
# resuming after a partial failure without re-running completed steps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# XML filenames (from this server's templates-user/) to push and deploy on the
# mirror as its auth stack. Order matters: database deps before Authelia.
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
#
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
# Containers to stop on the mirror before deploying the auth stack.
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_ARR_STACK
# XML filenames to push and deploy on the mirror as its arr stack.
# Leave empty to skip arr stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
#
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
# Arr containers to stop on the mirror before deploying the arr stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_onboard.sh
# Full onboard — role detected automatically
#
# Partnership/partnership_onboard.sh --dry-run
# Preview all steps without making changes
#
# Partnership/partnership_onboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_onboard.sh --skip-ssh
# Skip SSH key setup (key already in place)
#
# Partnership/partnership_onboard.sh --skip-auth-stack
# Skip auth stack stop + deploy (Steps 3-4)
#
# Partnership/partnership_onboard.sh --skip-arr-stack
# Skip arr stack stop + deploy (Steps 5-6)
#
# Partnership/partnership_onboard.sh --skip-arr-sync
# Skip arr library bootstrap (Step 8)
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
SKIP_AUTH_STACK=false
SKIP_ARR_STACK=false
SKIP_ARR_SYNC=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--skip-ssh) SKIP_SSH=true ;;
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
--skip-arr-stack) SKIP_ARR_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key.
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
write_onboard_phase() {
local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE"
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
command -v php &>/dev/null && \
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
# use the same convention), and reads the named config array from the mirror's own conf.
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
#
# deploy_container_from_xml() already stops/removes containers with the same name as what's
# being deployed. This step handles containers with DIFFERENT names that conflict.
# ==============================================================================================
stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
return 0
fi
log "Stopping $label on $MIRROR: ${to_stop[*]}"
for container in "${to_stop[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
log " $container removed ✅" || \
log " $container not found on $MIRROR — skipping"
done
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
echo ""
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH key ready ✅"
else
error "SSH key setup failed"
exit 1
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
echo ""
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
PHASE2_TRIGGERED=false
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
log "Phase 2 triggered on $OWNER ✅"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
PHASE2_TRIGGERED=true
else
warn "Could not auto-trigger Phase 2 on $OWNER"
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
if [[ "$PHASE2_TRIGGERED" == false ]]; then
echo ""
echo " Run manually on $OWNER:"
echo " bash Partnership/partnership_onboard.sh --phase2-only"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
echo ""
STEP_SSH_OK=false
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
AUTH_FAILED=0
STEP_STOP_ARR_OK=true
STEP_ARR_OK=true
ARR_DEPLOYED=0
ARR_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
MASTER_PUSH_OK=false
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
# Skipped when --phase2-only (SSH was already done in Phase 1).
echo "━━━ Step 1 — SSH Key Setup ━━━"
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping (--skip-ssh)"
STEP_SSH_OK=true
elif [[ "$PHASE1_ONLY" == true ]]; then
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
# hanging for a password prompt with no TTY.
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
log "SSH to $MIRROR already works ✅ — skipping key install"
STEP_SSH_OK=true
else
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
# Soft-fail: generate key locally if not present, then tell user to install manually
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
if [[ -f "$SSH_KEY" ]]; then
log "Local key exists at: $SSH_KEY"
else
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
fi
if [[ -f "${SSH_KEY}.pub" ]]; then
echo ""
echo " Install this key on $MIRROR to complete SSH setup:"
echo " ┌─────────────────────────────────────────────────────"
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
echo " └─────────────────────────────────────────────────────"
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
}
fi
STEP_SSH_OK=false
fi
fi
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
error "SSH key setup failed — aborting"
error "Re-run or use --skip-ssh if key is already set up"
exit 1
fi
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
if [[ "$PHASE1_ONLY" == true ]]; then
if [[ "$STEP_SSH_OK" == false ]]; then
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in Partnership tab, or run:"
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Phase 1 — Conf Push ━━━"
CONF_PUSH_OK=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf + state file to $MIRROR"
CONF_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
log "Conf push complete ✅"
CONF_PUSH_OK=true
else
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
else
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 3 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
STEP_AUTH_OK=false
else
deploy_xml_stack PARTNERSHIP_AUTH_STACK
AUTH_DEPLOYED=$_STACK_DEPLOYED
AUTH_FAILED=$_STACK_FAILED
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
SKIP_ARR_STACK=true
else
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
fi
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
else
deploy_xml_stack PARTNERSHIP_ARR_STACK
ARR_DEPLOYED=$_STACK_DEPLOYED
ARR_FAILED=$_STACK_FAILED
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
fi
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
ONBOARD_OK=true
else
error "Partnership onboard failed"
ONBOARD_OK=false
fi
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
fi
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
# SSH is now established and all partners have the plugin installed.
# Push the authoritative master.conf so every listed host is in sync immediately.
echo ""
echo "━━━ $ICON_GEAR Step 8 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf to all listed hosts"
MASTER_PUSH_OK=true
elif ! command -v php &>/dev/null; then
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
else
push_output=$(php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
\$results = vv_push_master_conf();
vv_push_setup_state();
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
\$failed = 0;
foreach (\$results as \$r) {
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
if (!\$r['ok']) \$failed++;
}
exit(\$failed > 0 ? 1 : 0);
" 2>/dev/null)
push_rc=$?
echo "$push_output"
if [[ $push_rc -eq 0 ]]; then
echo "master.conf sync complete ✅"
MASTER_PUSH_OK=true
else
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 2 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 3 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 4 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
echo " Step 5 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
echo " Step 6 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 7 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 8 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
echo "$ICON_DONE DONE — partnership established ✅"
echo "Verify with: Partnership/partnership_manager.sh --status"
else
error "Setup incomplete — resolve errors above and re-run"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ONBOARD_OK" == false ]] && exit 1
exit 0
@@ -0,0 +1,192 @@
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🤝 PARTNERSHIP
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Managed lifecycle for a two-server partnership — setup, ongoing operation,
and clean separation.** One server owns the shared services. The other mirrors
them and benefits from them. Every phase of the relationship has the same
engineering discipline as the rest of the ecosystem.
> **This folder exists because a clean exit should be as easy as a clean setup.**
> The partnership is not a permanent commitment. `--offboard` works from either
> server at any time. Everything the mirror needs to run independently is already
> there. The only thing that stops on separation is the sync — and that's intentional.
---
## ━━━ THE PROBLEM THAT BUILT THIS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
---
### 🔴 Two Servers, One Auth Stack, No Clean Way to Share It
The auth stack — NginxProxyManager, LLDAP, Authelia, MariaDB, Redis — runs on
HOST1. HOST2 serves its own domain to its own household. It needs its own auth.
But maintaining two independent auth stacks means double the work: two places to
add users, two places to update proxy rules, two places to renew certs, two
configurations that inevitably drift apart. One change on HOST1 has to be manually
replicated to HOST2 — or it isn't, and the configurations diverge silently.
The real cost isn't the initial setup. It's the maintenance burden that accumulates
over months — every new user, every proxy rule change, every config update applied
in one place and forgotten in the other.
**The fix:** one auth stack with a managed mirror. HOST1 owns the configuration.
HOST2 runs a warm copy that stays current via 30-minute sync. HOST2's operator makes
zero auth management decisions — clicking an auth container opens HOST1's WebUI via
Tailscale. Changes happen there, propagate to HOST2 in 30 minutes. One place to
manage everything for both households.
---
### 🔴 No Structure Around the Relationship Itself
Setting up the mirror was a manual process. SSH in, reconfigure container WebUI
URLs one by one, copy auth config, verify connectivity, update state tracking. No
defined sequence. No dry-run capability. No verification that each step worked. If
something went wrong midway, the mirror was in an inconsistent state with no clear
way to understand what had and hadn't been done. Offboard was worse — it involves
stopping a sync that's been running for months, making a final copy of data,
reconfiguring WebUIs back to local addresses, removing Tailscale access, and
notifying both servers. A manual process with that many steps, taken under pressure,
leaves one or both parties in a bad state.
**The fix:** `partnership_manager.sh` with explicit modes for each lifecycle phase.
Each mode is a defined sequence. Every step is verified. Dry-run shows exactly what
will happen before anything changes. State files make the current relationship status
unambiguous from either server.
---
### 🔴 No Safe Way to Check If the Other Server Has Gone Away
After months of operation, HOST2 goes quiet. The sync starts failing. The offline
counter increments. But nothing actually happens — the ecosystem just keeps failing
the same sync, incrementing the same counter, sending the same notifications.
Without a defined threshold and an automated response, "partner gone for 30 days"
looks exactly like "partner gone for 3 years."
**The fix:** `PARTNERSHIP_OFFLINE_THRESHOLD`. After this many days of missed sync
cycles, both servers independently auto-offboard. HOST1 removes HOST2 from Tailscale,
disables critical sync, writes INACTIVE state. HOST2 — if it eventually comes back —
reads HOST1's INACTIVE state and cleans up its own side. The relationship is formally
ended from both sides without anyone needing to be present.
---
### 🔴 Ownership Transfer Had No Safe Path
The arrangement was always intended to be flexible — HOST1 owns the auth stack now,
but circumstances change. Swapping ownership manually meant reconfiguring WebUIs on
both servers, swapping sync direction, updating master.conf on both, and hoping the
sequence was correct. A misstep — like flipping sync direction before the final sync
completed — leaves both servers with different auth configurations and no clear source
of truth.
**The fix:** `--transfer` with a required confirmation string, a consecutive health
check system, and a strict sequence. The confirmation string cannot be typed
accidentally. Health strikes require both servers to be healthy on multiple
consecutive checks before the transfer begins. A final sync in the current direction
runs before anything is flipped.
---
## ━━━ WHAT THIS FOLDER DOES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Onboarding** (`partnership_onboard.sh`) — one-time setup run from both servers.
Generates SSH keys, installs the auth stack and arr stack on the mirror from XML
templates, configures WebUI redirects to the owner, and bootstraps the arr library.
Role is detected automatically — no flags needed to declare which side you are.
**Offboarding** (`partnership_offboard.sh`) — handles clean separation from either
role. Owner path: final sync, WebUI reconfigure, remote container + appdata cleanup
(auth/arr stack by XML array, fallback coverage by naming), SSH key revocation,
Tailscale removal. Mirror path: local WebUI reconfigure, remove owner-deployed
containers locally, disable sync, revoke Emby admin, SSH key revocation, signal owner.
Called by `partnership_manager.sh --offboard` but runnable directly.
**Lifecycle management** (`partnership_manager.sh`) — dispatcher and monitor.
- Manually: `--onboard`, `--offboard` (delegates to offboard script), `--transfer`, `--status`, `--unblock`
- Automatically: `--check` called every 30 minutes by `critical_sync_maintenance.sh`
**SSH management** (`ssh_setup.sh`) — generates the keypair for rsync automation,
installs it on the remote, and tracks auth failures with a configurable strike system.
Called by `partnership_onboard.sh` but runnable independently for validation and
re-keying.
---
## ━━━ RELATIONSHIP TO OTHER FOLDERS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Orchestrators/** — `critical_sync_maintenance.sh` calls `partnership_manager.sh --check`
every 30 minutes, passing `--remote-seen` or `--remote-unseen` based on whether the
rsync to the partner succeeded. The rsync outcome is the connectivity signal — no
separate ping needed.
**Rsync/** — Critical-Data rsync keeps the auth stack appdata current on the mirror
(NPM rules, Authelia config, LLDAP database, certs). Partnership manages the
relationship; Rsync delivers the actual data. On offboard, `rsync_stop.sh --rsync-only`
stops any running rsync before the final sync runs.
**Media/** — `arr_sync.sh` bootstraps the mirror's arr library during onboard, ensuring
both servers have each other's full library from day one. Ongoing arr sync runs
independently at 4-hour cadence.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| Script | Role | When It Runs |
|--------|------|--------------|
| `partnership_onboard.sh` | One-time setup — SSH keys, stack deploy, arr bootstrap | Manually, once per server per partnership |
| `partnership_offboard.sh` | Clean separation — both paths, both roles | Via `partnership_manager.sh --offboard`; or directly |
| `partnership_manager.sh` | Dispatcher + monitor — onboard WebUIs, health check, transfer, status | `--check` every 30min; all other modes manually |
| `ssh_setup.sh` | SSH key generation, remote install, auth validation | Called by onboard; manually for re-keying or validation |
---
## ━━━ HOW THE SCRIPTS RELATE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
INITIAL SETUP (run once)
─────────────────────────────────────────────────────────────────────────────
HOST2 (mirror) runs:
partnership_onboard.sh
└─ ssh_setup.sh generates keypair, copies to owner
HOST1 (owner) runs:
partnership_onboard.sh
├─ ssh_setup.sh generates keypair, copies to mirror
├─ [plugin install on mirror] FolderView3 if configured
├─ [stop mirror auth stack] PARTNERSHIP_REPLACE_CONTAINERS via SSH
├─ deploy_container_from_xml() pushes auth XMLs to mirror + starts containers
│ └─ wait_for_container_healthy() Mariadb/Redis health-checked before Authelia
├─ [stop mirror arr stack] PARTNERSHIP_ARR_REPLACE_CONTAINERS via SSH
├─ deploy_container_from_xml() pushes arr XMLs to mirror + starts containers
├─ partnership_manager.sh --onboard reconfigures WebUIs, writes ACTIVE state
└─ arr_sync.sh bootstraps full library on both servers
ONGOING OPERATION (every 30min)
─────────────────────────────────────────────────────────────────────────────
critical_sync_maintenance.sh
├─ Critical-Data rsync keeps auth appdata current on mirror
└─ partnership_manager.sh --check reads state files, tracks offline counter
├─ --remote-seen path rsync succeeded → reset counter
└─ --remote-unseen path rsync failed → increment counter → auto-offboard at threshold
OFFBOARD (manual or auto)
─────────────────────────────────────────────────────────────────────────────
partnership_manager.sh --offboard
└─ partnership_offboard.sh (exec'd — holds own lock)
├─ [owner-initiated] stop rsync → final sync → reconfigure mirror WebUIs
│ → disable critical sync → write INACTIVE state
│ → local fallback cleanup → restart own stack
│ → remote: remove auth/arr stack + fallback containers + appdata
│ → restart mirror stack → Emby revoke → SSH key revocation
│ → Tailscale removal after grace window
└─ [mirror-initiated] stop rsync → reconfigure own WebUIs
→ remove owner-deployed containers locally (reads owner's stack arrays)
→ remove local fallback containers → disable critical sync
→ revoke own Emby admin → restart own stack
→ SSH key revocation → write INACTIVE → signal owner
```
@@ -0,0 +1,191 @@
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🤝 PARTNERSHIP
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Managed lifecycle for a two-server partnership — setup, ongoing operation,
and clean separation.** One server owns the shared services. The other mirrors
them and benefits from them. Every phase of the relationship has the same
engineering discipline as the rest of the ecosystem.
> **This folder exists because a clean exit should be as easy as a clean setup.**
> The partnership is not a permanent commitment. `--offboard` works from either
> server at any time. Everything the mirror needs to run independently is already
> there. The only thing that stops on separation is the sync — and that's intentional.
---
## ━━━ THE PROBLEM THAT BUILT THIS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
---
### 🔴 Two Servers, One Auth Stack, No Clean Way to Share It
The auth stack — NginxProxyManager, LLDAP, Authelia, MariaDB, Redis — runs on
HOST1. HOST2 serves its own domain to its own household. It needs its own auth.
But maintaining two independent auth stacks means double the work: two places to
add users, two places to update proxy rules, two places to renew certs, two
configurations that inevitably drift apart. One change on HOST1 has to be manually
replicated to HOST2 — or it isn't, and the configurations diverge silently.
The real cost isn't the initial setup. It's the maintenance burden that accumulates
over months — every new user, every proxy rule change, every config update applied
in one place and forgotten in the other.
**The fix:** one auth stack with a managed mirror. HOST1 owns the configuration.
HOST2 runs a warm copy that stays current via 30-minute sync. HOST2's operator makes
zero auth management decisions — clicking an auth container opens HOST1's WebUI via
Tailscale. Changes happen there, propagate to HOST2 in 30 minutes. One place to
manage everything for both households.
---
### 🔴 No Structure Around the Relationship Itself
Setting up the mirror was a manual process. SSH in, reconfigure container WebUI
URLs one by one, copy auth config, verify connectivity, update state tracking. No
defined sequence. No dry-run capability. No verification that each step worked. If
something went wrong midway, the mirror was in an inconsistent state with no clear
way to understand what had and hadn't been done. Offboard was worse — it involves
stopping a sync that's been running for months, making a final copy of data,
reconfiguring WebUIs back to local addresses, removing Tailscale access, and
notifying both servers. A manual process with that many steps, taken under pressure,
leaves one or both parties in a bad state.
**The fix:** `partnership_manager.sh` with explicit modes for each lifecycle phase.
Each mode is a defined sequence. Every step is verified. Dry-run shows exactly what
will happen before anything changes. State files make the current relationship status
unambiguous from either server.
---
### 🔴 No Safe Way to Check If the Other Server Has Gone Away
After months of operation, HOST2 goes quiet. The sync starts failing. The offline
counter increments. But nothing actually happens — the ecosystem just keeps failing
the same sync, incrementing the same counter, sending the same notifications.
Without a defined threshold and an automated response, "partner gone for 30 days"
looks exactly like "partner gone for 3 years."
**The fix:** `PARTNERSHIP_OFFLINE_THRESHOLD`. After this many days of missed sync
cycles, both servers independently auto-offboard. HOST1 removes HOST2 from Tailscale,
disables critical sync, writes INACTIVE state. HOST2 — if it eventually comes back —
reads HOST1's INACTIVE state and cleans up its own side. The relationship is formally
ended from both sides without anyone needing to be present.
---
### 🔴 Ownership Transfer Had No Safe Path
The arrangement was always intended to be flexible — HOST1 owns the auth stack now,
but circumstances change. Swapping ownership manually meant reconfiguring WebUIs on
both servers, swapping sync direction, updating master.conf on both, and hoping the
sequence was correct. A misstep — like flipping sync direction before the final sync
completed — leaves both servers with different auth configurations and no clear source
of truth.
**The fix:** `--transfer` with a required confirmation string, a consecutive health
check system, and a strict sequence. The confirmation string cannot be typed
accidentally. Health strikes require both servers to be healthy on multiple
consecutive checks before the transfer begins. A final sync in the current direction
runs before anything is flipped.
---
## ━━━ WHAT THIS FOLDER DOES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Onboarding** (`partnership_onboard.sh`) — one-time setup run from both servers.
Generates SSH keys, installs the auth stack and arr stack on the mirror from XML
templates, configures WebUI redirects to the owner, and bootstraps the arr library.
Role is detected automatically — no flags needed to declare which side you are.
**Offboarding** (`partnership_offboard.sh`) — handles clean separation from either
role. Owner path: final sync, WebUI reconfigure, remote container + appdata cleanup
(auth/arr stack by XML array, fallback coverage by naming), SSH key revocation,
Tailscale removal. Mirror path: local WebUI reconfigure, remove owner-deployed
containers locally, disable sync, revoke Emby admin, SSH key revocation, signal owner.
Called by `partnership_manager.sh --offboard` but runnable directly.
**Lifecycle management** (`partnership_manager.sh`) — dispatcher and monitor.
- Manually: `--onboard`, `--offboard` (delegates to offboard script), `--transfer`, `--status`, `--unblock`
- Automatically: `--check` called every 30 minutes by `critical_sync_maintenance.sh`
**SSH management** (`ssh_setup.sh`) — generates the keypair for rsync automation,
installs it on the remote, and tracks auth failures with a configurable strike system.
Called by `partnership_onboard.sh` but runnable independently for validation and
re-keying.
---
## ━━━ RELATIONSHIP TO OTHER FOLDERS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Orchestrators/** — `critical_sync_maintenance.sh` calls `partnership_manager.sh --check`
every 30 minutes, passing `--remote-seen` or `--remote-unseen` based on whether the
rsync to the partner succeeded. The rsync outcome is the connectivity signal — no
separate ping needed.
**Rsync/** — Critical-Data rsync keeps the auth stack appdata current on the mirror
(NPM rules, Authelia config, LLDAP database, certs). Partnership manages the
relationship; Rsync delivers the actual data. On offboard, `rsync_stop.sh --rsync-only`
stops any running rsync before the final sync runs.
**Media/** — `arr_sync.sh` bootstraps the mirror's arr library during onboard, ensuring
both servers have each other's full library from day one. Ongoing arr sync runs
independently at 4-hour cadence.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| Script | Role | When It Runs |
|--------|------|--------------|
| `partnership_onboard.sh` | One-time setup — SSH keys, stack deploy, arr bootstrap | Manually, once per server per partnership |
| `partnership_offboard.sh` | Clean separation — both paths, both roles | Via `partnership_manager.sh --offboard`; or directly |
| `partnership_manager.sh` | Dispatcher + monitor — onboard WebUIs, health check, transfer, status | `--check` every 30min; all other modes manually |
| `ssh_setup.sh` | SSH key generation, remote install, auth validation | Called by onboard; manually for re-keying or validation |
---
## ━━━ HOW THE SCRIPTS RELATE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
INITIAL SETUP (run once)
─────────────────────────────────────────────────────────────────────────────
HOST2 (mirror) runs:
partnership_onboard.sh
└─ ssh_setup.sh generates keypair, copies to owner
HOST1 (owner) runs:
partnership_onboard.sh
├─ ssh_setup.sh generates keypair, copies to mirror
├─ [stop mirror auth stack] PARTNERSHIP_REPLACE_CONTAINERS via SSH
├─ deploy_container_from_xml() pushes auth XMLs to mirror + starts containers
│ └─ wait_for_container_healthy() Mariadb/Redis health-checked before Authelia
├─ [stop mirror arr stack] PARTNERSHIP_ARR_REPLACE_CONTAINERS via SSH
├─ deploy_container_from_xml() pushes arr XMLs to mirror + starts containers
├─ partnership_manager.sh --onboard reconfigures WebUIs, writes ACTIVE state
└─ arr_sync.sh bootstraps full library on both servers
ONGOING OPERATION (every 30min)
─────────────────────────────────────────────────────────────────────────────
critical_sync_maintenance.sh
├─ Critical-Data rsync keeps auth appdata current on mirror
└─ partnership_manager.sh --check reads state files, tracks offline counter
├─ --remote-seen path rsync succeeded → reset counter
└─ --remote-unseen path rsync failed → increment counter → auto-offboard at threshold
OFFBOARD (manual or auto)
─────────────────────────────────────────────────────────────────────────────
partnership_manager.sh --offboard
└─ partnership_offboard.sh (exec'd — holds own lock)
├─ [owner-initiated] stop rsync → final sync → reconfigure mirror WebUIs
│ → disable critical sync → write INACTIVE state
│ → local fallback cleanup → restart own stack
│ → remote: remove auth/arr stack + fallback containers + appdata
│ → restart mirror stack → Emby revoke → SSH key revocation
│ → Tailscale removal after grace window
└─ [mirror-initiated] stop rsync → reconfigure own WebUIs
→ remove owner-deployed containers locally (reads owner's stack arrays)
→ remove local fallback containers → disable critical sync
→ revoke own Emby admin → restart own stack
→ SSH key revocation → write INACTIVE → signal owner
```
@@ -0,0 +1,284 @@
#!/bin/bash
# ==============================================================================================
# ============================= Certificate Monitor ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# SSL certificate expiry monitoring for all configured domains. Scheduled weekly
# (Sunday 9am). Connects via openssl directly to each domain — not to NPM's API,
# not to any internal check, but to the actual TLS handshake the outside world sees.
#
# Per domain: HEALTHY (> CERT_WARN_DAYS remaining, silent) | WARNING (≤ CERT_WARN_DAYS)
# | CRITICAL (≤ CERT_CRIT_DAYS) | FAILED (could not connect or parse cert).
# Notifications batched by severity — one message lists all WARNING domains, a
# separate message lists all CRITICAL domains. Not one notification per domain.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Direct openssl, Not an API
# API-based cert checks ask the certificate manager whether the cert is valid.
# openssl checks ask the server what cert it is actually serving. These are not
# the same question and the answers can differ. Catches: cert renewed in NPM but
# server not reloaded (old cert still serving), wrong cert being served to external
# clients, chain issues visible externally but not internally, NPM reporting healthy
# while the outside world sees an expired cert.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent runs producing duplicate notifications.
#
# Per-Host Domain List
# detect_hosts() aliases HOST*_CERT_MONITOR_DOMAINS → CERT_MONITOR_DOMAINS.
# Each server monitors its own domains only.
#
# Empty Array Guard
# Warns and exits cleanly if CERT_MONITOR_DOMAINS is empty — no silent no-op.
#
# Connection Timeout
# CERT_TIMEOUT caps each openssl connection attempt. One unreachable domain
# does not block the remaining domains.
#
# Notification Validated
# platform_require_cmd confirms openssl and notify script are present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_CERT_MONITOR_DOMAINS
# Domains this host monitors. Each domain and subdomain is a separate entry —
# they have independent certs. Aliased by detect_hosts() → CERT_MONITOR_DOMAINS.
#
# master.conf
#
# CERT_WARN_DAYS
# Days before expiry at which to send a warning notification. (default: 30)
#
# CERT_CRIT_DAYS
# Days before expiry at which to send a critical notification. (default: 7)
#
# CERT_TIMEOUT
# Seconds to wait per domain before declaring FAILED. (default: 10)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# cert_monitor.sh
# Check all configured domains and notify on WARNING, CRITICAL, or FAILED.
# Silent when all domains are healthy.
#
# cert_monitor.sh --dry-run
# Check all domains and show results. No notifications sent regardless of result.
#
# cert_monitor.sh --status
# Show domain list, warning thresholds, and timeout. Then exit.
#
# cert_monitor.sh --log
# Verbose per-domain output during the run.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Validate openssl — required for all cert checks
platform_require_cmd \
"$(command -v openssl 2>/dev/null || echo /usr/bin/openssl)" \
"version" "OpenSSL" \
"openssl" || { error "openssl not found — required for certificate checks"; exit 1; }
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
# detect_hosts() sets MY_ID and aliases HOST*_CERT_MONITOR_DOMAINS
detect_hosts
# Empty array guard
if [[ ${#CERT_MONITOR_DOMAINS[@]} -eq 0 ]]; then
warn "CERT_MONITOR_DOMAINS is empty for $MY_ID"
warn "Check HOST*_CERT_MONITOR_DOMAINS in host*.conf"
exit 0
fi
info "Domains to check: ${#CERT_MONITOR_DOMAINS[@]}"
log "$ICON_GEAR Config: warn=${CERT_WARN_DAYS}d crit=${CERT_CRIT_DAYS}d timeout=${CERT_TIMEOUT}s"
log "$ICON_GEAR Domains: ${CERT_MONITOR_DOMAINS[*]}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — results shown but no notifications sent"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_CERT Domains: ${CERT_MONITOR_DOMAINS[*]}"
echo "$ICON_WARN Warn at: ${CERT_WARN_DAYS} days remaining"
echo "$ICON_ERROR Crit at: ${CERT_CRIT_DAYS} days remaining"
echo "$ICON_TIME Timeout: ${CERT_TIMEOUT}s per domain"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── CERT CHECK FUNCTION ───────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Connects to domain:443 via openssl, extracts expiry date, calculates days remaining.
# Returns:
# 0 = healthy (> CERT_WARN_DAYS)
# 1 = warning (<= CERT_WARN_DAYS)
# 2 = critical (<= CERT_CRIT_DAYS)
# 3 = failed (could not connect or parse)
check_cert() {
local domain="$1"
local port="${2:-443}"
local expiry_str
expiry_str=$(echo | timeout "$CERT_TIMEOUT" openssl s_client \
-connect "${domain}:${port}" \
-servername "$domain" \
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -z "$expiry_str" ]]; then
error "$ICON_CERT $domain — could not retrieve certificate (unreachable or no TLS)"
return 3
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
if [[ -z "$expiry_epoch" ]]; then
error "$ICON_CERT $domain — could not parse expiry date: $expiry_str"
return 3
fi
local now days_remaining expiry_display
now=$(date +%s)
days_remaining=$(( (expiry_epoch - now) / 86400 ))
expiry_display=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
if [[ "$days_remaining" -le "$CERT_CRIT_DAYS" ]]; then
error "$ICON_CERT $domain — CRITICAL: ${days_remaining} days remaining (expires $expiry_display)"
return 2
elif [[ "$days_remaining" -le "$CERT_WARN_DAYS" ]]; then
warn "$ICON_CERT $domain — WARNING: ${days_remaining} days remaining (expires $expiry_display)"
return 1
else
log "$ICON_CERT $domain — OK: ${days_remaining} days remaining (expires $expiry_display)"
return 0
fi
}
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CERT Certificate Monitor — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
info "Warn threshold: ${CERT_WARN_DAYS} days"
info "Crit threshold: ${CERT_CRIT_DAYS} days"
echo ""
START=$(date +%s)
HEALTHY=()
WARNING=()
CRITICAL=()
FAILED=()
declare -A DOMAIN_STATUS
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$domain" ]] && continue
check_cert "$domain"
result=$?
case $result in
0) HEALTHY+=("$domain"); DOMAIN_STATUS["$domain"]="OK" ;;
1) WARNING+=("$domain"); DOMAIN_STATUS["$domain"]="WARN" ;;
2) CRITICAL+=("$domain"); DOMAIN_STATUS["$domain"]="CRIT" ;;
3) FAILED+=("$domain"); DOMAIN_STATUS["$domain"]="FAIL" ;;
esac
done
END=$(date +%s)
# ── Send notifications — batched per severity ─────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
[[ ${#CRITICAL[@]} -gt 0 ]] && \
notify "Certificate CRITICAL on $(hostname) — expiring within ${CERT_CRIT_DAYS} days: ${CRITICAL[*]}" \
"Certificate Monitor" "warning"
[[ ${#WARNING[@]} -gt 0 ]] && \
notify "Certificate WARNING on $(hostname) — expiring within ${CERT_WARN_DAYS} days: ${WARNING[*]}" \
"Certificate Monitor" "warning"
[[ ${#FAILED[@]} -gt 0 ]] && \
notify "Certificate check FAILED on $(hostname) — could not reach: ${FAILED[*]}" \
"Certificate Monitor" "warning"
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY CERTIFICATE MONITOR SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
echo " $ICON_SUCCESS Healthy: ${#HEALTHY[@]}"
[[ ${#WARNING[@]} -gt 0 ]] && warn "Warning: ${#WARNING[@]} — renewal recommended"
[[ ${#CRITICAL[@]} -gt 0 ]] && echo "$ICON_ERROR Critical: ${#CRITICAL[@]} — ACTION REQUIRED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${#FAILED[@]} — unreachable"
echo ""
# Per-domain results — only show problems, healthy ones stay in log()
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$domain" ]] && continue
case "${DOMAIN_STATUS[$domain]:-UNKN}" in
OK) log " $ICON_SUCCESS $domain — healthy" ;;
WARN) warn " $ICON_WARN $domain — warning" ;;
CRIT) echo " $ICON_ERROR $domain — CRITICAL" ;;
FAIL) echo " $ICON_ERROR $domain — unreachable" ;;
esac
done
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no notifications sent"
elif [[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: ACTION REQUIRED"
elif [[ ${#WARNING[@]} -gt 0 ]]; then
warn "Status: WARNINGS — renewal recommended"
else
echo "$ICON_DONE Status: all ${#HEALTHY[@]} certs healthy ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,309 @@
#!/bin/bash
# ==============================================================================================
# ============================= Certificate Monitor ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# SSL certificate expiry monitoring for all configured domains. Scheduled weekly
# (Sunday 9am). Connects via openssl directly to each domain — not to NPM's API,
# not to any internal check, but to the actual TLS handshake the outside world sees.
#
# Per domain: HEALTHY (> CERT_WARN_DAYS remaining, silent) | WARNING (≤ CERT_WARN_DAYS)
# | CRITICAL (≤ CERT_CRIT_DAYS) | FAILED (could not connect or parse cert).
# Notifications batched by severity — one message lists all WARNING domains, a
# separate message lists all CRITICAL domains. Not one notification per domain.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Direct openssl, Not an API
# API-based cert checks ask the certificate manager whether the cert is valid.
# openssl checks ask the server what cert it is actually serving. These are not
# the same question and the answers can differ. Catches: cert renewed in NPM but
# server not reloaded (old cert still serving), wrong cert being served to external
# clients, chain issues visible externally but not internally, NPM reporting healthy
# while the outside world sees an expired cert.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent runs producing duplicate notifications.
#
# Per-Host Domain List
# detect_hosts() aliases HOST*_CERT_MONITOR_DOMAINS → CERT_MONITOR_DOMAINS.
# Each server monitors its own domains only.
#
# Empty Array Guard
# Warns and exits cleanly if CERT_MONITOR_DOMAINS is empty — no silent no-op.
#
# Connection Timeout
# CERT_TIMEOUT caps each openssl connection attempt. One unreachable domain
# does not block the remaining domains.
#
# Notification Validated
# platform_require_cmd confirms openssl and notify script are present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_CERT_MONITOR_DOMAINS
# Domains this host monitors. Each domain and subdomain is a separate entry —
# they have independent certs. Aliased by detect_hosts() → CERT_MONITOR_DOMAINS.
#
# master.conf
#
# CERT_WARN_DAYS
# Days before expiry at which to send a warning notification. (default: 30)
#
# CERT_CRIT_DAYS
# Days before expiry at which to send a critical notification. (default: 7)
#
# CERT_TIMEOUT
# Seconds to wait per domain before declaring FAILED. (default: 10)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# cert_monitor.sh
# Check all configured domains and notify on WARNING, CRITICAL, or FAILED.
# Silent when all domains are healthy.
#
# cert_monitor.sh --dry-run
# Check all domains and show results. No notifications sent regardless of result.
#
# cert_monitor.sh --status
# Show domain list, warning thresholds, and timeout. Then exit.
#
# cert_monitor.sh --log
# Verbose per-domain output during the run.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Validate openssl — required for all cert checks
platform_require_cmd \
"$(command -v openssl 2>/dev/null || echo /usr/bin/openssl)" \
"version" "OpenSSL" \
"openssl" || { error "openssl not found — required for certificate checks"; exit 1; }
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
# detect_hosts() sets MY_ID and aliases HOST*_CERT_MONITOR_DOMAINS
detect_hosts
# Empty array guard
if [[ ${#CERT_MONITOR_DOMAINS[@]} -eq 0 ]]; then
warn "CERT_MONITOR_DOMAINS is empty for $MY_ID"
warn "Check HOST*_CERT_MONITOR_DOMAINS in host*.conf"
exit 0
fi
info "Domains to check: ${#CERT_MONITOR_DOMAINS[@]}"
log "$ICON_GEAR Config: warn=${CERT_WARN_DAYS}d crit=${CERT_CRIT_DAYS}d timeout=${CERT_TIMEOUT}s"
log "$ICON_GEAR Domains: ${CERT_MONITOR_DOMAINS[*]}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — results shown but no notifications sent"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_CERT Domains: ${CERT_MONITOR_DOMAINS[*]}"
echo "$ICON_WARN Warn at: ${CERT_WARN_DAYS} days remaining"
echo "$ICON_ERROR Crit at: ${CERT_CRIT_DAYS} days remaining"
echo "$ICON_TIME Timeout: ${CERT_TIMEOUT}s per domain"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── CERT CHECK FUNCTION ───────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Connects to domain:443 via openssl, extracts expiry date, calculates days remaining.
# Returns:
# 0 = healthy (> CERT_WARN_DAYS)
# 1 = warning (<= CERT_WARN_DAYS)
# 2 = critical (<= CERT_CRIT_DAYS)
# 3 = failed (could not connect or parse)
check_cert() {
local domain="$1"
local port="${2:-443}"
_CERT_DAYS=""
_CERT_EXPIRY=""
local expiry_str
expiry_str=$(echo | timeout "$CERT_TIMEOUT" openssl s_client \
-connect "${domain}:${port}" \
-servername "$domain" \
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -z "$expiry_str" ]]; then
error "$ICON_CERT $domain — could not retrieve certificate (unreachable or no TLS)"
return 3
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
if [[ -z "$expiry_epoch" ]]; then
error "$ICON_CERT $domain — could not parse expiry date: $expiry_str"
return 3
fi
local now days_remaining expiry_display
now=$(date +%s)
days_remaining=$(( (expiry_epoch - now) / 86400 ))
expiry_display=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
_CERT_DAYS=$days_remaining
_CERT_EXPIRY=$expiry_display
if [[ "$days_remaining" -le "$CERT_CRIT_DAYS" ]]; then
error "$ICON_CERT $domain — CRITICAL: ${days_remaining} days remaining (expires $expiry_display)"
return 2
elif [[ "$days_remaining" -le "$CERT_WARN_DAYS" ]]; then
warn "$ICON_CERT $domain — WARNING: ${days_remaining} days remaining (expires $expiry_display)"
return 1
else
log "$ICON_CERT $domain — OK: ${days_remaining} days remaining (expires $expiry_display)"
return 0
fi
}
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CERT Certificate Monitor — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
info "Warn threshold: ${CERT_WARN_DAYS} days"
info "Crit threshold: ${CERT_CRIT_DAYS} days"
echo ""
START=$(date +%s)
HEALTHY=()
WARNING=()
CRITICAL=()
FAILED=()
declare -A DOMAIN_STATUS DOMAIN_DAYS DOMAIN_EXPIRY
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$domain" ]] && continue
check_cert "$domain"
result=$?
DOMAIN_DAYS["$domain"]="${_CERT_DAYS:-}"
DOMAIN_EXPIRY["$domain"]="${_CERT_EXPIRY:-}"
case $result in
0) HEALTHY+=("$domain"); DOMAIN_STATUS["$domain"]="OK" ;;
1) WARNING+=("$domain"); DOMAIN_STATUS["$domain"]="WARN" ;;
2) CRITICAL+=("$domain"); DOMAIN_STATUS["$domain"]="CRIT" ;;
3) FAILED+=("$domain"); DOMAIN_STATUS["$domain"]="FAIL" ;;
esac
done
END=$(date +%s)
# ── Send notifications — batched per severity ─────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
[[ ${#CRITICAL[@]} -gt 0 ]] && \
notify "Certificate CRITICAL on $(hostname) — expiring within ${CERT_CRIT_DAYS} days: ${CRITICAL[*]}" \
"Certificate Monitor" "warning"
[[ ${#WARNING[@]} -gt 0 ]] && \
notify "Certificate WARNING on $(hostname) — expiring within ${CERT_WARN_DAYS} days: ${WARNING[*]}" \
"Certificate Monitor" "warning"
[[ ${#FAILED[@]} -gt 0 ]] && \
notify "Certificate check FAILED on $(hostname) — could not reach: ${FAILED[*]}" \
"Certificate Monitor" "warning"
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY CERTIFICATE MONITOR SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
echo " $ICON_SUCCESS Healthy: ${#HEALTHY[@]}"
[[ ${#WARNING[@]} -gt 0 ]] && warn "Warning: ${#WARNING[@]} — renewal recommended"
[[ ${#CRITICAL[@]} -gt 0 ]] && echo "$ICON_ERROR Critical: ${#CRITICAL[@]} — ACTION REQUIRED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${#FAILED[@]} — unreachable"
echo ""
# Per-domain results — only show problems, healthy ones stay in log()
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$domain" ]] && continue
case "${DOMAIN_STATUS[$domain]:-UNKN}" in
OK) log " $ICON_SUCCESS $domain — healthy" ;;
WARN) warn " $ICON_WARN $domain — warning" ;;
CRIT) echo " $ICON_ERROR $domain — CRITICAL" ;;
FAIL) echo " $ICON_ERROR $domain — unreachable" ;;
esac
done
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no notifications sent"
elif [[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: ACTION REQUIRED"
elif [[ ${#WARNING[@]} -gt 0 ]]; then
warn "Status: WARNINGS — renewal recommended"
else
echo "$ICON_DONE Status: all ${#HEALTHY[@]} certs healthy ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── Write JSON status cache ───────────────────────────────────────────────────
_CERT_CACHE_FILE="$SCRIPTS_DIR/State_Files/cert_status.json"
{
printf '{"checked_at":%d,"host":"%s","warn_days":%d,"crit_days":%d,"dry_run":%s,"domains":[\n' \
"$(date +%s)" "$MY_ID" "$CERT_WARN_DAYS" "$CERT_CRIT_DAYS" \
"$([[ $DRY_RUN == true ]] && echo true || echo false)"
_first=true
for _d in "${CERT_MONITOR_DOMAINS[@]}"; do
[[ -z "$_d" ]] && continue
[[ "$_first" != true ]] && printf ','
_first=false
_days="${DOMAIN_DAYS[$_d]:-null}"
_exp="${DOMAIN_EXPIRY[$_d]:-}"
printf '{"domain":"%s","status":"%s","days":%s,"expires":"%s"}\n' \
"$_d" "${DOMAIN_STATUS[$_d]:-UNKN}" "$_days" "$_exp"
done
printf ']}\n'
} > "$_CERT_CACHE_FILE" 2>/dev/null
[[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,136 @@
#!/bin/bash
# ==============================================================================================
# ================================= CONFIGURATION LOADER =======================================
# ==============================================================================================
# Single entry point for all configuration sourcing across the ecosystem.
# Every script sources this file instead of sourcing master.conf files directly.
#
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
# 3. Auto-discovers and sources all host*.conf files in the same directory
# Each host conf extends the shared profile arrays and adds host-specific credentials
# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
#
# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
# Without this loader every script had to explicitly source each conf file:
# source master.conf
# source host1.conf
# source host2.conf
# source common.sh
#
# Adding a new server meant updating every script.
# With this loader — add host3.conf to the git repo and every server
# auto-discovers it on next git pull. Zero script changes required. Ever.
#
# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
# 2. Commit and push to git repo
# 3. All servers pull it automatically — no other changes needed
#
# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
# Replace the three source lines at the top of every script with:
#
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/../load_config.sh"
#
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
# Scripts in root directory use ./ instead:
#
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/load_config.sh"
#
# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
# Sparse checkout controls which host*.conf files each server receives.
# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
#
# ==============================================================================================
# ━━━ Locate config root ━━━
# load_config.sh always lives in the repo root.
# Scripts call it from subdirectories using ../ — resolve to the actual root.
LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ━━━ Platform detection ━━━
# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
# Each platform adapter provides the same function API; scripts stay OS-agnostic.
if [[ -f /etc/unraid-version ]]; then PLATFORM="unraid"
elif [[ -f /etc/truenas ]]; then PLATFORM="truenas"
else PLATFORM="unknown"
fi
export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"
# ━━━ Source shared config ━━━
# master.conf must be sourced first — it declares the shared PROFILE_* arrays
# that Host confs extend. Sourcing host confs before master.conf would fail.
if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
echo "[FATAL] Check TARGET_DIR and git pull status" >&2
exit 1
fi
source "$LOAD_CONFIG_DIR/Configurations/master.conf"
# ━━━ Auto-discover and source all host*.conf files ━━━
# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
# Missing files are silently skipped — sparse checkout intentionally withholds some.
# At least one host conf must be present or the ecosystem has no identity to work with.
_host_confs_loaded=0
declare -A _disk_conf_basenames=()
# Use a sorted array glob — avoids word-splitting on paths with spaces
while IFS= read -r _conf; do
[[ -f "$_conf" ]] || continue
source "$_conf"
(( _host_confs_loaded++ ))
_disk_conf_basenames["$(basename "$_conf")"]=1
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)
if [[ "$_host_confs_loaded" -eq 0 ]]; then
echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
exit 1
fi
# ━━━ Source partner confs from /tmp cache ━━━
# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
# on array start and after any conf save. Sourcing them here makes partner vars
# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
# Confs already loaded from disk are skipped — disk copy is authoritative.
_VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
if [[ -d "$_VV_CONF_CACHE" ]]; then
while IFS= read -r _conf; do
[[ -f "$_conf" ]] || continue
_conf_base="$(basename "$_conf")"
# Skip if already sourced from disk
[[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
source "$_conf"
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
echo "[LOG] Loaded cached partner config: $_conf_base" >&2
done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
fi
unset _VV_CONF_CACHE _conf_base _disk_conf_basenames
# ━━━ Source shared functions ━━━
# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
exit 1
fi
source "$LOAD_CONFIG_DIR/common.sh"
# ━━━ Source platform adapter ━━━
# Provides platform_*() functions used by common.sh and scripts.
# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
_adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
[[ -f "$_adapter" ]] && source "$_adapter"
# ━━━ Cleanup ━━━
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR
@@ -0,0 +1,144 @@
#!/bin/bash
# ==============================================================================================
# ================================= CONFIGURATION LOADER =======================================
# ==============================================================================================
# Single entry point for all configuration sourcing across the ecosystem.
# Every script sources this file instead of sourcing master.conf files directly.
#
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
# 3. Auto-discovers and sources all host*.conf files in the same directory
# Each host conf extends the shared profile arrays and adds host-specific credentials
# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
#
# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
# Without this loader every script had to explicitly source each conf file:
# source master.conf
# source host1.conf
# source host2.conf
# source common.sh
#
# Adding a new server meant updating every script.
# With this loader — add host3.conf to the git repo and every server
# auto-discovers it on next git pull. Zero script changes required. Ever.
#
# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
# 2. Commit and push to git repo
# 3. All servers pull it automatically — no other changes needed
#
# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
# Replace the three source lines at the top of every script with:
#
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/../load_config.sh"
#
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
# Scripts in root directory use ./ instead:
#
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/load_config.sh"
#
# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
# Sparse checkout controls which host*.conf files each server receives.
# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
#
# ==============================================================================================
# ━━━ Locate config root ━━━
# load_config.sh always lives in the repo root.
# Scripts call it from subdirectories using ../ — resolve to the actual root.
LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ━━━ Platform detection ━━━
# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
# Each platform adapter provides the same function API; scripts stay OS-agnostic.
if [[ -f /etc/unraid-version ]]; then PLATFORM="unraid"
elif [[ -f /etc/truenas ]]; then PLATFORM="truenas"
else PLATFORM="unknown"
fi
export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"
# ━━━ Source shared config ━━━
# master.conf must be sourced first — it declares the shared PROFILE_* arrays
# that Host confs extend. Sourcing host confs before master.conf would fail.
if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
echo "[FATAL] Check TARGET_DIR and git pull status" >&2
exit 1
fi
source "$LOAD_CONFIG_DIR/Configurations/master.conf"
# ━━━ Auto-discover and source all host*.conf files ━━━
# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
# Missing files are silently skipped — sparse checkout intentionally withholds some.
# At least one host conf must be present or the ecosystem has no identity to work with.
_host_confs_loaded=0
declare -A _disk_conf_basenames=()
# Use a sorted array glob — avoids word-splitting on paths with spaces
while IFS= read -r _conf; do
[[ -f "$_conf" ]] || continue
source "$_conf"
(( _host_confs_loaded++ ))
_disk_conf_basenames["$(basename "$_conf")"]=1
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)
if [[ "$_host_confs_loaded" -eq 0 ]]; then
echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
exit 1
fi
# ━━━ Source partner confs from /tmp cache ━━━
# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
# on array start and after any conf save. Sourcing them here makes partner vars
# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
# Confs already loaded from disk are skipped — disk copy is authoritative.
_VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
if [[ -d "$_VV_CONF_CACHE" ]]; then
while IFS= read -r _conf; do
[[ -f "$_conf" ]] || continue
_conf_base="$(basename "$_conf")"
# Skip if already sourced from disk
[[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
source "$_conf"
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
echo "[LOG] Loaded cached partner config: $_conf_base" >&2
done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
fi
unset _VV_CONF_CACHE _conf_base _disk_conf_basenames
# ━━━ Source shared functions ━━━
# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
exit 1
fi
source "$LOAD_CONFIG_DIR/common.sh"
# ━━━ Source platform adapter ━━━
# Provides platform_*() functions used by common.sh and scripts.
# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
_adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
[[ -f "$_adapter" ]] && source "$_adapter"
# ━━━ Derived path constants ━━━
# Centralised here so every script that sources load_config.sh has them without
# re-deriving from SCRIPTS_DIR or hardcoding /var/log or /tmp paths inline.
CONF_DIR="${SCRIPTS_DIR}/Configurations"
LOG_DIR="/var/log/varaverk"
VV_CACHE_DIR="/tmp/vv_cache"
export CONF_DIR LOG_DIR VV_CACHE_DIR
# ━━━ Cleanup ━━━
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR
@@ -0,0 +1,138 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Cache Sync ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
# Credentials and partner keys live in RAM only — never on disk across hosts.
#
# On array start (default / --array-start):
# 1. Copy own conf to local cache
# 2. Pull each available partner's conf from their disk → local cache
# 3. Push own conf to each available partner's /tmp/.vv/ cache
#
# On conf save (--push-only):
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
# No pulls, no local cache rebuild.
#
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
# on next array start. Scripts source from cache for partner vars; own vars
# always come from disk (load_config.sh skips cached copy of own conf).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_sync.sh Full sync: pull from all partners + push to all partners
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
# conf_sync.sh --dry-run Show what would happen, no changes
# conf_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
PUSH_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--push-only) PUSH_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
CACHE_DIR="/tmp/.vv/config/cached/.confs"
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
SSH_TIMEOUT=10
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure cache dir exists ───────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR"
fi
# ── Copy own conf into local cache ───────────────────────────────────────────
if [[ "$PUSH_ONLY" == false ]]; then
if [[ -f "$MY_CONF" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
else
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
log "Own conf cached ✅" || warn "Failed to cache own conf"
fi
else
warn "Own conf not found: $MY_CONF"
fi
fi
# ── Per-partner sync ──────────────────────────────────────────────────────────
PUSHED=0
PULLED=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
warn "$partner_host — cannot resolve Tailscale IP, skipping"
(( FAILED++ ))
continue
fi
# ── Pull: grab partner's conf from their disk → our local cache ──────────
if [[ "$PUSH_ONLY" == false ]]; then
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_conf}" \
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
log "Pulled ${partner_slot}.conf from $partner_host ✅"
(( PULLED++ ))
else
warn "Could not pull ${partner_slot}.conf from $partner_host"
(( FAILED++ ))
fi
fi
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
continue
fi
# Ensure partner's cache dir exists, then SCP own conf into it
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"$MY_CONF" \
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
(( PUSHED++ ))
else
warn "Could not push to $partner_host"
(( FAILED++ ))
fi
done
# ── Summary ───────────────────────────────────────────────────────────────────
if [[ "$PUSH_ONLY" == true ]]; then
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
else
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
fi
@@ -0,0 +1,147 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Cache Sync ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
# Credentials and partner keys live in RAM only — never on disk across hosts.
#
# On array start (default / --array-start):
# 1. Copy own conf to local cache
# 2. Pull each available partner's conf from their disk → local cache
# 3. Push own conf to each available partner's /tmp/.vv/ cache
#
# On conf save (--push-only):
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
# No pulls, no local cache rebuild.
#
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
# on next array start. Scripts source from cache for partner vars; own vars
# always come from disk (load_config.sh skips cached copy of own conf).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_sync.sh Full sync: pull from all partners + push to all partners
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
# conf_sync.sh --pull-only Pull partner confs into local cache only (for intermediate orch)
# conf_sync.sh --dry-run Show what would happen, no changes
# conf_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
PUSH_ONLY=false
PULL_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--push-only) PUSH_ONLY=true ;;
--pull-only) PULL_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
CACHE_DIR="/tmp/.vv/config/cached/.confs"
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
SSH_TIMEOUT=10
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure cache dir exists ───────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR"
fi
# ── Copy own conf into local cache ───────────────────────────────────────────
if [[ "$PUSH_ONLY" == false ]] && [[ "$PULL_ONLY" == false ]]; then
if [[ -f "$MY_CONF" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
else
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
log "Own conf cached ✅" || warn "Failed to cache own conf"
fi
else
warn "Own conf not found: $MY_CONF"
fi
fi
# ── Per-partner sync ──────────────────────────────────────────────────────────
PUSHED=0
PULLED=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
warn "$partner_host — cannot resolve Tailscale IP, skipping"
(( FAILED++ ))
continue
fi
# ── Pull: grab partner's conf from their disk → our local cache ──────────
if [[ "$PUSH_ONLY" == false ]]; then
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_conf}" \
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
log "Pulled ${partner_slot}.conf from $partner_host ✅"
(( PULLED++ ))
else
warn "Could not pull ${partner_slot}.conf from $partner_host"
(( FAILED++ ))
fi
fi
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
if [[ "$PULL_ONLY" == true ]]; then
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
continue
fi
# Ensure partner's cache dir exists, then SCP own conf into it
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"$MY_CONF" \
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
(( PUSHED++ ))
else
warn "Could not push to $partner_host"
(( FAILED++ ))
fi
done
# ── Summary ───────────────────────────────────────────────────────────────────
if [[ "$PUSH_ONLY" == true ]]; then
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
elif [[ "$PULL_ONLY" == true ]]; then
info "Conf pull complete — pulled $PULLED partner conf(s)${FAILED:+, $FAILED failed}"
else
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
fi
@@ -0,0 +1,409 @@
#!/bin/bash
# ==============================================================================================
# ====================== Partnership — Unraid Container Adapter ================================
# ==============================================================================================
#
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
# source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
#
# Provides container deploy/cleanup functions specific to the Unraid platform:
# - Docker container deployment from Unraid CA XML templates
# - Community Applications plugin install via `plugin install`
# - FolderView3 docker.json manipulation
#
# Functions use variables from the calling script's scope (sourced, not exec'd):
# MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
#
# ==============================================================================================
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
_STACK_DEPLOYED=0
_STACK_FAILED=0
# ==============================================================================================
# ── Install Unraid plugins on the mirror ──────────────────────────────────────────────────────
#
# Installs FolderView3 via `plugin install` — Unraid Community Applications command.
# Checks if already installed before attempting. Returns 1 if install fails.
# ==============================================================================================
platform_install_plugins() {
local remote_ip="$1" ssh_key="$2"
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] || [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
log "FolderView3 not configured — skipping"
return 0
fi
local fv3_present
fv3_present=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)
if [[ "$fv3_present" == "yes" ]]; then
log "FolderView3 already installed on $MIRROR ✅"
return 0
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would install FolderView3 on $MIRROR"
return 0
fi
log "Installing FolderView3 on $MIRROR..."
if timeout 60 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
2>/dev/null | grep -q installed; then
log "FolderView3 installed ✅"
return 0
else
warn "FolderView3 install failed — install manually from Community Applications"
return 1
fi
}
# ==============================================================================================
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state. Non-fatal after timeout — some containers take time to
# fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
local name="$1" remote_ip="$2" ssh_key="$3"
local max_wait=60 interval=5 elapsed=0
[[ "$DRY_RUN" == true ]] && return 0
log " Waiting for $name to be ready..."
while (( elapsed < max_wait )); do
local status
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
echo \${h:-\$r}" 2>/dev/null)
case "$status" in
healthy|true)
log " $name ready ✅"
return 0
;;
*)
sleep "$interval"
(( elapsed += interval ))
;;
esac
done
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
return 0
}
# ==============================================================================================
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
#
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
local xml_file="$1" remote_ip="$2" ssh_key="$3"
local xml_name
xml_name=$(basename "$xml_file")
local name repo network extra privileged
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
if [[ -z "$name" || -z "$repo" ]]; then
warn " Cannot parse Name/Repository from $xml_name — skipping"
return 1
fi
log "Deploying $name..."
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
warn " SCP failed for $xml_name — skipping $name"
return 1
}
else
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
fi
local tmp_script
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
chmod 600 "$tmp_script"
{
echo "#!/bin/bash"
echo "set -e"
echo ""
printf "docker pull %q 2>/dev/null || true\n" "$repo"
printf "docker stop %q 2>/dev/null || true\n" "$name"
printf "docker rm %q 2>/dev/null || true\n" "$name"
echo ""
printf "docker create --name %q --restart=unless-stopped" "$name"
[[ -n "$network" ]] && printf " --network=%q" "$network"
[[ "$privileged" == "true" ]] && printf " --privileged"
[[ -n "$extra" ]] && printf " %s" "$extra"
# Port mappings → -p host:container/proto
awk '/Type="Port"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
proto = (m[1] == "udp") ? "udp" : "tcp"
printf " -p %s:%s/%s", v[1], t[1], proto
}
}' "$xml_file"
# Volume mappings → -v 'host:container:mode'
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
mode = (m[1] == "ro") ? "ro" : "rw"
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
}
}' "$xml_file"
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
match($0, /Target="([^"]+)"/, t)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
printf " -e %s%s=%s%s", q, t[1], v[1], q
}
}' "$xml_file"
printf " %q\n" "$repo"
echo ""
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
} > "$tmp_script"
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would deploy $name on $MIRROR"
rm -f "$tmp_script"
return 0
fi
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
grep -q "deployed:${name}"; then
log " $name deployed ✅"
rm -f "$tmp_script"
return 0
else
warn " $name deployment failed — check $MIRROR manually"
rm -f "$tmp_script"
return 1
fi
}
# ==============================================================================================
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
# (e.g. Authelia) start cleanly.
# ==============================================================================================
deploy_xml_stack() {
local -n xml_array_ref="$1"
_STACK_DEPLOYED=0
_STACK_FAILED=0
for xml_name in "${xml_array_ref[@]}"; do
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
(( _STACK_FAILED++ ))
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
(( _STACK_DEPLOYED++ ))
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
fi
else
(( _STACK_FAILED++ ))
fi
done
}
# ==============================================================================================
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
# Appdata paths collected via docker inspect before removal. Safety gate: only
# /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
local remote_ip="$1" ssh_key="$2"
local -a xml_names=()
[[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
[[ ${#PARTNERSHIP_ARR_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
if [[ ${#xml_names[@]} -eq 0 ]]; then
log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
return 0
fi
log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
for xml_name in "${xml_names[@]}"; do
[[ -z "$xml_name" ]] && continue
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn " $xml_name not found in local $TEMPLATES_DIR — skipping"
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
[[ -z "$cname" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $cname on $MIRROR"
warn " DRY RUN — would delete appdata for $cname on $MIRROR"
continue
fi
local appdata_paths
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"docker stop '$cname' >/dev/null 2>&1
docker rm '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
grep -q removed && \
log " $cname removed from $MIRROR ✅" || \
log " $cname not found on $MIRROR — skipping"
while IFS= read -r path; do
[[ -z "$path" ]] && continue
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
log " Appdata removed on $MIRROR: $path ✅" || \
warn " Failed to remove appdata on $MIRROR: $path"
done <<< "$appdata_paths"
done
}
# ==============================================================================================
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
local owner_ip="$1" ssh_key="$2"
local -a xml_names=()
if [[ -n "$owner_ip" ]]; then
local -a auth_arr arr_arr
mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
fi
if [[ ${#xml_names[@]} -eq 0 ]]; then
log "Could not read deployed stack from owner — skipping auth/arr cleanup"
return 0
fi
log "Removing owner-deployed containers (auth/arr stacks) locally..."
for xml_name in "${xml_names[@]}"; do
[[ -z "$xml_name" ]] && continue
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn " $xml_name not found locally — skipping"
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
[[ -z "$cname" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $cname"
warn " DRY RUN — would delete appdata for $cname"
continue
fi
local appdata_paths=""
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
appdata_paths=$(docker inspect \
--format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
_PM_TRAP_STOPPED+=("$cname")
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
log " $cname removed ✅" || warn " $cname rm failed"
else
log " $cname not found locally — skipping"
fi
while IFS= read -r path; do
[[ -z "$path" ]] && continue
rm -rf "$path" && log " Appdata removed: $path ✅" || warn " Failed to remove: $path"
done <<< "$appdata_paths"
done
}
# ==============================================================================================
# ── Remove a FolderView3 folder from a remote host ───────────────────────────────────────────
#
# Uses jq on the remote to delete the named folder from docker.json.
# No-op if PARTNERSHIP_FOLDERVIEW3 is not enabled or jq is unavailable on remote.
# ==============================================================================================
platform_remove_folderview3_on_remote() {
local remote_ip="$1" ssh_key="$2" folder_name="$3" server_label="${4:-remote}"
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] && return 0
log "Removing FolderView3 folder '$folder_name' from $server_label..."
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"fv3='/boot/config/plugins/folder.view3/docker.json'
[[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
jq --arg n '$folder_name' \
'with_entries(select(.value.name != \$n))' \
\"\$fv3\" > \"\${fv3}.tmp\" && \
mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
grep -q removed && \
log "FolderView3 '$folder_name' removed from $server_label ✅" || \
warn "FolderView3 folder not found on $server_label or jq unavailable — skipping"
else
warn "DRY RUN — would remove FolderView3 folder '$folder_name' from $server_label"
fi
}
@@ -0,0 +1,338 @@
#!/bin/bash
# ==============================================================================================
# ====================== Partnership — Unraid Container Adapter ================================
# ==============================================================================================
#
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
# source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
#
# Provides container deploy/cleanup functions specific to the Unraid platform:
# - Docker container deployment from Unraid CA XML templates
#
# Functions use variables from the calling script's scope (sourced, not exec'd):
# MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
#
# ==============================================================================================
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
_STACK_DEPLOYED=0
_STACK_FAILED=0
# ==============================================================================================
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state. Non-fatal after timeout — some containers take time to
# fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
local name="$1" remote_ip="$2" ssh_key="$3"
local max_wait=60 interval=5 elapsed=0
[[ "$DRY_RUN" == true ]] && return 0
log " Waiting for $name to be ready..."
while (( elapsed < max_wait )); do
local status
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
echo \${h:-\$r}" 2>/dev/null)
case "$status" in
healthy|true)
log " $name ready ✅"
return 0
;;
*)
sleep "$interval"
(( elapsed += interval ))
;;
esac
done
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
return 0
}
# ==============================================================================================
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
#
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
local xml_file="$1" remote_ip="$2" ssh_key="$3"
local xml_name
xml_name=$(basename "$xml_file")
local name repo network extra privileged
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
if [[ -z "$name" || -z "$repo" ]]; then
warn " Cannot parse Name/Repository from $xml_name — skipping"
return 1
fi
log "Deploying $name..."
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
warn " SCP failed for $xml_name — skipping $name"
return 1
}
else
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
fi
local tmp_script
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
chmod 600 "$tmp_script"
{
echo "#!/bin/bash"
echo "set -e"
echo ""
printf "docker pull %q 2>/dev/null || true\n" "$repo"
printf "docker stop %q 2>/dev/null || true\n" "$name"
printf "docker rm %q 2>/dev/null || true\n" "$name"
echo ""
printf "docker create --name %q --restart=unless-stopped" "$name"
[[ -n "$network" ]] && printf " --network=%q" "$network"
[[ "$privileged" == "true" ]] && printf " --privileged"
[[ -n "$extra" ]] && printf " %s" "$extra"
# Port mappings → -p host:container/proto
awk '/Type="Port"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
proto = (m[1] == "udp") ? "udp" : "tcp"
printf " -p %s:%s/%s", v[1], t[1], proto
}
}' "$xml_file"
# Volume mappings → -v 'host:container:mode'
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
match($0, /Target="([^"]+)"/, t)
match($0, /Mode="([^"]+)"/, m)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
mode = (m[1] == "ro") ? "ro" : "rw"
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
}
}' "$xml_file"
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
match($0, /Target="([^"]+)"/, t)
match($0, />([^<]+)<\/Config>/, v)
if (t[1] != "" && v[1] != "") {
printf " -e %s%s=%s%s", q, t[1], v[1], q
}
}' "$xml_file"
printf " %q\n" "$repo"
echo ""
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
} > "$tmp_script"
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would deploy $name on $MIRROR"
rm -f "$tmp_script"
return 0
fi
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
grep -q "deployed:${name}"; then
log " $name deployed ✅"
rm -f "$tmp_script"
return 0
else
warn " $name deployment failed — check $MIRROR manually"
rm -f "$tmp_script"
return 1
fi
}
# ==============================================================================================
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
# (e.g. Authelia) start cleanly.
# ==============================================================================================
deploy_xml_stack() {
local -n xml_array_ref="$1"
_STACK_DEPLOYED=0
_STACK_FAILED=0
for xml_name in "${xml_array_ref[@]}"; do
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
(( _STACK_FAILED++ ))
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
(( _STACK_DEPLOYED++ ))
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
fi
else
(( _STACK_FAILED++ ))
fi
done
}
# ==============================================================================================
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
# Appdata paths collected via docker inspect before removal. Safety gate: only
# /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
local remote_ip="$1" ssh_key="$2"
local -a xml_names=()
[[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
[[ ${#PARTNERSHIP_ARR_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
if [[ ${#xml_names[@]} -eq 0 ]]; then
log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
return 0
fi
log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
for xml_name in "${xml_names[@]}"; do
[[ -z "$xml_name" ]] && continue
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn " $xml_name not found in local $TEMPLATES_DIR — skipping"
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
[[ -z "$cname" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $cname on $MIRROR"
warn " DRY RUN — would delete appdata for $cname on $MIRROR"
continue
fi
local appdata_paths
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"docker stop '$cname' >/dev/null 2>&1
docker rm '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
grep -q removed && \
log " $cname removed from $MIRROR ✅" || \
log " $cname not found on $MIRROR — skipping"
while IFS= read -r path; do
[[ -z "$path" ]] && continue
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
log " Appdata removed on $MIRROR: $path ✅" || \
warn " Failed to remove appdata on $MIRROR: $path"
done <<< "$appdata_paths"
done
}
# ==============================================================================================
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
local owner_ip="$1" ssh_key="$2"
local -a xml_names=()
if [[ -n "$owner_ip" ]]; then
local -a auth_arr arr_arr
mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
fi
if [[ ${#xml_names[@]} -eq 0 ]]; then
log "Could not read deployed stack from owner — skipping auth/arr cleanup"
return 0
fi
log "Removing owner-deployed containers (auth/arr stacks) locally..."
for xml_name in "${xml_names[@]}"; do
[[ -z "$xml_name" ]] && continue
local xml_file="${TEMPLATES_DIR}/${xml_name}"
if [[ ! -f "$xml_file" ]]; then
warn " $xml_name not found locally — skipping"
continue
fi
local cname
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
[[ -z "$cname" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $cname"
warn " DRY RUN — would delete appdata for $cname"
continue
fi
local appdata_paths=""
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
appdata_paths=$(docker inspect \
--format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
_PM_TRAP_STOPPED+=("$cname")
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
log " $cname removed ✅" || warn " $cname rm failed"
else
log " $cname not found locally — skipping"
fi
while IFS= read -r path; do
[[ -z "$path" ]] && continue
rm -rf "$path" && log " Appdata removed: $path ✅" || warn " Failed to remove: $path"
done <<< "$appdata_paths"
done
}
@@ -0,0 +1,84 @@
<?php
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
$host = vv_detect_host();
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Cannot detect local host']);
exit;
}
$hostUpper = strtoupper($host);
$varName = $hostUpper . '_UNRAID_API_KEY';
$confFile = $host . '.conf';
// Create/overwrite the Varaverk API key.
// --description and --roles are required to suppress interactive prompts.
// --overwrite replaces any existing key with the same name (keeps it to one).
$dbg = ['ts' => date('H:i:s'), 'user' => trim(shell_exec('whoami'))];
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
$dbg['raw'] = $output;
file_put_contents('/tmp/vv_apikey_debug.json', json_encode($dbg, JSON_PRETTY_PRINT));
if (!$output) {
echo json_encode(['ok' => false, 'error' => 'unraid-api returned no output — check /tmp/vv_apikey_debug.json']);
exit;
}
$data = json_decode(trim($output), true);
if (!is_array($data)) {
echo json_encode(['ok' => false, 'error' => 'Could not parse unraid-api output', 'raw' => substr($output, 0, 300)]);
exit;
}
$key = $data['key'] ?? null;
if (!$key) {
echo json_encode(['ok' => false, 'error' => 'No key in response', 'raw' => substr($output, 0, 300)]);
exit;
}
// Read conf, replace the key value, write back
$raw = vv_read_conf_raw($confFile);
if ($raw === '') {
echo json_encode(['ok' => false, 'error' => 'Cannot read ' . $confFile]);
exit;
}
// If line is missing (older conf created before this field was added to the template),
// insert it after HOST*_OWNER_EMAIL, or after HOST*_SSH_KEY, or append to file.
if (!str_contains($raw, $varName)) {
$inserted = false;
foreach ([$hostUpper . '_OWNER_EMAIL', $hostUpper . '_SSH_KEY'] as $anchor) {
if (str_contains($raw, $anchor)) {
$raw = preg_replace(
'/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
'$1' . "\n " . $varName . '=""',
$raw, 1
);
$inserted = true;
break;
}
}
if (!$inserted) {
$raw = rtrim($raw) . "\n " . $varName . '=""' . "\n";
}
}
// Replace quoted value in-place
$updated = preg_replace(
'/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
'${1}"' . $key . '"',
$raw
);
if (!vv_write_conf_raw($confFile, $updated)) {
echo json_encode(['ok' => false, 'error' => 'Failed to write ' . $confFile]);
exit;
}
echo json_encode([
'ok' => true,
'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4),
'conf_file' => $confFile,
]);
@@ -0,0 +1,12 @@
<?php
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
$host = vv_detect_host();
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Cannot detect local host']);
exit;
}
echo json_encode(vv_auto_create_api_key($host, $host . '.conf'));
@@ -0,0 +1,23 @@
<?php
header('Content-Type: application/json');
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
if (!$scriptsDir) {
echo json_encode(['ok' => false, 'error' => 'scripts_dir is required']);
exit;
}
if (!is_dir($scriptsDir)) {
echo json_encode(['ok' => false, 'error' => 'Directory does not exist']);
exit;
}
$cfgFile = '/boot/config/plugins/varaverk/varaverk.cfg';
$cfgDir = dirname($cfgFile);
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
$ok = file_put_contents($cfgFile, $content) !== false;
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
@@ -0,0 +1,24 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
if (!$scriptsDir) {
echo json_encode(['ok' => false, 'error' => 'scripts_dir is required']);
exit;
}
if (!is_dir($scriptsDir)) {
echo json_encode(['ok' => false, 'error' => 'Directory does not exist']);
exit;
}
$cfgFile = PLUGIN_CFG;
$cfgDir = dirname($cfgFile);
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
$ok = file_put_contents($cfgFile, $content) !== false;
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
@@ -0,0 +1,9 @@
// Varaverk — shared JS utilities
// Page-specific JS lives inline in each page partial.
// Flash a status element briefly then fade
function vvFlashStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#4caf50' : '#f44336';
setTimeout(() => { el.textContent = ''; }, 3000);
}
@@ -0,0 +1,30 @@
// Varaverk — shared JS utilities
// Page-specific JS lives inline in each page partial.
// Flash a status element briefly then fade
function vvFlashStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#4caf50' : '#f44336';
setTimeout(() => { el.textContent = ''; }, 3000);
}
// ── Fullscreen toggle — hides Unraid header + menu ────────────────────────────
function vvToggleExpand() {
const on = document.body.classList.toggle('vv-fullscreen');
const btn = document.getElementById('vv-expand-btn');
if (btn) { btn.classList.toggle('active', on); btn.title = on ? 'Collapse' : 'Expand'; }
localStorage.setItem('vv-fullscreen', on ? '1' : '');
}
// Restore state on every page load
(function() {
if (localStorage.getItem('vv-fullscreen') !== '1') return;
document.body.classList.add('vv-fullscreen');
// Button may not exist yet if script runs before DOM — wait for it
const apply = () => {
const btn = document.getElementById('vv-expand-btn');
if (btn) { btn.classList.add('active'); btn.title = 'Collapse'; }
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
else apply();
})();
@@ -0,0 +1,285 @@
<?php
require_once __DIR__ . '/config.php';
// ── Config ────────────────────────────────────────────────────────────────────
function vv_auth_conf(): array {
$v = vv_conf_vars();
$host = strtoupper(vv_detect_host());
return [
'npm_url' => rtrim($v["{$host}_NPM_URL"] ?? 'http://localhost:81', '/'),
'npm_user' => $v["{$host}_NPM_USER"] ?? '',
'npm_pass' => $v["{$host}_NPM_PASS"] ?? '',
'lldap_url' => rtrim($v["{$host}_LLDAP_URL"] ?? 'http://localhost:17170', '/'),
'lldap_user' => $v["{$host}_LLDAP_USER"] ?? '',
'lldap_pass' => $v["{$host}_LLDAP_PASS"] ?? '',
'authelia_config' => $v["{$host}_AUTHELIA_CONFIG"] ?? '/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml',
'authelia_container' => $v["{$host}_AUTHELIA_CONTAINER"] ?? 'Authelia',
'is_owner' => vv_is_owner(),
];
}
// ── NPM ───────────────────────────────────────────────────────────────────────
function vv_npm_token(): string {
if (!session_id()) session_start();
$conf = vv_auth_conf();
$cached = $_SESSION['vv_npm_token'] ?? '';
$expiry = $_SESSION['vv_npm_token_exp'] ?? 0;
if ($cached && time() < $expiry) return $cached;
$resp = vv_npm_raw('POST', '/api/tokens', [
'identity' => $conf['npm_user'],
'secret' => $conf['npm_pass'],
'expiry' => '1d',
], '', $conf);
$token = $resp['token'] ?? '';
if ($token) {
$_SESSION['vv_npm_token'] = $token;
$_SESSION['vv_npm_token_exp'] = time() + 82800;
}
return $token;
}
function vv_npm_raw(string $method, string $path, array $data, string $token, array $conf = []): array {
if (!$conf) $conf = vv_auth_conf();
$url = $conf['npm_url'] . $path;
$headers = ['Content-Type: application/json', 'Accept: application/json'];
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($data && in_array($method, ['POST', 'PUT'], true))
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body ?: '{}', true) ?: [];
}
function vv_npm_req(string $method, string $path, array $data = []): array {
$token = vv_npm_token();
if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf'];
return vv_npm_raw($method, $path, $data, $token);
}
function vv_npm_list_proxies(): array {
$list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
if (!is_array($list) || isset($list['_err']))
return ['ok' => false, 'error' => $list['_err'] ?? 'Invalid response from NPM'];
return ['ok' => true, 'proxies' => $list];
}
function vv_npm_list_certs(): array {
$list = vv_npm_req('GET', '/api/nginx/certificates');
return is_array($list) ? $list : [];
}
function vv_npm_create_proxy(array $data): array {
$r = vv_npm_req('POST', '/api/nginx/proxy-hosts', $data);
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Create failed')];
}
function vv_npm_update_proxy(int $id, array $data): array {
$r = vv_npm_req('PUT', "/api/nginx/proxy-hosts/$id", $data);
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Update failed')];
}
function vv_npm_delete_proxy(int $id): array {
vv_npm_req('DELETE', "/api/nginx/proxy-hosts/$id");
return ['ok' => true];
}
function vv_npm_toggle_proxy(int $id, bool $enabled): array {
vv_npm_req('POST', "/api/nginx/proxy-hosts/$id/" . ($enabled ? 'enable' : 'disable'));
return ['ok' => true];
}
// ── lldap ─────────────────────────────────────────────────────────────────────
function vv_lldap_token(): string {
if (!session_id()) session_start();
$conf = vv_auth_conf();
$cached = $_SESSION['vv_lldap_token'] ?? '';
$expiry = $_SESSION['vv_lldap_token_exp'] ?? 0;
if ($cached && time() < $expiry) return $cached;
$ch = curl_init($conf['lldap_url'] . '/auth/simple/login');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['username' => $conf['lldap_user'], 'password' => $conf['lldap_pass']]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$body = curl_exec($ch);
curl_close($ch);
$resp = json_decode($body ?: '{}', true) ?: [];
$token = $resp['token'] ?? '';
if ($token) {
$_SESSION['vv_lldap_token'] = $token;
$_SESSION['vv_lldap_token_exp'] = time() + 3500;
}
return $token;
}
function vv_lldap_gql(string $query, array $variables = []): array {
$conf = vv_auth_conf();
$token = vv_lldap_token();
if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]];
$ch = curl_init($conf['lldap_url'] . '/api/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => $variables]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body ?: '{}', true) ?: [];
}
function vv_lldap_list_users(): array {
$r = vv_lldap_gql('query { listUsers { id displayName email creationDate groups { id displayName } } }');
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
return ['ok' => true, 'users' => $r['data']['listUsers'] ?? []];
}
function vv_lldap_list_groups(): array {
$r = vv_lldap_gql('query { listGroups { id displayName users { id displayName } } }');
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
return ['ok' => true, 'groups' => $r['data']['listGroups'] ?? []];
}
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
$r = vv_lldap_gql(
'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
if ($password) vv_lldap_set_password($id, $password);
return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
}
function vv_lldap_update_user(string $id, string $email, string $displayName): array {
$r = vv_lldap_gql(
'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
return ['ok' => true];
}
function vv_lldap_delete_user(string $id): array {
$r = vv_lldap_gql(
'mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }',
['userId' => $id]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
return ['ok' => true];
}
function vv_lldap_set_password(string $userId, string $password): array {
$r = vv_lldap_gql(
'mutation ChangePassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }',
['userId' => $userId, 'password' => $password]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Password change failed'];
return ['ok' => true];
}
function vv_lldap_create_group(string $name): array {
$r = vv_lldap_gql(
'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }',
['name' => $name]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
}
function vv_lldap_delete_group(int $id): array {
$r = vv_lldap_gql(
'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
['groupId' => $id]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
return ['ok' => true];
}
function vv_lldap_add_to_group(string $userId, int $groupId): array {
$r = vv_lldap_gql(
'mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }',
['userId' => $userId, 'groupId' => $groupId]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
return ['ok' => true];
}
function vv_lldap_remove_from_group(string $userId, int $groupId): array {
$r = vv_lldap_gql(
'mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }',
['userId' => $userId, 'groupId' => $groupId]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
return ['ok' => true];
}
// ── Authelia ──────────────────────────────────────────────────────────────────
function vv_authelia_read_rules(): array {
$conf = vv_auth_conf();
$file = $conf['authelia_config'];
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
$py = "import yaml,json,sys\n"
. "d=yaml.safe_load(open(sys.argv[1]))\n"
. "ac=d.get('access_control',{})\n"
. "print(json.dumps({'default_policy':ac.get('default_policy','deny'),'rules':ac.get('rules',[])}))\n";
$tmp = '/tmp/vv_auth_rd_' . getmypid() . '.py';
file_put_contents($tmp, $py);
$out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' 2>/dev/null');
@unlink($tmp);
if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required'];
$data = json_decode(trim($out), true);
if (!$data) return ['ok' => false, 'error' => 'Invalid YAML response'];
return ['ok' => true, 'default_policy' => $data['default_policy'], 'rules' => $data['rules']];
}
function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
$conf = vv_auth_conf();
$file = $conf['authelia_config'];
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
$acJson = json_encode(['default_policy' => $defaultPolicy, 'rules' => $rules]);
$py = <<<'PYEOF'
import yaml, json, sys, re
config_file = sys.argv[1]
new_ac = json.loads(sys.argv[2])
with open(config_file, 'r') as f:
content = f.read()
new_block = yaml.dump({'access_control': new_ac}, default_flow_style=False, allow_unicode=True, sort_keys=False)
pattern = r'(?ms)^access_control:.*?(?=^[a-zA-Z#]|\Z)'
if re.search(pattern, content):
content = re.sub(pattern, new_block + '\n', content)
else:
content = content.rstrip('\n') + '\n\n' + new_block + '\n'
with open(config_file, 'w') as f:
f.write(content)
print('ok')
PYEOF;
$tmp = '/tmp/vv_auth_wr_' . getmypid() . '.py';
file_put_contents($tmp, $py);
$out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' ' . escapeshellarg($acJson) . ' 2>&1');
@unlink($tmp);
if (trim($out) !== 'ok') return ['ok' => false, 'error' => 'Write failed: ' . trim($out)];
shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
return ['ok' => true];
}
@@ -0,0 +1,770 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
# ==============================================================================================
# HOST1-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST2 never receives this file.
# HOST2 never sees HOST1 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST2 variables here — they belong in host2.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST1
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
# RSYNC WRITEBACK HOST1 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
# LIDARR URL, API key, path map
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
HOST1_OWNER="gmer4lfe"
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST1_EMBY_CONTAINER="Emby"
HOST1_EMBY_URL="http://localhost:8096"
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST1_JELLYFIN_CONTAINER="Jellyfin"
HOST1_JELLYFIN_URL="http://localhost:8095"
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
# ━━━ Gitea ━━━
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
# with Gitea so git operations use key auth instead of passwords.
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOST1_GITEA_API_TOKEN=""
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST1_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST1 is always the owner (source of truth) unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
"NginxProxyManager|81"
"Lldap-Gmer4Lfe|17170"
"Authelia|9091"
"Authelia-Secondary|9092"
)
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
# Update filename if Lldap is renamed to drop the host suffix.
HOST1_PARTNERSHIP_AUTH_STACK=(
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
"my-Mariadb-Authelia.xml"
"my-Mariadb-Authelia-Secondary.xml"
"my-Redis-Authelia.xml"
"my-Redis-Authelia-Secondary.xml"
# Auth apps — deployed after their deps are confirmed healthy
"my-Authelia.xml"
"my-Authelia-Secondary.xml"
"my-NginxProxyManager.xml"
"my-Lldap-Gmer4Lfe.xml"
# Source of truth — must be available on HOST2 independently of the auth stack
"my-Gitea.xml"
)
# XML templates pushed to mirror for the arr stack during onboard.
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
HOST1_PARTNERSHIP_ARR_STACK=(
# "my-Sonarr.xml"
# "my-Radarr.xml"
# "my-Lidarr.xml"
# "my-Prowlarr.xml"
# "my-Bazarr.xml"
)
# Paths HOST2 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
# On offboard: account is deleted. Username collision → onboard exits with error.
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
HOST1_PARTNERSHIP_EMBY_PORT=8096
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST1_DAILY_SYNC_SHARES=(
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Nextcloud
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST1_PERSONAL_SHARES=(
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
HOST1_WEEKLY_SYNC_SHARES=(
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST1_INTERMEDIATE_SYNC_SHARES=(
# Add shares here to enable mid-day rsync
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
HOST1_CRITICAL_SYNC_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST1_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
PROFILE_BW_LIMIT[host1-appdata]=8000
PROFILE_RETRY_COUNT[host1-appdata]=3
PROFILE_SLEEP[host1-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
PROFILE_CONTAINER_DELAY[host1-appdata]=5
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
# Order matters — auth stack first, then media services.
HOST1_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Authelia"
"Authelia-Secondary"
"Dispatcharr-Iptv-Users"
"Dispatcharr" # Live TV scheduler — degrades without daily restart
"Dispatcharr-Basic"
"ErsatzTV-Emby"
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST1_WEEKLY_RESTART_CONTAINERS=(
"NextCloud"
"Organizrv2-Gmer4Lfe"
"AdGuard-Home"
"Immich-Gmer4Lfe"
)
# ━━━ Docker Watchdog ━━━
# Per-HOST1 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST1_WATCHDOG_CONTAINERS=(
["Emby"]=20480 # 20GB — large library + active transcodes
["LidaTube"]=6144 # 6GB — memory leak over time
["Tdarr"]=6144 # 6GB — encoding is memory intensive
["Code-Server"]=1024 # 1GB — should never need more
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST1.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Authelia"
"Authelia-Secondary"
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST1_WATCHDOG_SCAN_IGNORE=(
"DashGate"
"PIA-WG-Config-Generator"
"Aperture"
"Aperture-Kids"
"pgvector-18-Apeture-Kids"
"Pgvector18-Aperture"
"emby-test" # broken test container (exit 127 — bad image)
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting Authelia before Mariadb is back up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
["Authelia"]="Mariadb-Authelia Redis-Authelia"
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
["NextCloud"]="Postgres-NextCloud"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use this when a container legitimately has large stable data and you want to guarantee
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
# container's dir stays below this ceiling; above it, warnings resume as normal.
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
["7dtd"]="20480" # 20GB — game server world data, expected to be large
)
# ━━━ Network Watchdog ━━━
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST1_NETWORK_CONNECT_CONTAINERS=(
"memcached"
"Npm-CrowdSec"
)
HOST1_NETWORK_CONNECT_NETWORKS=(
"high-availability"
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
HOST1_DDNS_CONTAINERS=(
"Gmer4Lfe.com"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST1 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST1_STOP_ON_NO_NET=(
"Gmer4Lfe.com"
)
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
# Containers HOST1 starts when HOST2 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
"Gmer4Lfe.us"
"VaultWarden-Jayred365"
)
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
# "container-placeholder"
)
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST1_WRITEBACK_TIER1=(
"/mnt/user/Media_Server/Emby" # watch states built up during outage
)
FALLBACK_HOST1_WRITEBACK_TIER2=(
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
)
FALLBACK_HOST1_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST1_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST1_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/appcache
/mnt/user/Books
/mnt/user/Downloads
/mnt/user/Games
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movie_Recordings
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Photo
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Temp_Storage
/mnt/user/Tv_Recordings
/mnt/user/Tv_Shows
/mnt/user/YouTube
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST1_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
)
HOST1_MEDIA_CLEAN_FOLDERS=(
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Tv_Shows
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST1_CERT_MONITOR_DOMAINS=(
"Gmer4Lfe.com"
"Gmer4Lfe.us"
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST1_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
# Pool health thresholds defined in master.conf.
HOST1_ZFS_REPORT_IGNORE_POOLS=(
"disk5"
"disk6"
"disk8"
"disk9"
"disk10"
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
HOST1_RAMDISK_SIZE="10G"
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST1.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST1_TRANSCODE_SERVERS=(
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST1 vars when running on HOST1.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
# ━━━ Downloaders ━━━
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
# Clears stuck states, purges old history, prepares each client for a clean cycle.
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
HOST1_SLSKD_URL="http://localhost:8980"
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
# SABnzbd
HOST1_SABNZBD_URL="http://localhost:8180"
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently.
HOST1_QBIT_URL="http://localhost:8080"
HOST1_QBIT_USERNAME="root"
HOST1_QBIT_PASSWORD="Stay0utD!ck"
# ━━━ Lidarr — HOST1 only ━━━
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
HOST1_LIDARR_URL="http://localhost:8686"
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
declare -A HOST1_LIDARR_PATH_MAP=(
["/ext-music"]="/mnt/user/Music-New"
)
# ━━━ Sonarr ━━━
HOST1_SONARR_URL="http://localhost:8989"
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
declare -A HOST1_SONARR_PATH_MAP=(
["/tv"]="/mnt/user/Tv_Shows"
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
)
# ━━━ Radarr ━━━
HOST1_RADARR_URL="http://localhost:7878"
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
declare -A HOST1_RADARR_PATH_MAP=(
["/movies"]="/mnt/user/Movies"
["/kids movies"]="/mnt/user/Kids_Movies"
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST1_SONARR_RECOVERY=true
HOST1_RADARR_RECOVERY=true
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
#
# Three-tier response — all critical checks enabled by default on HOST1:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): everything else
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST1_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# These bypass the strike system — a single hit triggers immediate reboot.
# Disabling any of these is not recommended — they protect against acute system failure.
# Docker daemon unresponsive → try restart, reboot if restart fails.
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
HOST1_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
# Unexpected read-only /boot means state files and config writes are silently failing.
# Fallback state, watchdog reboot log, and lock files all go stale silently.
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
# Also provides diagnostic context in reboot messages (which processes were killed).
HOST1_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
HOST1_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
# Single spikes are ignored — sustained problems trigger reboot.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
HOST1_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
HOST1_SYS_WATCHDOG_CHECK_ARC=true
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
# Large zombie counts indicate serious process management failure — something is stuck.
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
# Script tries to clear aged /tmp files first — only strikes if clear fails.
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
HOST1_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — detects NIC going down (physical or driver failure).
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — attempts restart before escalating.
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST1_RW_PAUSE_CONTAINERS=(
"Huntarr" # arr search automation — safe to suspend
"Cleanuparr" # download cleanup — safe to suspend
"Healarr" # arr health checks — safe to suspend
"Soularr" # Slskd automation — background only
"ChannelTube" # YouTube archiver — background only
"Pinchflat" # YouTube archiver — background only
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST1_RW_STOP_CONTAINERS=(
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
"7DaysToDie" # game server — optional
"V-Rising" # game server — optional
"Code-Server" # IDE — not needed during pressure events
)
# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,792 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
# ==============================================================================================
# HOST1-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST2 never receives this file.
# HOST2 never sees HOST1 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST2 variables here — they belong in host2.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST1
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
# RSYNC WRITEBACK HOST1 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
# LIDARR URL, API key, path map
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
HOST1_OWNER="gmer4lfe"
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST1_EMBY_CONTAINER="Emby"
HOST1_EMBY_URL="http://localhost:8096"
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST1_JELLYFIN_CONTAINER="Jellyfin"
HOST1_JELLYFIN_URL="http://localhost:8095"
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
# ━━━ Gitea ━━━
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
# with Gitea so git operations use key auth instead of passwords.
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOST1_GITEA_API_TOKEN=""
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST1_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST1 is always the owner (source of truth) unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
"NginxProxyManager|81"
"Lldap-Gmer4Lfe|17170"
"Authelia|9091"
"Authelia-Secondary|9092"
)
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
# Update filename if Lldap is renamed to drop the host suffix.
HOST1_PARTNERSHIP_AUTH_STACK=(
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
"my-Mariadb-Authelia.xml"
"my-Mariadb-Authelia-Secondary.xml"
"my-Redis-Authelia.xml"
"my-Redis-Authelia-Secondary.xml"
# Auth apps — deployed after their deps are confirmed healthy
"my-Authelia.xml"
"my-Authelia-Secondary.xml"
"my-NginxProxyManager.xml"
"my-Lldap-Gmer4Lfe.xml"
# Source of truth — must be available on HOST2 independently of the auth stack
"my-Gitea.xml"
)
# XML templates pushed to mirror for the arr stack during onboard.
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
HOST1_PARTNERSHIP_ARR_STACK=(
# "my-Sonarr.xml"
# "my-Radarr.xml"
# "my-Lidarr.xml"
# "my-Prowlarr.xml"
# "my-Bazarr.xml"
)
# Paths HOST2 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
# On offboard: account is deleted. Username collision → onboard exits with error.
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
HOST1_PARTNERSHIP_EMBY_PORT=8096
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST1_DAILY_SYNC_SHARES=(
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Nextcloud
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST1_PERSONAL_SHARES=(
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
HOST1_WEEKLY_SYNC_SHARES=(
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST1_INTERMEDIATE_SYNC_SHARES=(
# Add shares here to enable mid-day rsync
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
HOST1_CRITICAL_SYNC_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST1_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
PROFILE_BW_LIMIT[host1-appdata]=8000
PROFILE_RETRY_COUNT[host1-appdata]=3
PROFILE_SLEEP[host1-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
PROFILE_CONTAINER_DELAY[host1-appdata]=5
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
# Order matters — auth stack first, then media services.
HOST1_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Authelia"
"Authelia-Secondary"
"Dispatcharr-Iptv-Users"
"Dispatcharr" # Live TV scheduler — degrades without daily restart
"Dispatcharr-Basic"
"ErsatzTV-Emby"
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST1_WEEKLY_RESTART_CONTAINERS=(
"NextCloud"
"Organizrv2-Gmer4Lfe"
"AdGuard-Home"
"Immich-Gmer4Lfe"
)
# ━━━ Docker Watchdog ━━━
# Per-HOST1 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST1_WATCHDOG_CONTAINERS=(
["Emby"]=20480 # 20GB — large library + active transcodes
["LidaTube"]=6144 # 6GB — memory leak over time
["Tdarr"]=6144 # 6GB — encoding is memory intensive
["Code-Server"]=1024 # 1GB — should never need more
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST1.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Authelia"
"Authelia-Secondary"
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST1_WATCHDOG_SCAN_IGNORE=(
"DashGate"
"PIA-WG-Config-Generator"
"Aperture"
"Aperture-Kids"
"pgvector-18-Apeture-Kids"
"Pgvector18-Aperture"
"emby-test" # broken test container (exit 127 — bad image)
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting Authelia before Mariadb is back up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
["Authelia"]="Mariadb-Authelia Redis-Authelia"
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
["NextCloud"]="Postgres-NextCloud"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use this when a container legitimately has large stable data and you want to guarantee
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
# container's dir stays below this ceiling; above it, warnings resume as normal.
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
["7dtd"]="20480" # 20GB — game server world data, expected to be large
)
# ━━━ Network Watchdog ━━━
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST1_NETWORK_CONNECT_CONTAINERS=(
"memcached"
"Npm-CrowdSec"
)
HOST1_NETWORK_CONNECT_NETWORKS=(
"high-availability"
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
HOST1_DDNS_CONTAINERS=(
"Gmer4Lfe.com"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST1 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST1_STOP_ON_NO_NET=(
"Gmer4Lfe.com"
)
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
# Containers HOST1 starts when HOST2 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
"Gmer4Lfe.us"
"VaultWarden-Jayred365"
)
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
# "container-placeholder"
)
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST1_WRITEBACK_TIER1=(
"/mnt/user/Media_Server/Emby" # watch states built up during outage
)
FALLBACK_HOST1_WRITEBACK_TIER2=(
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
)
FALLBACK_HOST1_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST1_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST1_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/appcache
/mnt/user/Books
/mnt/user/Downloads
/mnt/user/Games
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movie_Recordings
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Photo
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Temp_Storage
/mnt/user/Tv_Recordings
/mnt/user/Tv_Shows
/mnt/user/YouTube
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST1_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
)
HOST1_MEDIA_CLEAN_FOLDERS=(
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Tv_Shows
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST1_CERT_MONITOR_DOMAINS=(
"Gmer4Lfe.com"
"Gmer4Lfe.us"
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST1_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
# Pool health thresholds defined in master.conf.
HOST1_ZFS_REPORT_IGNORE_POOLS=(
"disk5"
"disk6"
"disk8"
"disk9"
"disk10"
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
HOST1_RAMDISK_SIZE="10G"
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST1.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST1_TRANSCODE_SERVERS=(
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST1 vars when running on HOST1.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
# ━━━ Downloaders ━━━
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
# Clears stuck states, purges old history, prepares each client for a clean cycle.
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
HOST1_SLSKD_URL="http://localhost:8980"
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
# SABnzbd
HOST1_SABNZBD_URL="http://localhost:8180"
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently.
HOST1_QBIT_URL="http://localhost:8080"
HOST1_QBIT_USERNAME="root"
HOST1_QBIT_PASSWORD="Stay0utD!ck"
# ━━━ Lidarr — HOST1 only ━━━
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
HOST1_LIDARR_URL="http://localhost:8686"
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
declare -A HOST1_LIDARR_PATH_MAP=(
["/ext-music"]="/mnt/user/Music-New"
)
# ━━━ Sonarr ━━━
HOST1_SONARR_URL="http://localhost:8989"
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
declare -A HOST1_SONARR_PATH_MAP=(
["/tv"]="/mnt/user/Tv_Shows"
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
)
# ━━━ Radarr ━━━
HOST1_RADARR_URL="http://localhost:7878"
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
declare -A HOST1_RADARR_PATH_MAP=(
["/movies"]="/mnt/user/Movies"
["/kids movies"]="/mnt/user/Kids_Movies"
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST1_SONARR_RECOVERY=true
HOST1_RADARR_RECOVERY=true
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
#
# Three-tier response — all critical checks enabled by default on HOST1:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): everything else
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST1_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# These bypass the strike system — a single hit triggers immediate reboot.
# Disabling any of these is not recommended — they protect against acute system failure.
# Docker daemon unresponsive → try restart, reboot if restart fails.
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
HOST1_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
# Unexpected read-only /boot means state files and config writes are silently failing.
# Fallback state, watchdog reboot log, and lock files all go stale silently.
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
# Also provides diagnostic context in reboot messages (which processes were killed).
HOST1_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
HOST1_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
# Single spikes are ignored — sustained problems trigger reboot.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
HOST1_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
HOST1_SYS_WATCHDOG_CHECK_ARC=true
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
# Large zombie counts indicate serious process management failure — something is stuck.
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
# Script tries to clear aged /tmp files first — only strikes if clear fails.
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
HOST1_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — detects NIC going down (physical or driver failure).
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — attempts restart before escalating.
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST1_RW_PAUSE_CONTAINERS=(
"Huntarr" # arr search automation — safe to suspend
"Cleanuparr" # download cleanup — safe to suspend
"Healarr" # arr health checks — safe to suspend
"Soularr" # Slskd automation — background only
"ChannelTube" # YouTube archiver — background only
"Pinchflat" # YouTube archiver — background only
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST1_RW_STOP_CONTAINERS=(
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
"7DaysToDie" # game server — optional
"V-Rising" # game server — optional
"Code-Server" # IDE — not needed during pressure events
)
# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# NPM: Admin email + password from NginxProxyManager settings.
# lldap: Admin username + password from lldap config.
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
# ━━━ NginxProxyManager ━━━
HOST1_NPM_URL="http://localhost:81"
HOST1_NPM_USER="" # NPM admin email
HOST1_NPM_PASS="" # NPM admin password
# ━━━ lldap ━━━
HOST1_LLDAP_URL="http://localhost:17170"
HOST1_LLDAP_USER="admin" # lldap admin username
HOST1_LLDAP_PASS="" # lldap admin password
# ━━━ Authelia ━━━
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
HOST1_AUTHELIA_CONTAINER="Authelia"
# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,793 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
# ==============================================================================================
# HOST1-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST2 never receives this file.
# HOST2 never sees HOST1 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST2 variables here — they belong in host2.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST1
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
# RSYNC WRITEBACK HOST1 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
# LIDARR URL, API key, path map
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
HOST1_OWNER="gmer4lfe"
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
HOST1_UNRAID_API_KEY="e6acb2dfea4c4434fc63ff35f78b4b68434680cc68b602e0eb48059b5f5ef4b9"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST1_EMBY_CONTAINER="Emby"
HOST1_EMBY_URL="http://localhost:8096"
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST1_JELLYFIN_CONTAINER="Jellyfin"
HOST1_JELLYFIN_URL="http://localhost:8095"
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
# ━━━ Gitea ━━━
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
# with Gitea so git operations use key auth instead of passwords.
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOST1_GITEA_API_TOKEN=""
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST1_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST1 is always the owner (source of truth) unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
"NginxProxyManager|81"
"Lldap-Gmer4Lfe|17170"
"Authelia|9091"
"Authelia-Secondary|9092"
)
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
# Update filename if Lldap is renamed to drop the host suffix.
HOST1_PARTNERSHIP_AUTH_STACK=(
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
"my-Mariadb-Authelia.xml"
"my-Mariadb-Authelia-Secondary.xml"
"my-Redis-Authelia.xml"
"my-Redis-Authelia-Secondary.xml"
# Auth apps — deployed after their deps are confirmed healthy
"my-Authelia.xml"
"my-Authelia-Secondary.xml"
"my-NginxProxyManager.xml"
"my-Lldap-Gmer4Lfe.xml"
# Source of truth — must be available on HOST2 independently of the auth stack
"my-Gitea.xml"
)
# XML templates pushed to mirror for the arr stack during onboard.
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
HOST1_PARTNERSHIP_ARR_STACK=(
# "my-Sonarr.xml"
# "my-Radarr.xml"
# "my-Lidarr.xml"
# "my-Prowlarr.xml"
# "my-Bazarr.xml"
)
# Paths HOST2 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
# On offboard: account is deleted. Username collision → onboard exits with error.
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
HOST1_PARTNERSHIP_EMBY_PORT=8096
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST1_DAILY_SYNC_SHARES=(
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Nextcloud
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST1_PERSONAL_SHARES=(
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
HOST1_WEEKLY_SYNC_SHARES=(
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST1_INTERMEDIATE_SYNC_SHARES=(
# Add shares here to enable mid-day rsync
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
HOST1_CRITICAL_SYNC_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST1_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
PROFILE_BW_LIMIT[host1-appdata]=8000
PROFILE_RETRY_COUNT[host1-appdata]=3
PROFILE_SLEEP[host1-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
PROFILE_CONTAINER_DELAY[host1-appdata]=5
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
# Order matters — auth stack first, then media services.
HOST1_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Authelia"
"Authelia-Secondary"
"Dispatcharr-Iptv-Users"
"Dispatcharr" # Live TV scheduler — degrades without daily restart
"Dispatcharr-Basic"
"ErsatzTV-Emby"
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST1_WEEKLY_RESTART_CONTAINERS=(
"NextCloud"
"Organizrv2-Gmer4Lfe"
"AdGuard-Home"
"Immich-Gmer4Lfe"
)
# ━━━ Docker Watchdog ━━━
# Per-HOST1 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST1_WATCHDOG_CONTAINERS=(
["Emby"]=20480 # 20GB — large library + active transcodes
["LidaTube"]=6144 # 6GB — memory leak over time
["Tdarr"]=6144 # 6GB — encoding is memory intensive
["Code-Server"]=1024 # 1GB — should never need more
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST1.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Authelia"
"Authelia-Secondary"
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST1_WATCHDOG_SCAN_IGNORE=(
"DashGate"
"PIA-WG-Config-Generator"
"Aperture"
"Aperture-Kids"
"pgvector-18-Apeture-Kids"
"Pgvector18-Aperture"
"emby-test" # broken test container (exit 127 — bad image)
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting Authelia before Mariadb is back up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
["Authelia"]="Mariadb-Authelia Redis-Authelia"
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
["NextCloud"]="Postgres-NextCloud"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use this when a container legitimately has large stable data and you want to guarantee
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
# container's dir stays below this ceiling; above it, warnings resume as normal.
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
["7dtd"]="20480" # 20GB — game server world data, expected to be large
)
# ━━━ Network Watchdog ━━━
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST1_NETWORK_CONNECT_CONTAINERS=(
"memcached"
"Npm-CrowdSec"
)
HOST1_NETWORK_CONNECT_NETWORKS=(
"high-availability"
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
HOST1_DDNS_CONTAINERS=(
"Gmer4Lfe.com"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST1 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST1_STOP_ON_NO_NET=(
"Gmer4Lfe.com"
)
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
# Containers HOST1 starts when HOST2 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
"Gmer4Lfe.us"
"VaultWarden-Jayred365"
)
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
# "container-placeholder"
)
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST1_WRITEBACK_TIER1=(
"/mnt/user/Media_Server/Emby" # watch states built up during outage
)
FALLBACK_HOST1_WRITEBACK_TIER2=(
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
)
FALLBACK_HOST1_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST1_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST1_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/appcache
/mnt/user/Books
/mnt/user/Downloads
/mnt/user/Games
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movie_Recordings
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Photo
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Temp_Storage
/mnt/user/Tv_Recordings
/mnt/user/Tv_Shows
/mnt/user/YouTube
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST1_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
)
HOST1_MEDIA_CLEAN_FOLDERS=(
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Tv_Shows
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST1_CERT_MONITOR_DOMAINS=(
"Gmer4Lfe.com"
"Gmer4Lfe.us"
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST1_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
# Pool health thresholds defined in master.conf.
HOST1_ZFS_REPORT_IGNORE_POOLS=(
"disk5"
"disk6"
"disk8"
"disk9"
"disk10"
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
HOST1_RAMDISK_SIZE="10G"
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST1.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST1_TRANSCODE_SERVERS=(
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST1 vars when running on HOST1.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
# ━━━ Downloaders ━━━
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
# Clears stuck states, purges old history, prepares each client for a clean cycle.
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
HOST1_SLSKD_URL="http://localhost:8980"
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
# SABnzbd
HOST1_SABNZBD_URL="http://localhost:8180"
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently.
HOST1_QBIT_URL="http://localhost:8080"
HOST1_QBIT_USERNAME="root"
HOST1_QBIT_PASSWORD="Stay0utD!ck"
# ━━━ Lidarr — HOST1 only ━━━
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
HOST1_LIDARR_URL="http://localhost:8686"
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
declare -A HOST1_LIDARR_PATH_MAP=(
["/ext-music"]="/mnt/user/Music-New"
)
# ━━━ Sonarr ━━━
HOST1_SONARR_URL="http://localhost:8989"
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
declare -A HOST1_SONARR_PATH_MAP=(
["/tv"]="/mnt/user/Tv_Shows"
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
)
# ━━━ Radarr ━━━
HOST1_RADARR_URL="http://localhost:7878"
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
declare -A HOST1_RADARR_PATH_MAP=(
["/movies"]="/mnt/user/Movies"
["/kids movies"]="/mnt/user/Kids_Movies"
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST1_SONARR_RECOVERY=true
HOST1_RADARR_RECOVERY=true
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
#
# Three-tier response — all critical checks enabled by default on HOST1:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): everything else
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST1_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# These bypass the strike system — a single hit triggers immediate reboot.
# Disabling any of these is not recommended — they protect against acute system failure.
# Docker daemon unresponsive → try restart, reboot if restart fails.
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
HOST1_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
# Unexpected read-only /boot means state files and config writes are silently failing.
# Fallback state, watchdog reboot log, and lock files all go stale silently.
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
# Also provides diagnostic context in reboot messages (which processes were killed).
HOST1_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
HOST1_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
# Single spikes are ignored — sustained problems trigger reboot.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
HOST1_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
HOST1_SYS_WATCHDOG_CHECK_ARC=true
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
# Large zombie counts indicate serious process management failure — something is stuck.
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
# Script tries to clear aged /tmp files first — only strikes if clear fails.
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
HOST1_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — detects NIC going down (physical or driver failure).
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — attempts restart before escalating.
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST1_RW_PAUSE_CONTAINERS=(
"Huntarr" # arr search automation — safe to suspend
"Cleanuparr" # download cleanup — safe to suspend
"Healarr" # arr health checks — safe to suspend
"Soularr" # Slskd automation — background only
"ChannelTube" # YouTube archiver — background only
"Pinchflat" # YouTube archiver — background only
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST1_RW_STOP_CONTAINERS=(
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
"7DaysToDie" # game server — optional
"V-Rising" # game server — optional
"Code-Server" # IDE — not needed during pressure events
)
# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# NPM: Admin email + password from NginxProxyManager settings.
# lldap: Admin username + password from lldap config.
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
# ━━━ NginxProxyManager ━━━
HOST1_NPM_URL="http://localhost:81"
HOST1_NPM_USER="" # NPM admin email
HOST1_NPM_PASS="" # NPM admin password
# ━━━ lldap ━━━
HOST1_LLDAP_URL="http://localhost:17170"
HOST1_LLDAP_USER="admin" # lldap admin username
HOST1_LLDAP_PASS="" # lldap admin password
# ━━━ Authelia ━━━
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
HOST1_AUTHELIA_CONTAINER="Authelia"
# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,793 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
# ==============================================================================================
# HOST1-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST2 never receives this file.
# HOST2 never sees HOST1 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST2 variables here — they belong in host2.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST1
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
# RSYNC WRITEBACK HOST1 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
# LIDARR URL, API key, path map
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
HOST1_OWNER="gmer4lfe"
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
HOST1_UNRAID_API_KEY="e6acb2dfea4c4434fc63ff35f78b4b68434680cc68b602e0eb48059b5f5ef4b9"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST1_EMBY_CONTAINER="Emby"
HOST1_EMBY_URL="http://localhost:8096"
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST1_JELLYFIN_CONTAINER="Jellyfin"
HOST1_JELLYFIN_URL="http://localhost:8095"
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
# ━━━ Gitea ━━━
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
# with Gitea so git operations use key auth instead of passwords.
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOST1_GITEA_API_TOKEN=""
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST1_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST1 is always the owner (source of truth) unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
"NginxProxyManager|81"
"Lldap-Gmer4Lfe|17170"
"Authelia|9091"
"Authelia-Secondary|9092"
)
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
# Update filename if Lldap is renamed to drop the host suffix.
HOST1_PARTNERSHIP_AUTH_STACK=(
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
"my-Mariadb-Authelia.xml"
"my-Mariadb-Authelia-Secondary.xml"
"my-Redis-Authelia.xml"
"my-Redis-Authelia-Secondary.xml"
# Auth apps — deployed after their deps are confirmed healthy
"my-Authelia.xml"
"my-Authelia-Secondary.xml"
"my-NginxProxyManager.xml"
"my-Lldap-Gmer4Lfe.xml"
# Source of truth — must be available on HOST2 independently of the auth stack
"my-Gitea.xml"
)
# XML templates pushed to mirror for the arr stack during onboard.
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
HOST1_PARTNERSHIP_ARR_STACK=(
# "my-Sonarr.xml"
# "my-Radarr.xml"
# "my-Lidarr.xml"
# "my-Prowlarr.xml"
# "my-Bazarr.xml"
)
# Paths HOST2 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
# On offboard: account is deleted. Username collision → onboard exits with error.
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
HOST1_PARTNERSHIP_EMBY_PORT=8096
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST1_DAILY_SYNC_SHARES=(
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Nextcloud
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST1_PERSONAL_SHARES=(
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
HOST1_WEEKLY_SYNC_SHARES=(
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST1_INTERMEDIATE_SYNC_SHARES=(
# Add shares here to enable mid-day rsync
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
HOST1_CRITICAL_SYNC_SHARES=(
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST1_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
PROFILE_BW_LIMIT[host1-appdata]=8000
PROFILE_RETRY_COUNT[host1-appdata]=3
PROFILE_SLEEP[host1-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
PROFILE_CONTAINER_DELAY[host1-appdata]=5
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
# Order matters — auth stack first, then media services.
HOST1_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Authelia"
"Authelia-Secondary"
"Dispatcharr-Iptv-Users"
"Dispatcharr" # Live TV scheduler — degrades without daily restart
"Dispatcharr-Basic"
"ErsatzTV-Emby"
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST1_WEEKLY_RESTART_CONTAINERS=(
"NextCloud"
"Organizrv2-Gmer4Lfe"
"AdGuard-Home"
"Immich-Gmer4Lfe"
)
# ━━━ Docker Watchdog ━━━
# Per-HOST1 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST1_WATCHDOG_CONTAINERS=(
["Emby"]=20480 # 20GB — large library + active transcodes
["LidaTube"]=6144 # 6GB — memory leak over time
["Tdarr"]=6144 # 6GB — encoding is memory intensive
["Code-Server"]=1024 # 1GB — should never need more
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST1.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
"Lldap-Gmer4Lfe"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Authelia"
"Authelia-Secondary"
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST1_WATCHDOG_SCAN_IGNORE=(
"DashGate"
"PIA-WG-Config-Generator"
"Aperture"
"Aperture-Kids"
"pgvector-18-Apeture-Kids"
"Pgvector18-Aperture"
"emby-test" # broken test container (exit 127 — bad image)
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting Authelia before Mariadb is back up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
["Authelia"]="Mariadb-Authelia Redis-Authelia"
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
["NextCloud"]="Postgres-NextCloud"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use this when a container legitimately has large stable data and you want to guarantee
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
# container's dir stays below this ceiling; above it, warnings resume as normal.
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
["7dtd"]="20480" # 20GB — game server world data, expected to be large
)
# ━━━ Network Watchdog ━━━
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST1_NETWORK_CONNECT_CONTAINERS=(
"memcached"
"Npm-CrowdSec"
)
HOST1_NETWORK_CONNECT_NETWORKS=(
"high-availability"
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
HOST1_DDNS_CONTAINERS=(
"Gmer4Lfe.com"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST1 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST1_STOP_ON_NO_NET=(
"Gmer4Lfe.com"
)
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
# Containers HOST1 starts when HOST2 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
"Gmer4Lfe.us"
"VaultWarden-Jayred365"
)
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
# "container-placeholder"
)
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
# "container-placeholder"
)
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST1_WRITEBACK_TIER1=(
"/mnt/user/Media_Server/Emby" # watch states built up during outage
)
FALLBACK_HOST1_WRITEBACK_TIER2=(
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
)
FALLBACK_HOST1_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST1_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST1_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/appcache
/mnt/user/Books
/mnt/user/Downloads
/mnt/user/Games
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movie_Recordings
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/Photo
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Temp_Storage
/mnt/user/Tv_Recordings
/mnt/user/Tv_Shows
/mnt/user/YouTube
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST1_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Movies-Old
/mnt/user/Anime_Shows
/mnt/user/Anime_Shows-Old
)
HOST1_MEDIA_CLEAN_FOLDERS=(
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Sports
/mnt/user/stand-up_comedy
/mnt/user/Tv_Shows
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST1_CERT_MONITOR_DOMAINS=(
"Gmer4Lfe.com"
"Gmer4Lfe.us"
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST1_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
# Pool health thresholds defined in master.conf.
HOST1_ZFS_REPORT_IGNORE_POOLS=(
"disk5"
"disk6"
"disk8"
"disk9"
"disk10"
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
HOST1_RAMDISK_SIZE="10G"
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST1.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST1_TRANSCODE_SERVERS=(
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST1 vars when running on HOST1.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
# ━━━ Downloaders ━━━
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
# Clears stuck states, purges old history, prepares each client for a clean cycle.
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
HOST1_SLSKD_URL="http://localhost:8980"
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
# SABnzbd
HOST1_SABNZBD_URL="http://localhost:8180"
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently.
HOST1_QBIT_URL="http://localhost:8080"
HOST1_QBIT_USERNAME="root"
HOST1_QBIT_PASSWORD="Stay0utD!ck"
# ━━━ Lidarr — HOST1 only ━━━
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
HOST1_LIDARR_URL="http://localhost:8686"
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
declare -A HOST1_LIDARR_PATH_MAP=(
["/ext-music"]="/mnt/user/Music-New"
)
# ━━━ Sonarr ━━━
HOST1_SONARR_URL="http://localhost:8989"
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
declare -A HOST1_SONARR_PATH_MAP=(
["/tv"]="/mnt/user/Tv_Shows"
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
)
# ━━━ Radarr ━━━
HOST1_RADARR_URL="http://localhost:7878"
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
declare -A HOST1_RADARR_PATH_MAP=(
["/movies"]="/mnt/user/Movies"
["/kids movies"]="/mnt/user/Kids_Movies"
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST1_SONARR_RECOVERY=true
HOST1_RADARR_RECOVERY=true
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
#
# Three-tier response — all critical checks enabled by default on HOST1:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): everything else
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST1_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# These bypass the strike system — a single hit triggers immediate reboot.
# Disabling any of these is not recommended — they protect against acute system failure.
# Docker daemon unresponsive → try restart, reboot if restart fails.
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
HOST1_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
# Unexpected read-only /boot means state files and config writes are silently failing.
# Fallback state, watchdog reboot log, and lock files all go stale silently.
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
# Also provides diagnostic context in reboot messages (which processes were killed).
HOST1_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
HOST1_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
# Single spikes are ignored — sustained problems trigger reboot.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
HOST1_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
HOST1_SYS_WATCHDOG_CHECK_ARC=true
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
# Large zombie counts indicate serious process management failure — something is stuck.
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
# Script tries to clear aged /tmp files first — only strikes if clear fails.
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
HOST1_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — detects NIC going down (physical or driver failure).
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — attempts restart before escalating.
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST1_RW_PAUSE_CONTAINERS=(
"Huntarr" # arr search automation — safe to suspend
"Cleanuparr" # download cleanup — safe to suspend
"Healarr" # arr health checks — safe to suspend
"Soularr" # Slskd automation — background only
"ChannelTube" # YouTube archiver — background only
"Pinchflat" # YouTube archiver — background only
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST1_RW_STOP_CONTAINERS=(
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
"7DaysToDie" # game server — optional
"V-Rising" # game server — optional
"Code-Server" # IDE — not needed during pressure events
)
# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# NPM: Admin email + password from NginxProxyManager settings.
# lldap: Admin username + password from lldap config.
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
# ━━━ NginxProxyManager ━━━
HOST1_NPM_URL="http://localhost:81"
HOST1_NPM_USER="failedproxy@gmail.com" # NPM admin email
HOST1_NPM_PASS="183134\$eanHess" # NPM admin password
# ━━━ lldap ━━━
HOST1_LLDAP_URL="http://localhost:17170"
HOST1_LLDAP_USER="FailedProxy" # lldap admin username
HOST1_LLDAP_PASS="183134\$eanHess" # lldap admin password
# ━━━ Authelia ━━━
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
HOST1_AUTHELIA_CONTAINER="Authelia"
# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $body['action'] ?? '';
$allowed = ['stop', 'shutdown', 'restart'];
if (!in_array($action, $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'invalid action']);
exit;
}
$cmd = match($action) {
'stop' => '/usr/local/sbin/mdcmd stop',
'shutdown' => '/sbin/shutdown -h now',
'restart' => '/sbin/shutdown -r now',
};
$logLine = date('Y-m-d H:i:s') . " action={$action} ip=" . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
@file_put_contents('/boot/config/plugins/varaverk/actions.log', $logLine, FILE_APPEND | LOCK_EX);
exec($cmd . ' > /dev/null 2>&1 &');
echo json_encode(['ok' => true]);
@@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $body['action'] ?? '';
$allowed = ['stop', 'shutdown', 'restart'];
if (!in_array($action, $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'invalid action']);
exit;
}
$cmd = match($action) {
'stop' => '/usr/local/sbin/mdcmd stop',
'shutdown' => '/sbin/shutdown -h now',
'restart' => '/sbin/shutdown -r now',
};
$logLine = date('Y-m-d H:i:s') . " action={$action} ip=" . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
@file_put_contents(SCRIPTS_DIR . '/actions.log', $logLine, FILE_APPEND | LOCK_EX);
exec($cmd . ' > /dev/null 2>&1 &');
echo json_encode(['ok' => true]);
@@ -0,0 +1,62 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'settings'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'settings' => 'Settings'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="margin-left:auto;padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
@@ -0,0 +1,62 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="margin-left:auto;padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
@@ -0,0 +1,65 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
</div>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
@@ -0,0 +1,65 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'cert', 'rsync', 'auth', 'settings'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'cert' => 'Certs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
</div>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
@@ -0,0 +1,65 @@
Menu="Tasks:95"
Title="Varaverk"
Icon="varaverk.png"
---
<?php
$plugin = 'varaverk';
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
$pluginDir = "$docroot/plugins/$plugin";
require_once "$pluginDir/include/config.php";
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$_host1_blank = empty(trim($_h1m[1] ?? ''));
$_my_hostid = vv_detect_host();
$_conf_missing = $_my_hostid !== 'unknown'
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
if ($_host1_blank || $_conf_missing) {
include "$pluginDir/pages/setup.php";
return;
}
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
// Determine active tab
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
if (!in_array($tab, $validTabs)) $tab = 'monitor';
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
?>
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
<div id="varaverk-wrap">
<!-- Tab bar -->
<div id="vv-tabs">
<?php foreach ($validTabs as $t): ?>
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
<?= $tabLabels[$t] ?? ucfirst($t) ?>
</a>
<?php endforeach; ?>
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
display:flex;align-items:center;letter-spacing:.03em;"
title="GitHub — source, issues, changelog">
⎋ GitHub
</a>
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
</div>
</div>
<!-- Tab content -->
<div id="vv-content">
<?php
$page = "$pluginDir/pages/$tab.php";
if (file_exists($page)) include $page;
else echo "<p>Page not found: $tab</p>";
?>
</div>
</div>
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
@@ -0,0 +1,56 @@
<?php
// API cache writer — runs every minute via Varaverk scheduler.
// Builds monitor + arrs payloads and writes them to /tmp/vv_cache/ so page
// loads can serve instantly from the file instead of making live HTTP calls.
//
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
$_base = dirname(__DIR__) . '/Plugin/unraid';
require_once $_base . '/include/monitor.php';
require_once $_base . '/include/vms.php';
require_once $_base . '/include/docker_folders.php';
require_once $_base . '/include/arrs.php';
$t = microtime(true);
// ── Monitor payload ───────────────────────────────────────────────────────────
// Call vv_api_data() once — result is static-cached for the rest of this process.
vv_api_data();
$monitor = [
'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
'fallback_active' => vv_fallback_active(),
'partner' => vv_partner_state(),
'resources' => vv_system_resources(),
'cpu' => vv_cpu_per_core(),
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(),
'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(),
'stopped' => vv_docker_stopped(),
'transcode' => vv_transcode_sessions(),
'ups' => vv_ups_stats(),
'parity' => vv_parity_status(),
'storage' => vv_storage_pools(),
'array_disks' => vv_array_disks(),
'disk_io' => vv_disk_io_rates(),
'watchdog' => vv_watchdog_summary(),
'scripts' => vv_scripts_status(),
'rsync' => vv_rsync_status(),
'thresholds' => vv_disk_thresholds(),
'vms' => vv_get_vms(),
'docker_folders' => vv_get_docker_folders(),
'remote_hosts' => vv_remote_hosts_stats(),
'_api_status' => vv_api_get_status(),
'ts' => time(),
];
vv_cache_write('monitor', $monitor);
// ── Arrs payload ──────────────────────────────────────────────────────────────
$arrs = vv_arrs_all();
vv_cache_write('arrs', $arrs);
$elapsed = round((microtime(true) - $t) * 1000);
echo "Cache written in {$elapsed}ms — monitor + arrs\n";
@@ -0,0 +1,56 @@
<?php
// API cache writer — runs every minute via Varaverk scheduler.
// Builds monitor + arrs payloads and writes them to /tmp/vv_cache/ so page
// loads can serve instantly from the file instead of making live HTTP calls.
//
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
$_base = dirname(__DIR__);
require_once $_base . '/include/monitor.php';
require_once $_base . '/include/vms.php';
require_once $_base . '/include/docker_folders.php';
require_once $_base . '/include/arrs.php';
$t = microtime(true);
// ── Monitor payload ───────────────────────────────────────────────────────────
// Call vv_api_data() once — result is static-cached for the rest of this process.
vv_api_data();
$monitor = [
'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
'fallback_active' => vv_fallback_active(),
'partner' => vv_partner_state(),
'resources' => vv_system_resources(),
'cpu' => vv_cpu_per_core(),
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(),
'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(),
'stopped' => vv_docker_stopped(),
'transcode' => vv_transcode_sessions(),
'ups' => vv_ups_stats(),
'parity' => vv_parity_status(),
'storage' => vv_storage_pools(),
'array_disks' => vv_array_disks(),
'disk_io' => vv_disk_io_rates(),
'watchdog' => vv_watchdog_summary(),
'scripts' => vv_scripts_status(),
'rsync' => vv_rsync_status(),
'thresholds' => vv_disk_thresholds(),
'vms' => vv_get_vms(),
'docker_folders' => vv_get_docker_folders(),
'remote_hosts' => vv_remote_hosts_stats(),
'_api_status' => vv_api_get_status(),
'ts' => time(),
];
vv_cache_write('monitor', $monitor);
// ── Arrs payload ──────────────────────────────────────────────────────────────
$arrs = vv_arrs_all();
vv_cache_write('arrs', $arrs);
$elapsed = round((microtime(true) - $t) * 1000);
echo "Cache written in {$elapsed}ms — monitor + arrs\n";
@@ -0,0 +1,931 @@
/* Varaverk plugin styles — inherits unRAID theme, adds plugin-specific layout */
#varaverk-wrap { padding: 10px; font-family: inherit; }
/* Tab bar */
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; }
.vv-tab { padding: 6px 16px; text-decoration: none; color: #aaa; border-radius: 4px 4px 0 0; }
.vv-tab:hover { color: #fff; background: #333; }
.vv-tab.active { color: #fff; background: #555; border-bottom: 2px solid #fff; }
/* Cards / layout */
.vv-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.vv-card { flex: 1; min-width: 200px; background: #1e1e1e; border: 1px solid #444;
border-radius: 6px; padding: 12px; }
.vv-wide { flex: 100%; }
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
color: #888; letter-spacing: 0.05em; white-space: normal;
overflow: hidden; min-width: 0;
display: flex; align-items: center; justify-content: space-between; }
/* Cog icon — links to related Unraid page. Hidden until card hovered. */
.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none;
padding: 1px 3px; border-radius: 3px; flex-shrink: 0;
transition: color 0.15s, background 0.15s;
font-style: normal; font-weight: normal; text-transform: none;
letter-spacing: 0; cursor: pointer; }
.vv-card:hover .vv-card-cog { color: #4a4a4a; }
.vv-card-cog:hover { color: #aaa !important; background: #333; }
/* Card header icon wrapper */
.vv-ico { display:inline-flex; align-items:center; opacity:0.38; flex-shrink:0; }
/* Status accent — left border colour for state-driven cards */
.vv-accent-ok { border-left-color: #2a5a2a !important; }
.vv-accent-warn { border-left-color: #5a4000 !important; }
.vv-accent-err { border-left-color: #5a1e1e !important; }
#vv-monitor .vv-card { transition: border-left-color 0.5s; }
/* Status banner — coloured strip at top of card body */
.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 600; }
.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; }
.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; }
.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; }
.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; }
/* System card — no h3, no top padding waste */
#vv-system { padding-top: 14px; }
/* System action buttons */
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
width: 50px; min-width: 0; text-align: center; }
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
/* Fallback state badge */
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
.vv-state-normal { color: #4caf50; }
.vv-state-failover { color: #f44336; }
.vv-state-no_internet { color: #ff9800; }
.vv-state-dark { color: #9e9e9e; }
.vv-state-unknown { color: #666; }
/* Docker table */
#vv-docker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
#vv-docker-table th { text-align: left; padding: 4px 8px; color: #888;
border-bottom: 1px solid #444; }
#vv-docker-table td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; }
.vv-status-up { color: #4caf50; }
.vv-status-down { color: #f44336; }
/* Scheduler */
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
.vv-sched-card { margin-bottom: 10px; }
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
.vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
transition: background 0.1s, box-shadow 0.1s; }
/* Orchestrator top row — clickable, show pointer + hover feedback */
.vv-orch-row { cursor: pointer; border-radius: 4px; }
.vv-orch-row:hover { background: rgba(255,255,255,0.04); }
.vv-job-label { flex: 1; font-size: 16px; font-weight: bold;
color: #6fcf97; min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-job-actions { display: flex; align-items: center; gap: 8px; padding-left: 44px; margin-top: 6px; }
.vv-job-desc { font-size: 14px; color: #777; margin: 3px 0 2px 0;
padding-left: 44px; box-sizing: border-box; width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: default; }
.vv-cron { flex: 0 0 80px; width: 80px; background: #111; border: 1px solid #444; color: #ddd;
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; }
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
white-space: nowrap; font-weight: 500; }
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
white-space: nowrap; font-family: monospace; }
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.vv-log-label input { cursor: pointer; accent-color: #4caf50; }
.vv-log-label:has(input:checked) { color: #4caf50; }
.vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.vv-advanced-toggle:hover { border-color: #888; color: #fff; }
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Save checkmark */
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
opacity: 0; text-align: center; }
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
/* Running dot */
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
/* Selected job row — editor-style left accent + blue tint */
.vv-row-selected { background: rgba(100,149,237,0.1); border-radius: 4px;
outline: 1px solid rgba(100,149,237,0.35);
box-shadow: inset 3px 0 0 #6495ed; }
/* Two-panel layout */
#vv-sched-layout { display: flex; gap: 16px; align-items: stretch; }
#vv-sched-left { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-self: flex-start; }
#vv-sched-cards { flex: 1; }
#vv-sched-right { display: none; flex: 1 1 0; min-width: 0; flex-direction: column; overflow: hidden; align-self: flex-start; }
#vv-sched-right.vv-panel-visible { display: flex; }
.vv-log-card { flex: 1; display: flex; flex-direction: column; padding-bottom: 0; }
.vv-log-right-pre { max-height: none; overflow-y: auto; }
/* Scheduler stacked layout (narrow viewport) */
@media (max-width: 900px) {
#vv-sched-layout { flex-direction: column; align-items: stretch; }
#vv-sched-left { flex: none; width: 100%; }
#vv-sched-right { flex-direction: column; width: 100%; overflow: visible; }
.vv-log-right-pre { min-height: 520px; max-height: 680px; }
/* Toolbar: stack title row above buttons row, let buttons wrap */
.vv-log-toolbar { flex-direction: column; align-items: flex-start; gap: 6px; }
.vv-log-toolbar > div { flex-wrap: wrap; gap: 6px !important; }
/* Array-event rows: badge already shows the event, cron input is redundant */
.vv-job-row:has(.vv-event-badge) .vv-cron { display: none; }
}
/* Plugin settings row (Advanced mode) */
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
/* ── Monitor: locked row height + scrollable cards ────────────────────────── */
/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */
#vv-monitor .vv-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
#vv-monitor .vv-card h3 { flex-shrink: 0; }
#vv-monitor .vv-card > div {
flex: 1;
min-height: 0;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; }
/* Hide scrollbars on any nested scrollable div inside monitor cards */
#vv-monitor .vv-card div::-webkit-scrollbar { display: none; }
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
Breakpoints are viewport height (after browser chrome), not screen height. */
/* ~720p (viewport ≤ 700px) */
@media (min-width: 481px) and (max-height: 700px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); }
}
/* ~1080p (viewport 7011100px) */
@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); }
}
/* ~1440p (viewport 11011450px) — calibrated on 15" 1440p display */
@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); }
}
/* ~4K (viewport > 1450px) */
@media (min-width: 481px) and (min-height: 1451px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); }
}
/* Mobile: natural heights, let page scroll */
@media (max-width: 480px) {
#vv-monitor { grid-auto-rows: auto !important; }
#vv-monitor .vv-card { overflow: visible !important; }
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
}
/* Monitor responsive — 4-column grid at medium width */
@media (max-width: 1400px) {
/* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */
.vv-cpu-cores { gap: 2px !important; }
.vv-cpu-core { min-width: 7px !important; }
}
@media (max-width: 1024px) {
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
#vv-docker { grid-column: span 4 !important; }
/* Reset explicit placements so cards reflow in the 4-col grid */
#vv-docker-folders { grid-column: span 4 !important; }
#vv-parity-card { grid-column: auto !important; }
#vv-storage-card { grid-column: auto !important; }
#vv-array-card { grid-column: auto !important; }
/* Streams header: hide right chip group entirely, keep server badges + media type */
.vv-stream-right { display: none; }
}
/* Containers+VMs: single column when viewport is narrow */
@media (max-width: 900px) {
.vv-df-cols { flex-direction: column; align-items: stretch; }
}
/* Phone layout — scheduler row/actions fixes + monitor single-column */
@media (max-width: 480px) {
/* Phone portrait — hint gone, cron stays inline but compact */
.vv-cron-hint { display: none !important; }
.vv-job-row .vv-cron { flex: 0 0 68px; width: 68px; font-size: 10px; margin-left: 30px; }
}
@media (max-width: 600px) {
/* Allow action buttons to wrap rather than overflow the card */
.vv-job-actions { flex-wrap: wrap; padding-left: 0; }
/* Kill the inline margin-left:auto that pushes Advanced off-screen */
.vv-advanced-toggle { margin-left: 0 !important; width: auto !important; }
/* Keep cron from growing — label owns remaining row space */
.vv-cron { flex: 0 0 75px; width: 75px; }
/* Slightly tighter label on narrow screens */
.vv-job-label { font-size: 14px; }
/* Footer buttons wrap instead of overflowing */
.vv-sched-footer { flex-wrap: wrap; }
/* Log toolbar search — narrow on small screens */
#vv-log-search { width: 80px; }
/* Snapshot footer — smaller on mobile */
.vv-snap-footer { gap: 10px !important; }
.vv-snap-item { gap: 4px; }
.vv-snap-label { font-size: 10px; }
.vv-snap-bar { width: 44px; height: 5px; }
.vv-snap-val { font-size: 11px; min-width: 26px; }
.vv-snap-div { font-size: 11px; }
.vv-snap-state { font-size: 11px; }
#vv-snap-partner { font-size: 11px; }
.vv-snap-media { font-size: 11px; }
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
.vv-cpu-cores { gap: 2px !important; }
.vv-cpu-core { min-width: 6px !important; }
/* Monitor single-column — explicit placement cards need override too */
#vv-monitor { grid-template-columns: 1fr !important; }
#vv-monitor > .vv-card { grid-column: 1 / -1 !important; }
#vv-docker-folders { grid-column: 1 / -1 !important; }
}
/* Shared footer (Save Schedule left, info right) — same min-height so log card ends level with script cards */
.vv-sched-footer { display: flex; align-items: center; gap: 10px;
margin-top: 12px; padding: 10px 0; border-top: 1px solid #333;
flex-shrink: 0; min-height: 72px; box-sizing: border-box; }
.vv-sched-info { color: #666; font-size: 14px; display: flex; flex-direction: column; gap: 9px; }
.vv-save-btn { padding: 5px 18px; background: #4caf50; border: none; color: #fff;
border-radius: 4px; cursor: pointer; font-size: 13px; }
.vv-save-btn:hover { background: #388e3c; }
.vv-save-status { font-size: 12px; color: #aaa; }
.vv-run-btn { border-color: #2196f3 !important; color: #2196f3 !important; }
.vv-run-btn:hover { background: #0d47a1 !important; color: #fff !important;
border-color: #2196f3 !important; }
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
border-color: #ff9800 !important; }
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
border-color: #4caf50 !important; }
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
.vv-add-script-btn:hover { background: #0d47a1 !important; }
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
.vv-delete-btn:hover { background: #7f0000 !important; }
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
/* Config form */
#vv-confform { padding: 2px 4px; }
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
letter-spacing: 0; }
.vv-cf-field { margin-bottom: 10px; }
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
.vv-cf-scalar:focus { border-color: #555; outline: none; }
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
line-height: 1.6; resize: vertical; min-height: 60px; }
.vv-cf-array:focus { border-color: #555; outline: none; }
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
#vv-editor { flex-direction: column; gap: 0; }
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box;
white-space: pre; overflow-x: auto; }
/* Editor layout: gutter + inner area — unified bordered block */
#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0;
overflow: hidden; background: #0d0d0d; }
#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0;
background: #0a0a0a; border-right: 1px solid #1c1c1c;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
overflow: hidden; user-select: none; white-space: pre; }
.vv-gln-cur { color: #ccc; font-weight: bold; }
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; }
/* Current line highlight — sits behind overlay */
#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px;
background: rgba(255,255,255,0.04);
border-top: 1px solid rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.03);
pointer-events: none; display: none; }
/* Syntax-highlight overlay — transparent bg so cur-line shows through */
#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
margin: 0; padding: 10px 12px; box-sizing: border-box;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
white-space: pre; overflow: hidden;
pointer-events: none; user-select: none;
background: transparent; border: none; border-radius: 0; }
.vv-editor-hl #vv-hl-overlay { display: block; }
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent;
border: none !important; border-radius: 0 !important; }
/* Visible selection — works even when text is transparent */
.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; }
.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; }
/* Word match and search marks in the overlay */
.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; }
.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; }
.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; }
/* Slim scrollbar for editor */
#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; }
#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; }
#vv-editor-body::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; }
#vv-editor-body::-webkit-scrollbar-thumb:hover { background: #444; }
/* Editor find bar — two-row (find + optional replace) */
#vv-editor-find { display: none; flex-direction: column; gap: 0; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-editor-find.vv-find-open { display: flex; }
.vv-find-row { display: flex; align-items: center; gap: 6px; }
.vv-replace-row { display: none; align-items: center; gap: 6px;
margin-top: 4px; padding-top: 4px; border-top: 1px solid #222; }
.vv-replace-row.vv-repl-open { display: flex; }
.vv-find-expand { font-size: 10px; color: #555; cursor: pointer; flex-shrink: 0;
width: 14px; text-align: center; user-select: none;
transition: transform 0.15s; line-height: 1; }
.vv-find-expand:hover { color: #aaa; }
.vv-find-expand.open { transform: rotate(90deg); color: #888; }
#vv-find-input, #vv-replace-input {
flex: 1; min-width: 0; background: #0d0d0d; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 3px;
font-family: monospace; font-size: 12px; }
#vv-find-input:focus, #vv-replace-input:focus {
border-color: #6495ed; outline: none; box-shadow: 0 0 0 1px rgba(100,149,237,0.25); }
#vv-find-input.vv-find-no-match { border-color: #c62828; box-shadow: 0 0 0 1px rgba(198,40,40,0.3); }
.vv-find-count { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; min-width: 64px; text-align: right; }
.vv-find-nav-btn{ padding: 2px 8px !important; }
.vv-repl-btn { border-color: #444 !important; color: #777 !important; }
.vv-repl-btn:hover { border-color: #777 !important; color: #ddd !important; }
.vv-find-x { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
padding: 0 2px; flex-shrink: 0; }
.vv-find-x:hover { color: #aaa; }
/* Editor status bar */
#vv-editor-status { display: none; align-items: center; gap: 10px; padding: 3px 10px;
background: #0e0e0e; border: 1px solid #2e2e2e; border-top: none;
border-radius: 0 0 4px 4px; font-family: monospace;
font-size: 10px; color: #444; user-select: none; flex-shrink: 0; }
#vv-editor-status.vv-ed-active { display: flex; }
.vv-es-pos { color: #666; }
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* Status bar action buttons */
.vv-es-btn { background: none; border: none; cursor: pointer; color: #555;
font-size: 10px; padding: 1px 4px; border-radius: 2px; font-family: monospace; }
.vv-es-btn:hover { color: #aaa; background: rgba(255,255,255,0.05); }
.vv-es-btn.active { color: #6495ed; }
#vv-es-fontsize { color: #444; font-size: 10px; min-width: 26px; text-align: center; }
/* Word wrap mode — applied to #vv-editor-wrap when wrap is enabled */
#vv-editor-wrap.vv-ed-wrap .vv-editor-body { white-space: pre-wrap !important; overflow-x: hidden !important; }
#vv-editor-wrap.vv-ed-wrap #vv-hl-overlay { white-space: pre-wrap !important; word-break: break-word !important; }
#vv-editor-wrap.vv-ed-wrap #vv-ln-gutter { display: none; }
#vv-editor-wrap.vv-ed-wrap #vv-cur-line { display: none !important; }
/* Indent guides — 1px lines every 2 chars, starting at indent level 1 */
#vv-editor-inner {
position: relative; flex: 1; overflow: hidden;
background-color: #0d0d0d;
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.045) 0 1px,
transparent 1px 2ch
);
background-position: calc(12px + 2ch) 0;
}
/* Go to line bar */
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-goto-bar.vv-goto-open { display: flex; }
#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd;
padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
#vv-goto-input:focus { border-color: #6495ed; outline: none; }
.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; }
/* Undo / Redo buttons */
.vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; }
.vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; }
.vv-undo-redo-btn[disabled] { opacity: 0.28 !important; cursor: default !important; pointer-events: none; }
.vv-undo-count { font-size: 10px; opacity: 0.7; margin-left: 2px; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
.vv-sug-header { display: flex; align-items: center; gap: 8px; padding: 7px 4px;
cursor: pointer; user-select: none; flex-wrap: wrap; }
.vv-sug-header:hover { background: rgba(255,255,255,0.03); }
.vv-sug-chevron { color: #555; font-size: 11px; flex-shrink: 0; width: 10px; }
.vv-sug-title { color: #7a9eb5; font-weight: bold; font-size: 13px; flex: 1; min-width: 120px; }
.vv-sug-cron { color: #ddd; background: #111; border: 1px solid #333; padding: 1px 6px;
border-radius: 3px; font-size: 11px; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
.vv-sug-label { color: #666; font-size: 12px; flex: 1; min-width: 0; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.vv-sug-status { font-size: 11px; flex-shrink: 0; margin-left: auto; }
.vv-sug-on { color: #4caf50; }
.vv-sug-off { color: #777; }
.vv-sug-none { color: #444; }
.vv-sug-body { padding: 0 14px 10px; }
.vv-sug-desc { font-size: 12px; color: #888; white-space: pre-wrap; word-break: break-word;
background: none; border: none; margin: 4px 0 8px; padding: 0;
font-family: inherit; line-height: 1.6; }
.vv-sug-scripts { display: flex; flex-direction: column; gap: 4px; }
.vv-sug-script-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.vv-sug-inline-cron { color: #aaa; background: #111; padding: 1px 5px; border-radius: 3px;
font-size: 11px; white-space: nowrap; }
.vv-sug-path { color: #888; font-family: monospace; }
.vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */
.vv-info-block .vv-sug-title { color: #9ab; }
.vv-info-body { padding: 2px 10px 10px; }
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
.vv-info-cols .vv-info-sep { column-span: all; list-style: none; margin: 10px -4px 6px;
padding: 4px 8px; background: #161a1d; border-left: 2px solid #1e6fa5;
font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .08em; color: #4a8ab5; break-inside: avoid; }
.vv-info-cols li strong { color: #ccc; }
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
font-size: 11px; color: #9ab; border: 1px solid #333; }
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
/* How do I use this — pinned at top of suggestions panel */
#vv-how-to-use {
position: sticky;
top: 0;
z-index: 3;
background: #1e1e1e;
border-bottom: 1px solid #333;
}
/* Run-status dot colours (tree + activity list) */
.vv-stat-ok { color: #4caf50; }
.vv-stat-warn { color: #ff9800; }
.vv-stat-error{ color: #f44336; }
.vv-stat-skip { color: #607d8b; }
.vv-stat-none { color: #444; }
/* Cron humanizer hint — left of cron input; first to collapse under space pressure */
.vv-cron-hint { font-size: 10px; color: #505050; white-space: nowrap;
flex: 0 10 auto; min-width: 0; max-width: 130px;
overflow: hidden; text-overflow: ellipsis;
pointer-events: none; user-select: none; }
/* Recent Activity list */
.vv-activity-list { display: flex; flex-direction: column; }
.vv-activity-row { display: flex; align-items: center; gap: 6px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 11px; cursor: pointer; }
.vv-activity-row:last-child { border-bottom: none; }
.vv-activity-row:hover .vv-activity-label { color: #fff; }
.vv-activity-dot { flex-shrink: 0; font-size: 9px; }
.vv-activity-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: #ccc; }
.vv-activity-ago { color: #555; font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.vv-activity-dur { color: #444; font-size: 10px; white-space: nowrap; flex-shrink: 0;
min-width: 32px; text-align: right; }
/* Log search */
#vv-log-search { width: 120px; font-size: 11px; padding: 2px 6px;
background: #111; border: 1px solid #333; color: #ccc;
border-radius: 3px; font-family: monospace; }
#vv-log-search:focus { border-color: #555; outline: none; }
mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
.vv-log-dim { opacity: 0.2; }
/* Cron Calculator */
.vv-calc-wrap { display: flex; flex-direction: column; gap: 8px; }
.vv-calc-in { width: 100%; background: #111; border: 1px solid #333; color: #ccc;
padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px;
box-sizing: border-box; }
.vv-calc-in:focus { border-color: #555; outline: none; }
.vv-calc-expr { font-family: monospace; font-size: 13px; color: #7cb8e8;
background: #0d1117; padding: 4px 8px; border-radius: 3px; }
.vv-calc-desc { font-size: 12px; color: #aaa; padding: 2px 2px 0; }
.vv-calc-hint { font-size: 11px; color: #555; font-style: italic; }
.vv-calc-runs-lbl { font-size: 10px; color: #555; text-transform: uppercase;
letter-spacing: .05em; margin-top: 4px; }
.vv-calc-runs { display: flex; flex-direction: column; gap: 2px; }
.vv-calc-run-row { display: flex; gap: 8px; font-size: 11px; }
.vv-calc-run-in { color: #4caf50; white-space: nowrap; min-width: 56px; }
.vv-calc-run-at { color: #666; }
.vv-calc-apply-btn { align-self: flex-start; margin-top: 2px; }
/* Board blocks — Next Runs, Errors, Locks, Partner, Disabled */
.vv-board-placeholder { color: #555; font-size: 11px; padding: 3px 0; }
.vv-hdr-badge { display: inline-block; padding: 1px 7px; border-radius: 10px;
font-size: 10px; font-weight: bold; flex-shrink: 0; }
.vv-hdr-badge-red { background: #7f0000; color: #ef9a9a; }
.vv-hdr-badge-orange { background: #5d2000; color: #ffcc80; }
.vv-hdr-badge-gray { background: #2a2a2a; color: #aaa; }
/* Next Runs */
.vv-nextrun-list { display: flex; flex-direction: column; }
.vv-nextrun-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-nextrun-row:last-child { border-bottom: none; }
.vv-nr-label { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vv-nr-cron { color: #555; font-family: monospace; font-size: 10px; flex-shrink: 0; }
.vv-nr-in { color: #4caf50; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
.vv-nr-at { color: #666; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
/* Errors */
.vv-errors-list { display: flex; flex-direction: column; gap: 1px; }
.vv-err-row { padding: 5px 0; border-bottom: 1px solid #1a1a1a; }
.vv-err-row:last-child { border-bottom: none; }
.vv-err-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.vv-err-script { color: #e07070; font-size: 11px; font-weight: bold; }
.vv-err-age { color: #555; font-size: 10px; flex: 1; padding-left: 6px; white-space: nowrap; }
.vv-ack-btn { margin-left: auto; font-size: 10px; padding: 1px 6px;
color: #555; border-color: #333; flex-shrink: 0; }
.vv-ack-btn:hover { color: #aaa; border-color: #555; }
.vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; }
/* Locks */
.vv-locks-list { display: flex; flex-direction: column; }
.vv-lock-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-lock-row:last-child { border-bottom: none; }
.vv-lk-name { color: #e8a87c; flex: 1; }
.vv-lk-age { color: #777; font-size: 11px; white-space: nowrap; }
.vv-lock-clear { font-size: 11px !important; padding: 1px 7px !important;
border-color: #b71c1c !important; color: #ef9a9a !important; }
.vv-lock-clear:hover { background: #7f0000 !important; color: #fff !important; }
/* Partner */
.vv-partner-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.vv-partner-name { color: #ccc; }
.vv-partner-detail { color: #666; font-size: 11px; }
.vv-partner-down { color: #f44336 !important; }
/* Disabled scripts */
.vv-disabled-list { display: flex; flex-direction: column; gap: 1px; }
.vv-disabled-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-disabled-row:last-child { border-bottom: none; }
.vv-disabled-name { color: #888; flex: 1; }
.vv-disabled-grp { color: #444; font-size: 10px; font-family: monospace; white-space: nowrap; }
/* Notification board */
.vv-nb-board { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 7px 10px; background: #141414; border-bottom: 1px solid #222;
font-size: 12px; min-height: 34px; }
.vv-nb-stat { color: #aaa; }
.vv-nb-sep { color: #444; }
.vv-nb-running { color: #f0a040; }
.vv-nb-conf-btns { display: flex; gap: 6px; margin-left: auto; }
.vv-nb-conf-btn { font-size: 11px; font-family: monospace; }
/* Advanced mode button */
.vv-adv-mode-btn { border: 1px solid #555; color: #999; transition: background 0.15s, color 0.15s, border-color 0.15s; }
.vv-adv-mode-btn:hover { border-color: #888; color: #fff; }
.vv-adv-mode-btn.vv-adv-mode-on { background: #1565c0; border-color: #1565c0; color: #fff; }
/* Script browser in suggestions panel */
.vv-sb-desc { font-size: 12px; color: #888; margin: 4px 0 6px; line-height: 1.5; }
.vv-sb-child-desc { margin-left: 16px; }
.vv-sb-hdr { font-family: monospace; font-size: 11px; color: #666; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 4px 0 8px;
padding: 0; line-height: 1.5; border-left: 2px solid #222; padding-left: 8px; }
.vv-sb-full { font-family: monospace; font-size: 11px; color: #888; white-space: pre-wrap;
word-break: break-word; background: #0a0a0a; border: 1px solid #222;
border-radius: 3px; margin: 6px 0 8px; padding: 8px 10px; line-height: 1.5;
max-height: 480px; overflow-y: auto; }
.vv-sb-child-block { border-top: 1px solid #1a1a1a; margin-top: 8px; padding-top: 8px; }
.vv-sb-child-name { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
/* README content display */
.vv-readme-body { font-family: monospace; font-size: 11px; color: #777; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 0 4px; line-height: 1.6; }
/* Script browser tree rows */
#vv-sb-tree { padding: 0; }
.vv-sb-entry { }
.vv-sb-row { display: flex; align-items: center; gap: 6px; padding: 5px 6px;
cursor: pointer; border-radius: 3px; user-select: none;
transition: background 0.1s, box-shadow 0.1s; }
.vv-sb-row:hover { background: rgba(255,255,255,0.04); }
.vv-sb-selected { background: rgba(100,149,237,0.14) !important;
outline: 1px solid rgba(100,149,237,0.4);
box-shadow: inset 3px 0 0 #6495ed; }
.vv-sb-orch-row { border-bottom: 1px solid #1c1c1c; }
.vv-sb-child-row { padding-left: 2px; }
.vv-sb-expand { width: 14px; flex-shrink: 0; color: #555; font-size: 10px; text-align: center; }
.vv-sb-expand:hover { color: #aaa; }
.vv-sb-leaf { cursor: default; pointer-events: none; }
.vv-sb-name { flex: 1; font-size: 13px; color: #b0c4d0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-sb-orch-row .vv-sb-name { font-weight: bold; color: #9ab; }
.vv-sb-children { padding-left: 10px; }
.vv-sb-child-indent { width: 14px; flex-shrink: 0; color: #333; font-size: 11px;
text-align: center; pointer-events: none; }
.vv-sb-badge { flex-shrink: 0; font-size: 11px; }
.vv-sb-cron { color: #555; background: #111; border: 1px solid #222;
padding: 0 4px; border-radius: 2px; font-size: 10px;
font-family: monospace; flex-shrink: 0; }
.vv-sb-status { font-size: 10px; flex-shrink: 0; }
/* Script info content area */
.vv-si-hdr { font-family: monospace; font-size: 12px; color: #4d894d; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.6; }
.vv-si-src { font-family: monospace; font-size: 11px; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.5; }
/* Cog settings button next to script name */
.vv-cog-btn { cursor:pointer; color:#444; font-size:.85em; margin-right:4px;
line-height:1; vertical-align:middle; user-select:none; }
.vv-cog-btn:hover { color:#aaa; }
/* Enriched script info blocks (advanced mode: header + docs + config) */
.vv-sinfo-block { border-top: 1px solid #222; padding: 0; }
.vv-sinfo-block:first-child { border-top: none; }
.vv-sinfo-lbl { font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .06em; color: #555; padding: 6px 12px 2px; }
/* Syntax highlight tokens — dark theme (VSCode-inspired) */
.vv-hl-sep { color: #2d2d2d; }
.vv-hl-shebang { color: #4a4a4a; }
.vv-hl-hash { color: #4a7340; }
.vv-hl-comment { color: #6a9955; }
.vv-hl-section { color: #9cdcfe; font-weight: bold; letter-spacing: 0.04em; }
.vv-hl-key { color: #9cdcfe; }
.vv-hl-value { color: #ce9178; }
.vv-hl-cron { color: #d7ba7d; font-weight: bold; }
.vv-hl-text { color: #4d894d; }
.vv-hl-keyword { color: #569cd6; }
.vv-hl-builtin { color: #4ec9b0; }
.vv-hl-string { color: #ce9178; }
.vv-hl-var { color: #d7ba7d; }
.vv-hl-number { color: #b5cea8; }
.vv-hl-op { color: #808080; }
/* Log panel */
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.vv-log-ts { font-size: 11px; color: #666; }
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
color: #ccc; white-space: pre-wrap; word-break: break-all;
max-height: 340px; overflow-y: auto; line-height: 1.5;
scroll-behavior: auto; }
/* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.vv-toggle input { opacity: 0; width: 0; height: 0; }
.vv-slider { position: absolute; inset: 0; background: #444; border-radius: 20px; cursor: pointer;
transition: 0.2s; }
.vv-slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%; transition: 0.2s; }
.vv-toggle input:checked + .vv-slider { background: #4caf50; }
.vv-toggle input:checked + .vv-slider:before { transform: translateX(16px); }
/* Config editor */
#vv-conf-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.vv-conf-tab { padding: 4px 12px; text-decoration: none; color: #aaa;
border: 1px solid #444; border-radius: 4px; font-size: 13px; }
.vv-conf-tab.active { color: #fff; background: #333; border-color: #666; }
#vv-conf-editor { width: 100%; min-height: 500px; background: #111; color: #ddd;
border: 1px solid #444; padding: 12px; font-family: monospace;
font-size: 13px; line-height: 1.5; border-radius: 4px; box-sizing: border-box; resize: vertical; }
#vv-conf-actions { margin-top: 8px; display: flex; align-items: center; gap: 10px; }
#vv-conf-actions button { padding: 6px 18px; background: #4caf50; border: none;
color: #fff; border-radius: 4px; cursor: pointer; font-size: 14px; }
#vv-conf-actions button:hover { background: #388e3c; }
#vv-conf-status { font-size: 13px; color: #aaa; }
/* Docs */
#vv-docs { display: flex; gap: 16px; }
#vv-docs-sidebar { width: 220px; flex-shrink: 0; }
#vv-docs-sidebar h3 { font-size: 12px; text-transform: uppercase; color: #888; margin: 0 0 8px; }
#vv-docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
#vv-docs-sidebar li { margin: 2px 0; }
#vv-docs-sidebar a { display: block; padding: 3px 8px; font-size: 12px; color: #aaa;
text-decoration: none; border-radius: 3px; }
#vv-docs-sidebar a:hover { background: #222; color: #fff; }
#vv-docs-sidebar a.active { background: #333; color: #fff; }
#vv-docs-content { flex: 1; min-width: 0; }
.vv-doc-body { background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
padding: 20px; line-height: 1.7; }
.vv-doc-body h1, .vv-doc-body h2, .vv-doc-body h3 { color: #ddd; }
.vv-doc-body code { background: #111; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
/* Media stream sessions */
.vv-stream-servers { display: flex; justify-content: space-between; align-items: center;
flex-wrap: nowrap; gap: 8px; margin-bottom: 10px; }
/* Left side: server badges + media type */
.vv-stream-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
/* Right side: device / resolution / codec chip groups */
.vv-stream-right { display: flex; align-items: center; gap: 0; flex-wrap: nowrap; flex-shrink: 0; }
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
/* Chip groups — server badge row */
.vv-device-sep { color: #2a2a2a; font-size: 12px; margin: 0 2px; user-select: none; }
/* shared chip geometry */
.vv-device-chip,
.vv-chip-device, .vv-chip-res, .vv-chip-codec, .vv-chip-mtype {
font-size: 10px; padding: 1px 7px; border-radius: 10px;
white-space: nowrap; border: 1px solid; }
/* media type — purple */
.vv-chip-mtype { color: #9a6abf; background: #1a0d2e; border-color: #3a1e55; }
/* device — blue */
.vv-chip-device { color: #5a9ec8; background: #0d1e2e; border-color: #1c3f5c; }
/* resolution — teal */
.vv-chip-res { color: #4aaa9a; background: #0b1e1c; border-color: #1a4540; }
/* codec — amber */
.vv-chip-codec { color: #b38820; background: #201500; border-color: #483300; }
/* legacy fallback */
.vv-device-chip { color: #666; background: #181818; border-color: #252525; }
/* chip group wrappers — inline so they flow with the badge row */
.vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; }
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
.vv-stream-empty span { font-size: 11px; color: #444; }
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
color: #666; margin-bottom: 5px; }
.vv-stream-user { color: #888; }
.vv-stream-client { color: #555; }
.vv-stream-method { font-weight: 500; }
.vv-stream-time { color: #555; margin-left: auto; }
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
/* Live var substitution colours */
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
/* ── Arrange mode ─────────────────────────────────────────────────────────── */
.vv-drag-handle { cursor: grab; color: #555; font-size: 14px; padding: 0 5px 0 0; user-select: none; flex-shrink: 0; }
.vv-drag-handle:hover { color: #888; }
.vv-drag-handle:active { cursor: grabbing; color: #aaa; }
/* Dragged item: semi-transparent with a dashed ring so you still see what you grabbed */
.vv-drag-ghost { opacity: 0.4; outline: 1px dashed #555; border-radius: 4px; }
/* Drop insertion line: blue glow to match selection accent */
.vv-drop-line { height: 2px; background: #6495ed; border-radius: 2px; margin: 2px 0;
pointer-events: none; box-shadow: 0 0 6px rgba(100,149,237,0.7); }
.vv-arrange-active .vv-children { min-height: 28px; border: 1px dashed #2a2a2a; border-radius: 4px;
padding: 4px 2px; transition: border-color 0.12s, background 0.12s; }
/* Drop targets use blue to match selection theme */
.vv-arrange-active .vv-children.vv-drop-target { border-color: #6495ed; background: rgba(100,149,237,0.07); }
.vv-arrange-btn-active { background: #1a3a1e !important; color: #4caf50 !important; border-color: #2d5c33 !important; }
/* Grabbing cursor while dragging anything */
.vv-is-dragging, .vv-is-dragging * { cursor: grabbing !important; }
.vv-arrange-save-btn { background: #1a3a1e; border-color: #2d5c33; color: #4caf50; }
.vv-arrange-save-btn:hover { background: #22502a; }
#vv-arrange-btn { background: #7b1fa2; border-color: #7b1fa2; }
#vv-arrange-btn:hover { background: #4a148c; border-color: #4a148c; }
/* ── Arrange workspace panel ─────────────────────────────────────────────── */
.vv-arrange-ws-hdr { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase;
letter-spacing: 0.6px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
#vv-pending-badge { background: #ff9800; color: #000; font-size: 10px; padding: 1px 7px;
border-radius: 10px; font-weight: bold; }
.vv-arrange-pending-hdr { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 5px; }
.vv-pending-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
font-size: 11px; border-bottom: 1px solid #1e1e1e; }
.vv-pending-script { color: #ddd; font-weight: 500; }
.vv-pending-arrow { color: #555; font-size: 10px; }
.vv-library-zone { border: 1px dashed #2a2a2a; border-radius: 4px; padding: 6px;
min-height: 60px; transition: border-color 0.12s, background 0.12s; }
.vv-library-zone.vv-drop-target { border-color: #ef5350; background: rgba(239,83,80,0.09);
box-shadow: inset 0 0 8px rgba(239,83,80,0.08); }
.vv-arrange-drop-hint { font-size: 10px; color: #444; text-align: center; padding: 2px 0 7px; }
.vv-lib-card { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 3px;
padding: 4px 8px; margin-bottom: 4px; cursor: grab; display: flex;
align-items: center; justify-content: space-between; gap: 8px; }
.vv-lib-card:hover { border-color: #444; }
.vv-lib-card.vv-drag-ghost { opacity: 0.35; }
.vv-lib-card-name { font-size: 16px; color: #ccc; font-weight: bold; }
.vv-lib-card-path { font-size: 10px; color: #444; font-family: monospace; }
/* ── Custom script folders ────────────────────────────────────────────────── */
.vv-folder-group { margin-bottom: 1px; }
.vv-folder-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; color: #888; font-size: 12px; user-select: none; }
.vv-folder-row:hover { background: #1e1e1e; }
.vv-folder-chevron { font-size: 10px; color: #555; width: 10px; flex-shrink: 0; }
.vv-folder-name { flex: 1; font-weight: 500; color: #aaa; }
.vv-folder-count { font-size: 10px; color: #555; background: #1c1c1c;
padding: 0 5px; border-radius: 8px; border: 1px solid #2a2a2a; }
.vv-folder-children { padding-left: 12px; min-height: 4px; }
.vv-folder-children.vv-drop-target { background: rgba(100,149,237,0.07); border-radius: 4px;
outline: 1px dashed rgba(100,149,237,0.4); }
.vv-folder-new-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; }
.vv-new-folder-btn { background: #0277bd !important; border: none !important; color: #fff !important; }
.vv-new-folder-btn:hover { background: #01579b !important; }
/* ── Snapshot footer (right panel status bar) ────────────────────────────── */
.vv-snap-footer { display: flex !important; flex-direction: row !important;
align-items: center !important; justify-content: center; gap: 20px; flex-wrap: wrap; }
.vv-snap-item { display: flex; align-items: center; gap: 7px; }
.vv-snap-label { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
.vv-snap-bar { width: 90px; height: 8px; background: #222; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
.vv-snap-bar span { display: block; height: 100%; border-radius: 4px; width: 0;
transition: width 0.5s, background-color 0.5s; }
.vv-snap-val { font-size: 15px; color: #aaa; font-family: monospace; min-width: 36px; }
.vv-snap-div { color: #333; font-size: 15px; }
.vv-snap-state { font-size: 15px; font-weight: bold; }
#vv-snap-partner { font-size: 15px; color: #666; }
.vv-snap-media { font-size: 15px; color: #666; }
/* ── Containers and VMs card ──────────────────────────────────────────────── */
.vv-df-section-hdr { font-size: 10px; font-weight: bold; color: #555; text-transform: uppercase;
letter-spacing: 0.08em; padding: 4px 2px 5px; border-bottom: 1px solid #222;
margin-bottom: 4px; }
.vv-df-empty { font-size: 12px; color: #555; font-style: italic; padding: 4px 2px 8px; }
.vv-df-vm-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px;
border-bottom: 1px solid #1a1a1a; }
.vv-df-vm-row:last-of-type { border-bottom: none; }
.vv-df-vm-icon { font-size: 15px; line-height: 1; flex-shrink: 0; }
.vv-df-vm-meta { font-size: 10px; color: #555; }
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
.vv-df-col { flex: 1; min-width: 0; }
#vv-docker-folders-body { overflow-x: hidden; }
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
.vv-df-folder:last-child { border-bottom: none; }
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
cursor: pointer; user-select: none; border-radius: 3px; }
.vv-df-folder-hdr:hover { background: rgba(255,255,255,0.03); }
.vv-df-chevron { color: #555; font-size: 10px; width: 10px; flex-shrink: 0; }
.vv-df-fname { flex: 1; font-size: 12px; color: #aaa; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-folder-body { padding-left: 10px; padding-bottom: 3px; }
.vv-df-container { display: flex; align-items: center; gap: 7px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; user-select: none; }
.vv-df-container:hover { background: rgba(255,255,255,0.04); }
.vv-df-active { background: rgba(100,149,237,0.1) !important;
outline: 1px solid rgba(100,149,237,0.35);
box-shadow: inset 3px 0 0 #6495ed; }
.vv-df-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.vv-df-cname { flex: 1; font-size: 12px; color: #ccc;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-status { font-size: 10px; color: #555; flex-shrink: 0; white-space: nowrap; }
.vv-df-actions { display: flex; gap: 6px; padding: 3px 6px 5px 24px; flex-wrap: wrap; }
/* ── Rsync standalone controls ────────────────────────────────────────────── */
.vv-rsync-location { width: 120px; flex-shrink: 1; min-width: 60px; font-size: 11px; font-family: monospace;
background: #111; border: 1px solid #333; color: #aaa;
padding: 2px 6px; border-radius: 3px; }
.vv-rsync-location:focus { border-color: #555; outline: none; }
.vv-rsync-location::placeholder { color: #444; }
.vv-script-args { flex: 1 1 0; min-width: 60px; max-width: 180px; font-size: 11px; font-family: monospace;
background: #111; border: 1px solid #2a2a2a; color: #aaa;
padding: 2px 6px; border-radius: 3px;
opacity: 0.3; transition: opacity 0.15s; }
.vv-script-args:hover, .vv-script-args:focus { opacity: 1; border-color: #555; outline: none; }
.vv-script-args::placeholder { color: #3a3a3a; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; }
@@ -0,0 +1,946 @@
/* Varaverk plugin styles — inherits unRAID theme, adds plugin-specific layout */
#varaverk-wrap { padding: 10px; font-family: inherit; }
/* Tab bar */
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; align-items: flex-end; }
.vv-tab { padding: 6px 16px; text-decoration: none; color: #aaa; border-radius: 4px 4px 0 0; }
.vv-tab:hover { color: #fff; background: #333; }
.vv-tab.active { color: #fff; background: #555; border-bottom: 2px solid #fff; }
/* Expand toggle button */
#vv-expand-btn {
background: none; border: none; cursor: pointer;
color: #333; font-size: 15px; padding: 2px 8px 4px;
line-height: 1; border-radius: 3px; transition: color .15s;
margin-left: 6px; flex-shrink: 0;
}
#vv-expand-btn:hover { color: #888; }
#vv-expand-btn.active { color: #aaa; }
/* Fullscreen mode — hide Unraid chrome, reclaim the space */
body.vv-fullscreen #header { display: none !important; }
body.vv-fullscreen #menu { display: none !important; }
body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5rem !important; }
/* Cards / layout */
.vv-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.vv-card { flex: 1; min-width: 200px; background: #1e1e1e; border: 1px solid #444;
border-radius: 6px; padding: 12px; }
.vv-wide { flex: 100%; }
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
color: #888; letter-spacing: 0.05em; white-space: normal;
overflow: hidden; min-width: 0;
display: flex; align-items: center; justify-content: space-between; }
/* Cog icon — links to related Unraid page. Hidden until card hovered. */
.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none;
padding: 1px 3px; border-radius: 3px; flex-shrink: 0;
transition: color 0.15s, background 0.15s;
font-style: normal; font-weight: normal; text-transform: none;
letter-spacing: 0; cursor: pointer; }
.vv-card:hover .vv-card-cog { color: #4a4a4a; }
.vv-card-cog:hover { color: #aaa !important; background: #333; }
/* Card header icon wrapper */
.vv-ico { display:inline-flex; align-items:center; opacity:0.38; flex-shrink:0; }
/* Status accent — left border colour for state-driven cards */
.vv-accent-ok { border-left-color: #2a5a2a !important; }
.vv-accent-warn { border-left-color: #5a4000 !important; }
.vv-accent-err { border-left-color: #5a1e1e !important; }
#vv-monitor .vv-card { transition: border-left-color 0.5s; }
/* Status banner — coloured strip at top of card body */
.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 600; }
.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; }
.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; }
.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; }
.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; }
/* System card — no h3, no top padding waste */
#vv-system { padding-top: 14px; }
/* System action buttons */
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
width: 50px; min-width: 0; text-align: center; }
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
/* Fallback state badge */
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
.vv-state-normal { color: #4caf50; }
.vv-state-failover { color: #f44336; }
.vv-state-no_internet { color: #ff9800; }
.vv-state-dark { color: #9e9e9e; }
.vv-state-unknown { color: #666; }
/* Docker table */
#vv-docker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
#vv-docker-table th { text-align: left; padding: 4px 8px; color: #888;
border-bottom: 1px solid #444; }
#vv-docker-table td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; }
.vv-status-up { color: #4caf50; }
.vv-status-down { color: #f44336; }
/* Scheduler */
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
.vv-sched-card { margin-bottom: 10px; }
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
.vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
transition: background 0.1s, box-shadow 0.1s; }
/* Orchestrator top row — clickable, show pointer + hover feedback */
.vv-orch-row { cursor: pointer; border-radius: 4px; }
.vv-orch-row:hover { background: rgba(255,255,255,0.04); }
.vv-job-label { flex: 1; font-size: 16px; font-weight: bold;
color: #6fcf97; min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-job-actions { display: flex; align-items: center; gap: 8px; padding-left: 44px; margin-top: 6px; }
.vv-job-desc { font-size: 14px; color: #777; margin: 3px 0 2px 0;
padding-left: 44px; box-sizing: border-box; width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: default; }
.vv-cron { flex: 0 0 80px; width: 80px; background: #111; border: 1px solid #444; color: #ddd;
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; }
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
white-space: nowrap; font-weight: 500; }
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
white-space: nowrap; font-family: monospace; }
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.vv-log-label input { cursor: pointer; accent-color: #4caf50; }
.vv-log-label:has(input:checked) { color: #4caf50; }
.vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.vv-advanced-toggle:hover { border-color: #888; color: #fff; }
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.vv-btn-sm:hover { border-color: #888; color: #fff; }
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
/* Save checkmark */
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
opacity: 0; text-align: center; }
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
/* Running dot */
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
/* Selected job row — editor-style left accent + blue tint */
.vv-row-selected { background: rgba(100,149,237,0.1); border-radius: 4px;
outline: 1px solid rgba(100,149,237,0.35);
box-shadow: inset 3px 0 0 #6495ed; }
/* Two-panel layout */
#vv-sched-layout { display: flex; gap: 16px; align-items: stretch; }
#vv-sched-left { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-self: flex-start; }
#vv-sched-cards { flex: 1; }
#vv-sched-right { display: none; flex: 1 1 0; min-width: 0; flex-direction: column; overflow: hidden; align-self: flex-start; }
#vv-sched-right.vv-panel-visible { display: flex; }
.vv-log-card { flex: 1; display: flex; flex-direction: column; padding-bottom: 0; }
.vv-log-right-pre { max-height: none; overflow-y: auto; }
/* Scheduler stacked layout (narrow viewport) */
@media (max-width: 900px) {
#vv-sched-layout { flex-direction: column; align-items: stretch; }
#vv-sched-left { flex: none; width: 100%; }
#vv-sched-right { flex-direction: column; width: 100%; overflow: visible; }
.vv-log-right-pre { min-height: 520px; max-height: 680px; }
/* Toolbar: stack title row above buttons row, let buttons wrap */
.vv-log-toolbar { flex-direction: column; align-items: flex-start; gap: 6px; }
.vv-log-toolbar > div { flex-wrap: wrap; gap: 6px !important; }
/* Array-event rows: badge already shows the event, cron input is redundant */
.vv-job-row:has(.vv-event-badge) .vv-cron { display: none; }
}
/* Plugin settings row (Advanced mode) */
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
/* ── Monitor: locked row height + scrollable cards ────────────────────────── */
/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */
#vv-monitor .vv-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
#vv-monitor .vv-card h3 { flex-shrink: 0; }
#vv-monitor .vv-card > div {
flex: 1;
min-height: 0;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; }
/* Hide scrollbars on any nested scrollable div inside monitor cards */
#vv-monitor .vv-card div::-webkit-scrollbar { display: none; }
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
Breakpoints are viewport height (after browser chrome), not screen height. */
/* ~720p (viewport ≤ 700px) */
@media (min-width: 481px) and (max-height: 700px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); }
}
/* ~1080p (viewport 7011100px) */
@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); }
}
/* ~1440p (viewport 11011450px) — calibrated on 15" 1440p display */
@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); }
}
/* ~4K (viewport > 1450px) */
@media (min-width: 481px) and (min-height: 1451px) {
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); }
}
/* Mobile: natural heights, let page scroll */
@media (max-width: 480px) {
#vv-monitor { grid-auto-rows: auto !important; }
#vv-monitor .vv-card { overflow: visible !important; }
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
}
/* Monitor responsive — 4-column grid at medium width */
@media (max-width: 1400px) {
/* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */
.vv-cpu-cores { gap: 2px !important; }
.vv-cpu-core { min-width: 7px !important; }
}
@media (max-width: 1024px) {
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
#vv-docker { grid-column: span 4 !important; }
/* Reset explicit placements so cards reflow in the 4-col grid */
#vv-docker-folders { grid-column: span 4 !important; }
#vv-parity-card { grid-column: auto !important; }
#vv-storage-card { grid-column: auto !important; }
#vv-array-card { grid-column: auto !important; }
/* Streams header: hide right chip group entirely, keep server badges + media type */
.vv-stream-right { display: none; }
}
/* Containers+VMs: single column when viewport is narrow */
@media (max-width: 900px) {
.vv-df-cols { flex-direction: column; align-items: stretch; }
}
/* Phone layout — scheduler row/actions fixes + monitor single-column */
@media (max-width: 480px) {
/* Phone portrait — hint gone, cron stays inline but compact */
.vv-cron-hint { display: none !important; }
.vv-job-row .vv-cron { flex: 0 0 68px; width: 68px; font-size: 10px; margin-left: 30px; }
}
@media (max-width: 600px) {
/* Allow action buttons to wrap rather than overflow the card */
.vv-job-actions { flex-wrap: wrap; padding-left: 0; }
/* Kill the inline margin-left:auto that pushes Advanced off-screen */
.vv-advanced-toggle { margin-left: 0 !important; width: auto !important; }
/* Keep cron from growing — label owns remaining row space */
.vv-cron { flex: 0 0 75px; width: 75px; }
/* Slightly tighter label on narrow screens */
.vv-job-label { font-size: 14px; }
/* Footer buttons wrap instead of overflowing */
.vv-sched-footer { flex-wrap: wrap; }
/* Log toolbar search — narrow on small screens */
#vv-log-search { width: 80px; }
/* Snapshot footer — smaller on mobile */
.vv-snap-footer { gap: 10px !important; }
.vv-snap-item { gap: 4px; }
.vv-snap-label { font-size: 10px; }
.vv-snap-bar { width: 44px; height: 5px; }
.vv-snap-val { font-size: 11px; min-width: 26px; }
.vv-snap-div { font-size: 11px; }
.vv-snap-state { font-size: 11px; }
#vv-snap-partner { font-size: 11px; }
.vv-snap-media { font-size: 11px; }
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
.vv-cpu-cores { gap: 2px !important; }
.vv-cpu-core { min-width: 6px !important; }
/* Monitor single-column — explicit placement cards need override too */
#vv-monitor { grid-template-columns: 1fr !important; }
#vv-monitor > .vv-card { grid-column: 1 / -1 !important; }
#vv-docker-folders { grid-column: 1 / -1 !important; }
}
/* Shared footer (Save Schedule left, info right) — same min-height so log card ends level with script cards */
.vv-sched-footer { display: flex; align-items: center; gap: 10px;
margin-top: 12px; padding: 10px 0; border-top: 1px solid #333;
flex-shrink: 0; min-height: 72px; box-sizing: border-box; }
.vv-sched-info { color: #666; font-size: 14px; display: flex; flex-direction: column; gap: 9px; }
.vv-save-btn { padding: 5px 18px; background: #4caf50; border: none; color: #fff;
border-radius: 4px; cursor: pointer; font-size: 13px; }
.vv-save-btn:hover { background: #388e3c; }
.vv-save-status { font-size: 12px; color: #aaa; }
.vv-run-btn { border-color: #2196f3 !important; color: #2196f3 !important; }
.vv-run-btn:hover { background: #0d47a1 !important; color: #fff !important;
border-color: #2196f3 !important; }
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
border-color: #ff9800 !important; }
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
border-color: #4caf50 !important; }
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
.vv-add-script-btn:hover { background: #0d47a1 !important; }
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
.vv-delete-btn:hover { background: #7f0000 !important; }
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
/* Config form */
#vv-confform { padding: 2px 4px; }
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
letter-spacing: 0; }
.vv-cf-field { margin-bottom: 10px; }
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
.vv-cf-scalar:focus { border-color: #555; outline: none; }
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
line-height: 1.6; resize: vertical; min-height: 60px; }
.vv-cf-array:focus { border-color: #555; outline: none; }
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
#vv-editor { flex-direction: column; gap: 0; }
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box;
white-space: pre; overflow-x: auto; }
/* Editor layout: gutter + inner area — unified bordered block */
#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0;
overflow: hidden; background: #0d0d0d; }
#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0;
background: #0a0a0a; border-right: 1px solid #1c1c1c;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
overflow: hidden; user-select: none; white-space: pre; }
.vv-gln-cur { color: #ccc; font-weight: bold; }
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; }
/* Current line highlight — sits behind overlay */
#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px;
background: rgba(255,255,255,0.04);
border-top: 1px solid rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.03);
pointer-events: none; display: none; }
/* Syntax-highlight overlay — transparent bg so cur-line shows through */
#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
margin: 0; padding: 10px 12px; box-sizing: border-box;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
white-space: pre; overflow: hidden;
pointer-events: none; user-select: none;
background: transparent; border: none; border-radius: 0; }
.vv-editor-hl #vv-hl-overlay { display: block; }
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent;
border: none !important; border-radius: 0 !important; }
/* Visible selection — works even when text is transparent */
.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; }
.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; }
/* Word match and search marks in the overlay */
.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; }
.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; }
.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; }
/* Slim scrollbar for editor */
#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; }
#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; }
#vv-editor-body::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; }
#vv-editor-body::-webkit-scrollbar-thumb:hover { background: #444; }
/* Editor find bar — two-row (find + optional replace) */
#vv-editor-find { display: none; flex-direction: column; gap: 0; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-editor-find.vv-find-open { display: flex; }
.vv-find-row { display: flex; align-items: center; gap: 6px; }
.vv-replace-row { display: none; align-items: center; gap: 6px;
margin-top: 4px; padding-top: 4px; border-top: 1px solid #222; }
.vv-replace-row.vv-repl-open { display: flex; }
.vv-find-expand { font-size: 10px; color: #555; cursor: pointer; flex-shrink: 0;
width: 14px; text-align: center; user-select: none;
transition: transform 0.15s; line-height: 1; }
.vv-find-expand:hover { color: #aaa; }
.vv-find-expand.open { transform: rotate(90deg); color: #888; }
#vv-find-input, #vv-replace-input {
flex: 1; min-width: 0; background: #0d0d0d; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 3px;
font-family: monospace; font-size: 12px; }
#vv-find-input:focus, #vv-replace-input:focus {
border-color: #6495ed; outline: none; box-shadow: 0 0 0 1px rgba(100,149,237,0.25); }
#vv-find-input.vv-find-no-match { border-color: #c62828; box-shadow: 0 0 0 1px rgba(198,40,40,0.3); }
.vv-find-count { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; min-width: 64px; text-align: right; }
.vv-find-nav-btn{ padding: 2px 8px !important; }
.vv-repl-btn { border-color: #444 !important; color: #777 !important; }
.vv-repl-btn:hover { border-color: #777 !important; color: #ddd !important; }
.vv-find-x { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
padding: 0 2px; flex-shrink: 0; }
.vv-find-x:hover { color: #aaa; }
/* Editor status bar */
#vv-editor-status { display: none; align-items: center; gap: 10px; padding: 3px 10px;
background: #0e0e0e; border: 1px solid #2e2e2e; border-top: none;
border-radius: 0 0 4px 4px; font-family: monospace;
font-size: 10px; color: #444; user-select: none; flex-shrink: 0; }
#vv-editor-status.vv-ed-active { display: flex; }
.vv-es-pos { color: #666; }
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* Status bar action buttons */
.vv-es-btn { background: none; border: none; cursor: pointer; color: #555;
font-size: 10px; padding: 1px 4px; border-radius: 2px; font-family: monospace; }
.vv-es-btn:hover { color: #aaa; background: rgba(255,255,255,0.05); }
.vv-es-btn.active { color: #6495ed; }
#vv-es-fontsize { color: #444; font-size: 10px; min-width: 26px; text-align: center; }
/* Word wrap mode — applied to #vv-editor-wrap when wrap is enabled */
#vv-editor-wrap.vv-ed-wrap .vv-editor-body { white-space: pre-wrap !important; overflow-x: hidden !important; }
#vv-editor-wrap.vv-ed-wrap #vv-hl-overlay { white-space: pre-wrap !important; word-break: break-word !important; }
#vv-editor-wrap.vv-ed-wrap #vv-ln-gutter { display: none; }
#vv-editor-wrap.vv-ed-wrap #vv-cur-line { display: none !important; }
/* Indent guides — 1px lines every 2 chars, starting at indent level 1 */
#vv-editor-inner {
position: relative; flex: 1; overflow: hidden;
background-color: #0d0d0d;
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.045) 0 1px,
transparent 1px 2ch
);
background-position: calc(12px + 2ch) 0;
}
/* Go to line bar */
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-goto-bar.vv-goto-open { display: flex; }
#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd;
padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
#vv-goto-input:focus { border-color: #6495ed; outline: none; }
.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; }
/* Undo / Redo buttons */
.vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; }
.vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; }
.vv-undo-redo-btn[disabled] { opacity: 0.28 !important; cursor: default !important; pointer-events: none; }
.vv-undo-count { font-size: 10px; opacity: 0.7; margin-left: 2px; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
.vv-sug-header { display: flex; align-items: center; gap: 8px; padding: 7px 4px;
cursor: pointer; user-select: none; flex-wrap: wrap; }
.vv-sug-header:hover { background: rgba(255,255,255,0.03); }
.vv-sug-chevron { color: #555; font-size: 11px; flex-shrink: 0; width: 10px; }
.vv-sug-title { color: #7a9eb5; font-weight: bold; font-size: 13px; flex: 1; min-width: 120px; }
.vv-sug-cron { color: #ddd; background: #111; border: 1px solid #333; padding: 1px 6px;
border-radius: 3px; font-size: 11px; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
.vv-sug-label { color: #666; font-size: 12px; flex: 1; min-width: 0; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.vv-sug-status { font-size: 11px; flex-shrink: 0; margin-left: auto; }
.vv-sug-on { color: #4caf50; }
.vv-sug-off { color: #777; }
.vv-sug-none { color: #444; }
.vv-sug-body { padding: 0 14px 10px; }
.vv-sug-desc { font-size: 12px; color: #888; white-space: pre-wrap; word-break: break-word;
background: none; border: none; margin: 4px 0 8px; padding: 0;
font-family: inherit; line-height: 1.6; }
.vv-sug-scripts { display: flex; flex-direction: column; gap: 4px; }
.vv-sug-script-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.vv-sug-inline-cron { color: #aaa; background: #111; padding: 1px 5px; border-radius: 3px;
font-size: 11px; white-space: nowrap; }
.vv-sug-path { color: #888; font-family: monospace; }
.vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */
.vv-info-block .vv-sug-title { color: #9ab; }
.vv-info-body { padding: 2px 10px 10px; }
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
.vv-info-cols .vv-info-sep { column-span: all; list-style: none; margin: 10px -4px 6px;
padding: 4px 8px; background: #161a1d; border-left: 2px solid #1e6fa5;
font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .08em; color: #4a8ab5; break-inside: avoid; }
.vv-info-cols li strong { color: #ccc; }
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
font-size: 11px; color: #9ab; border: 1px solid #333; }
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
/* How do I use this — pinned at top of suggestions panel */
#vv-how-to-use {
position: sticky;
top: 0;
z-index: 3;
background: #1e1e1e;
border-bottom: 1px solid #333;
}
/* Run-status dot colours (tree + activity list) */
.vv-stat-ok { color: #4caf50; }
.vv-stat-warn { color: #ff9800; }
.vv-stat-error{ color: #f44336; }
.vv-stat-skip { color: #607d8b; }
.vv-stat-none { color: #444; }
/* Cron humanizer hint — left of cron input; first to collapse under space pressure */
.vv-cron-hint { font-size: 10px; color: #505050; white-space: nowrap;
flex: 0 10 auto; min-width: 0; max-width: 130px;
overflow: hidden; text-overflow: ellipsis;
pointer-events: none; user-select: none; }
/* Recent Activity list */
.vv-activity-list { display: flex; flex-direction: column; }
.vv-activity-row { display: flex; align-items: center; gap: 6px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 11px; cursor: pointer; }
.vv-activity-row:last-child { border-bottom: none; }
.vv-activity-row:hover .vv-activity-label { color: #fff; }
.vv-activity-dot { flex-shrink: 0; font-size: 9px; }
.vv-activity-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: #ccc; }
.vv-activity-ago { color: #555; font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.vv-activity-dur { color: #444; font-size: 10px; white-space: nowrap; flex-shrink: 0;
min-width: 32px; text-align: right; }
/* Log search */
#vv-log-search { width: 120px; font-size: 11px; padding: 2px 6px;
background: #111; border: 1px solid #333; color: #ccc;
border-radius: 3px; font-family: monospace; }
#vv-log-search:focus { border-color: #555; outline: none; }
mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
.vv-log-dim { opacity: 0.2; }
/* Cron Calculator */
.vv-calc-wrap { display: flex; flex-direction: column; gap: 8px; }
.vv-calc-in { width: 100%; background: #111; border: 1px solid #333; color: #ccc;
padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px;
box-sizing: border-box; }
.vv-calc-in:focus { border-color: #555; outline: none; }
.vv-calc-expr { font-family: monospace; font-size: 13px; color: #7cb8e8;
background: #0d1117; padding: 4px 8px; border-radius: 3px; }
.vv-calc-desc { font-size: 12px; color: #aaa; padding: 2px 2px 0; }
.vv-calc-hint { font-size: 11px; color: #555; font-style: italic; }
.vv-calc-runs-lbl { font-size: 10px; color: #555; text-transform: uppercase;
letter-spacing: .05em; margin-top: 4px; }
.vv-calc-runs { display: flex; flex-direction: column; gap: 2px; }
.vv-calc-run-row { display: flex; gap: 8px; font-size: 11px; }
.vv-calc-run-in { color: #4caf50; white-space: nowrap; min-width: 56px; }
.vv-calc-run-at { color: #666; }
.vv-calc-apply-btn { align-self: flex-start; margin-top: 2px; }
/* Board blocks — Next Runs, Errors, Locks, Partner, Disabled */
.vv-board-placeholder { color: #555; font-size: 11px; padding: 3px 0; }
.vv-hdr-badge { display: inline-block; padding: 1px 7px; border-radius: 10px;
font-size: 10px; font-weight: bold; flex-shrink: 0; }
.vv-hdr-badge-red { background: #7f0000; color: #ef9a9a; }
.vv-hdr-badge-orange { background: #5d2000; color: #ffcc80; }
.vv-hdr-badge-gray { background: #2a2a2a; color: #aaa; }
/* Next Runs */
.vv-nextrun-list { display: flex; flex-direction: column; }
.vv-nextrun-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-nextrun-row:last-child { border-bottom: none; }
.vv-nr-label { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vv-nr-cron { color: #555; font-family: monospace; font-size: 10px; flex-shrink: 0; }
.vv-nr-in { color: #4caf50; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
.vv-nr-at { color: #666; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
/* Errors */
.vv-errors-list { display: flex; flex-direction: column; gap: 1px; }
.vv-err-row { padding: 5px 0; border-bottom: 1px solid #1a1a1a; }
.vv-err-row:last-child { border-bottom: none; }
.vv-err-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.vv-err-script { color: #e07070; font-size: 11px; font-weight: bold; }
.vv-err-age { color: #555; font-size: 10px; flex: 1; padding-left: 6px; white-space: nowrap; }
.vv-ack-btn { margin-left: auto; font-size: 10px; padding: 1px 6px;
color: #555; border-color: #333; flex-shrink: 0; }
.vv-ack-btn:hover { color: #aaa; border-color: #555; }
.vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; }
/* Locks */
.vv-locks-list { display: flex; flex-direction: column; }
.vv-lock-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-lock-row:last-child { border-bottom: none; }
.vv-lk-name { color: #e8a87c; flex: 1; }
.vv-lk-age { color: #777; font-size: 11px; white-space: nowrap; }
.vv-lock-clear { font-size: 11px !important; padding: 1px 7px !important;
border-color: #b71c1c !important; color: #ef9a9a !important; }
.vv-lock-clear:hover { background: #7f0000 !important; color: #fff !important; }
/* Partner */
.vv-partner-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.vv-partner-name { color: #ccc; }
.vv-partner-detail { color: #666; font-size: 11px; }
.vv-partner-down { color: #f44336 !important; }
/* Disabled scripts */
.vv-disabled-list { display: flex; flex-direction: column; gap: 1px; }
.vv-disabled-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-disabled-row:last-child { border-bottom: none; }
.vv-disabled-name { color: #888; flex: 1; }
.vv-disabled-grp { color: #444; font-size: 10px; font-family: monospace; white-space: nowrap; }
/* Notification board */
.vv-nb-board { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 7px 10px; background: #141414; border-bottom: 1px solid #222;
font-size: 12px; min-height: 34px; }
.vv-nb-stat { color: #aaa; }
.vv-nb-sep { color: #444; }
.vv-nb-running { color: #f0a040; }
.vv-nb-conf-btns { display: flex; gap: 6px; margin-left: auto; }
.vv-nb-conf-btn { font-size: 11px; font-family: monospace; }
/* Advanced mode button */
.vv-adv-mode-btn { border: 1px solid #555; color: #999; transition: background 0.15s, color 0.15s, border-color 0.15s; }
.vv-adv-mode-btn:hover { border-color: #888; color: #fff; }
.vv-adv-mode-btn.vv-adv-mode-on { background: #1565c0; border-color: #1565c0; color: #fff; }
/* Script browser in suggestions panel */
.vv-sb-desc { font-size: 12px; color: #888; margin: 4px 0 6px; line-height: 1.5; }
.vv-sb-child-desc { margin-left: 16px; }
.vv-sb-hdr { font-family: monospace; font-size: 11px; color: #666; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 4px 0 8px;
padding: 0; line-height: 1.5; border-left: 2px solid #222; padding-left: 8px; }
.vv-sb-full { font-family: monospace; font-size: 11px; color: #888; white-space: pre-wrap;
word-break: break-word; background: #0a0a0a; border: 1px solid #222;
border-radius: 3px; margin: 6px 0 8px; padding: 8px 10px; line-height: 1.5;
max-height: 480px; overflow-y: auto; }
.vv-sb-child-block { border-top: 1px solid #1a1a1a; margin-top: 8px; padding-top: 8px; }
.vv-sb-child-name { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
/* README content display */
.vv-readme-body { font-family: monospace; font-size: 11px; color: #777; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 0 4px; line-height: 1.6; }
/* Script browser tree rows */
#vv-sb-tree { padding: 0; }
.vv-sb-entry { }
.vv-sb-row { display: flex; align-items: center; gap: 6px; padding: 5px 6px;
cursor: pointer; border-radius: 3px; user-select: none;
transition: background 0.1s, box-shadow 0.1s; }
.vv-sb-row:hover { background: rgba(255,255,255,0.04); }
.vv-sb-selected { background: rgba(100,149,237,0.14) !important;
outline: 1px solid rgba(100,149,237,0.4);
box-shadow: inset 3px 0 0 #6495ed; }
.vv-sb-orch-row { border-bottom: 1px solid #1c1c1c; }
.vv-sb-child-row { padding-left: 2px; }
.vv-sb-expand { width: 14px; flex-shrink: 0; color: #555; font-size: 10px; text-align: center; }
.vv-sb-expand:hover { color: #aaa; }
.vv-sb-leaf { cursor: default; pointer-events: none; }
.vv-sb-name { flex: 1; font-size: 13px; color: #b0c4d0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-sb-orch-row .vv-sb-name { font-weight: bold; color: #9ab; }
.vv-sb-children { padding-left: 10px; }
.vv-sb-child-indent { width: 14px; flex-shrink: 0; color: #333; font-size: 11px;
text-align: center; pointer-events: none; }
.vv-sb-badge { flex-shrink: 0; font-size: 11px; }
.vv-sb-cron { color: #555; background: #111; border: 1px solid #222;
padding: 0 4px; border-radius: 2px; font-size: 10px;
font-family: monospace; flex-shrink: 0; }
.vv-sb-status { font-size: 10px; flex-shrink: 0; }
/* Script info content area */
.vv-si-hdr { font-family: monospace; font-size: 12px; color: #4d894d; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.6; }
.vv-si-src { font-family: monospace; font-size: 11px; white-space: pre-wrap;
word-break: break-word; background: none; border: none; margin: 0;
padding: 8px 12px; line-height: 1.5; }
/* Cog settings button next to script name */
.vv-cog-btn { cursor:pointer; color:#444; font-size:.85em; margin-right:4px;
line-height:1; vertical-align:middle; user-select:none; }
.vv-cog-btn:hover { color:#aaa; }
/* Enriched script info blocks (advanced mode: header + docs + config) */
.vv-sinfo-block { border-top: 1px solid #222; padding: 0; }
.vv-sinfo-block:first-child { border-top: none; }
.vv-sinfo-lbl { font-size: 10px; font-weight: bold; text-transform: uppercase;
letter-spacing: .06em; color: #555; padding: 6px 12px 2px; }
/* Syntax highlight tokens — dark theme (VSCode-inspired) */
.vv-hl-sep { color: #2d2d2d; }
.vv-hl-shebang { color: #4a4a4a; }
.vv-hl-hash { color: #4a7340; }
.vv-hl-comment { color: #6a9955; }
.vv-hl-section { color: #9cdcfe; font-weight: bold; letter-spacing: 0.04em; }
.vv-hl-key { color: #9cdcfe; }
.vv-hl-value { color: #ce9178; }
.vv-hl-cron { color: #d7ba7d; font-weight: bold; }
.vv-hl-text { color: #4d894d; }
.vv-hl-keyword { color: #569cd6; }
.vv-hl-builtin { color: #4ec9b0; }
.vv-hl-string { color: #ce9178; }
.vv-hl-var { color: #d7ba7d; }
.vv-hl-number { color: #b5cea8; }
.vv-hl-op { color: #808080; }
/* Log panel */
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.vv-log-ts { font-size: 11px; color: #666; }
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
color: #ccc; white-space: pre-wrap; word-break: break-all;
max-height: 340px; overflow-y: auto; line-height: 1.5;
scroll-behavior: auto; }
/* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.vv-toggle input { opacity: 0; width: 0; height: 0; }
.vv-slider { position: absolute; inset: 0; background: #444; border-radius: 20px; cursor: pointer;
transition: 0.2s; }
.vv-slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%; transition: 0.2s; }
.vv-toggle input:checked + .vv-slider { background: #4caf50; }
.vv-toggle input:checked + .vv-slider:before { transform: translateX(16px); }
/* Config editor */
#vv-conf-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.vv-conf-tab { padding: 4px 12px; text-decoration: none; color: #aaa;
border: 1px solid #444; border-radius: 4px; font-size: 13px; }
.vv-conf-tab.active { color: #fff; background: #333; border-color: #666; }
#vv-conf-editor { width: 100%; min-height: 500px; background: #111; color: #ddd;
border: 1px solid #444; padding: 12px; font-family: monospace;
font-size: 13px; line-height: 1.5; border-radius: 4px; box-sizing: border-box; resize: vertical; }
#vv-conf-actions { margin-top: 8px; display: flex; align-items: center; gap: 10px; }
#vv-conf-actions button { padding: 6px 18px; background: #4caf50; border: none;
color: #fff; border-radius: 4px; cursor: pointer; font-size: 14px; }
#vv-conf-actions button:hover { background: #388e3c; }
#vv-conf-status { font-size: 13px; color: #aaa; }
/* Docs */
#vv-docs { display: flex; gap: 16px; }
#vv-docs-sidebar { width: 220px; flex-shrink: 0; }
#vv-docs-sidebar h3 { font-size: 12px; text-transform: uppercase; color: #888; margin: 0 0 8px; }
#vv-docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
#vv-docs-sidebar li { margin: 2px 0; }
#vv-docs-sidebar a { display: block; padding: 3px 8px; font-size: 12px; color: #aaa;
text-decoration: none; border-radius: 3px; }
#vv-docs-sidebar a:hover { background: #222; color: #fff; }
#vv-docs-sidebar a.active { background: #333; color: #fff; }
#vv-docs-content { flex: 1; min-width: 0; }
.vv-doc-body { background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
padding: 20px; line-height: 1.7; }
.vv-doc-body h1, .vv-doc-body h2, .vv-doc-body h3 { color: #ddd; }
.vv-doc-body code { background: #111; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
/* Media stream sessions */
.vv-stream-servers { display: flex; justify-content: space-between; align-items: center;
flex-wrap: nowrap; gap: 8px; margin-bottom: 10px; }
/* Left side: server badges + media type */
.vv-stream-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
/* Right side: device / resolution / codec chip groups */
.vv-stream-right { display: flex; align-items: center; gap: 0; flex-wrap: nowrap; flex-shrink: 0; }
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
/* Chip groups — server badge row */
.vv-device-sep { color: #2a2a2a; font-size: 12px; margin: 0 2px; user-select: none; }
/* shared chip geometry */
.vv-device-chip,
.vv-chip-device, .vv-chip-res, .vv-chip-codec, .vv-chip-mtype {
font-size: 10px; padding: 1px 7px; border-radius: 10px;
white-space: nowrap; border: 1px solid; }
/* media type — purple */
.vv-chip-mtype { color: #9a6abf; background: #1a0d2e; border-color: #3a1e55; }
/* device — blue */
.vv-chip-device { color: #5a9ec8; background: #0d1e2e; border-color: #1c3f5c; }
/* resolution — teal */
.vv-chip-res { color: #4aaa9a; background: #0b1e1c; border-color: #1a4540; }
/* codec — amber */
.vv-chip-codec { color: #b38820; background: #201500; border-color: #483300; }
/* legacy fallback */
.vv-device-chip { color: #666; background: #181818; border-color: #252525; }
/* chip group wrappers — inline so they flow with the badge row */
.vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; }
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
.vv-stream-empty span { font-size: 11px; color: #444; }
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
color: #666; margin-bottom: 5px; }
.vv-stream-user { color: #888; }
.vv-stream-client { color: #555; }
.vv-stream-method { font-weight: 500; }
.vv-stream-time { color: #555; margin-left: auto; }
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
/* Live var substitution colours */
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
/* ── Arrange mode ─────────────────────────────────────────────────────────── */
.vv-drag-handle { cursor: grab; color: #555; font-size: 14px; padding: 0 5px 0 0; user-select: none; flex-shrink: 0; }
.vv-drag-handle:hover { color: #888; }
.vv-drag-handle:active { cursor: grabbing; color: #aaa; }
/* Dragged item: semi-transparent with a dashed ring so you still see what you grabbed */
.vv-drag-ghost { opacity: 0.4; outline: 1px dashed #555; border-radius: 4px; }
/* Drop insertion line: blue glow to match selection accent */
.vv-drop-line { height: 2px; background: #6495ed; border-radius: 2px; margin: 2px 0;
pointer-events: none; box-shadow: 0 0 6px rgba(100,149,237,0.7); }
.vv-arrange-active .vv-children { min-height: 28px; border: 1px dashed #2a2a2a; border-radius: 4px;
padding: 4px 2px; transition: border-color 0.12s, background 0.12s; }
/* Drop targets use blue to match selection theme */
.vv-arrange-active .vv-children.vv-drop-target { border-color: #6495ed; background: rgba(100,149,237,0.07); }
.vv-arrange-btn-active { background: #1a3a1e !important; color: #4caf50 !important; border-color: #2d5c33 !important; }
/* Grabbing cursor while dragging anything */
.vv-is-dragging, .vv-is-dragging * { cursor: grabbing !important; }
.vv-arrange-save-btn { background: #1a3a1e; border-color: #2d5c33; color: #4caf50; }
.vv-arrange-save-btn:hover { background: #22502a; }
#vv-arrange-btn { background: #7b1fa2; border-color: #7b1fa2; }
#vv-arrange-btn:hover { background: #4a148c; border-color: #4a148c; }
/* ── Arrange workspace panel ─────────────────────────────────────────────── */
.vv-arrange-ws-hdr { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase;
letter-spacing: 0.6px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
#vv-pending-badge { background: #ff9800; color: #000; font-size: 10px; padding: 1px 7px;
border-radius: 10px; font-weight: bold; }
.vv-arrange-pending-hdr { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 5px; }
.vv-pending-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
font-size: 11px; border-bottom: 1px solid #1e1e1e; }
.vv-pending-script { color: #ddd; font-weight: 500; }
.vv-pending-arrow { color: #555; font-size: 10px; }
.vv-library-zone { border: 1px dashed #2a2a2a; border-radius: 4px; padding: 6px;
min-height: 60px; transition: border-color 0.12s, background 0.12s; }
.vv-library-zone.vv-drop-target { border-color: #ef5350; background: rgba(239,83,80,0.09);
box-shadow: inset 0 0 8px rgba(239,83,80,0.08); }
.vv-arrange-drop-hint { font-size: 10px; color: #444; text-align: center; padding: 2px 0 7px; }
.vv-lib-card { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 3px;
padding: 4px 8px; margin-bottom: 4px; cursor: grab; display: flex;
align-items: center; justify-content: space-between; gap: 8px; }
.vv-lib-card:hover { border-color: #444; }
.vv-lib-card.vv-drag-ghost { opacity: 0.35; }
.vv-lib-card-name { font-size: 16px; color: #ccc; font-weight: bold; }
.vv-lib-card-path { font-size: 10px; color: #444; font-family: monospace; }
/* ── Custom script folders ────────────────────────────────────────────────── */
.vv-folder-group { margin-bottom: 1px; }
.vv-folder-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; color: #888; font-size: 12px; user-select: none; }
.vv-folder-row:hover { background: #1e1e1e; }
.vv-folder-chevron { font-size: 10px; color: #555; width: 10px; flex-shrink: 0; }
.vv-folder-name { flex: 1; font-weight: 500; color: #aaa; }
.vv-folder-count { font-size: 10px; color: #555; background: #1c1c1c;
padding: 0 5px; border-radius: 8px; border: 1px solid #2a2a2a; }
.vv-folder-children { padding-left: 12px; min-height: 4px; }
.vv-folder-children.vv-drop-target { background: rgba(100,149,237,0.07); border-radius: 4px;
outline: 1px dashed rgba(100,149,237,0.4); }
.vv-folder-new-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; }
.vv-new-folder-btn { background: #0277bd !important; border: none !important; color: #fff !important; }
.vv-new-folder-btn:hover { background: #01579b !important; }
/* ── Snapshot footer (right panel status bar) ────────────────────────────── */
.vv-snap-footer { display: flex !important; flex-direction: row !important;
align-items: center !important; justify-content: center; gap: 20px; flex-wrap: wrap; }
.vv-snap-item { display: flex; align-items: center; gap: 7px; }
.vv-snap-label { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
.vv-snap-bar { width: 90px; height: 8px; background: #222; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
.vv-snap-bar span { display: block; height: 100%; border-radius: 4px; width: 0;
transition: width 0.5s, background-color 0.5s; }
.vv-snap-val { font-size: 15px; color: #aaa; font-family: monospace; min-width: 36px; }
.vv-snap-div { color: #333; font-size: 15px; }
.vv-snap-state { font-size: 15px; font-weight: bold; }
#vv-snap-partner { font-size: 15px; color: #666; }
.vv-snap-media { font-size: 15px; color: #666; }
/* ── Containers and VMs card ──────────────────────────────────────────────── */
.vv-df-section-hdr { font-size: 10px; font-weight: bold; color: #555; text-transform: uppercase;
letter-spacing: 0.08em; padding: 4px 2px 5px; border-bottom: 1px solid #222;
margin-bottom: 4px; }
.vv-df-empty { font-size: 12px; color: #555; font-style: italic; padding: 4px 2px 8px; }
.vv-df-vm-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px;
border-bottom: 1px solid #1a1a1a; }
.vv-df-vm-row:last-of-type { border-bottom: none; }
.vv-df-vm-icon { font-size: 15px; line-height: 1; flex-shrink: 0; }
.vv-df-vm-meta { font-size: 10px; color: #555; }
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
.vv-df-col { flex: 1; min-width: 0; }
#vv-docker-folders-body { overflow-x: hidden; }
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
.vv-df-folder:last-child { border-bottom: none; }
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
cursor: pointer; user-select: none; border-radius: 3px; }
.vv-df-folder-hdr:hover { background: rgba(255,255,255,0.03); }
.vv-df-chevron { color: #555; font-size: 10px; width: 10px; flex-shrink: 0; }
.vv-df-fname { flex: 1; font-size: 12px; color: #aaa; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-folder-body { padding-left: 10px; padding-bottom: 3px; }
.vv-df-container { display: flex; align-items: center; gap: 7px; padding: 3px 6px;
cursor: pointer; border-radius: 3px; user-select: none; }
.vv-df-container:hover { background: rgba(255,255,255,0.04); }
.vv-df-active { background: rgba(100,149,237,0.1) !important;
outline: 1px solid rgba(100,149,237,0.35);
box-shadow: inset 3px 0 0 #6495ed; }
.vv-df-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.vv-df-cname { flex: 1; font-size: 12px; color: #ccc;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-df-status { font-size: 10px; color: #555; flex-shrink: 0; white-space: nowrap; }
.vv-df-actions { display: flex; gap: 6px; padding: 3px 6px 5px 24px; flex-wrap: wrap; }
/* ── Rsync standalone controls ────────────────────────────────────────────── */
.vv-rsync-location { width: 120px; flex-shrink: 1; min-width: 60px; font-size: 11px; font-family: monospace;
background: #111; border: 1px solid #333; color: #aaa;
padding: 2px 6px; border-radius: 3px; }
.vv-rsync-location:focus { border-color: #555; outline: none; }
.vv-rsync-location::placeholder { color: #444; }
.vv-script-args { flex: 1 1 0; min-width: 60px; max-width: 180px; font-size: 11px; font-family: monospace;
background: #111; border: 1px solid #2a2a2a; color: #aaa;
padding: 2px 6px; border-radius: 3px;
opacity: 0.3; transition: opacity 0.15s; }
.vv-script-args:hover, .vv-script-args:focus { opacity: 1; border-color: #555; outline: none; }
.vv-script-args::placeholder { color: #3a3a3a; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; }
@@ -0,0 +1,86 @@
{
"Orchestrators\/array_started.sh": {
"id": "Orchestrators\/array_started.sh",
"enabled": true,
"cron": "array_start",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/array_stopping.sh": {
"id": "Orchestrators\/array_stopping.sh",
"enabled": true,
"cron": "array_stop",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/critical_sync_maintenance.sh": {
"id": "Orchestrators\/critical_sync_maintenance.sh",
"enabled": true,
"cron": "*\/30 * * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/daily_sync_maintenance.sh": {
"id": "Orchestrators\/daily_sync_maintenance.sh",
"enabled": true,
"cron": "0 1 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/intermediate_sync_maintenance.sh": {
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
"enabled": true,
"cron": "0 *\/4 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/monthly_maintenance.sh": {
"id": "Orchestrators\/monthly_maintenance.sh",
"enabled": true,
"cron": "0 0 15 * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/sunday_morning_coffee_report.sh": {
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
"enabled": true,
"cron": "0 7 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/transcode_management.sh": {
"id": "Orchestrators\/transcode_management.sh",
"enabled": true,
"cron": "*\/7 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:55-04:00"
},
"Orchestrators\/watchdog_orchestrator.sh": {
"id": "Orchestrators\/watchdog_orchestrator.sh",
"enabled": true,
"cron": "*\/15 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:56-04:00"
},
"Orchestrators\/weekly_sync_maintenance.sh": {
"id": "Orchestrators\/weekly_sync_maintenance.sh",
"enabled": true,
"cron": "30 2 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Tools\/api_cache_writer.sh": {
"id": "Tools\/api_cache_writer.sh",
"enabled": true,
"cron": "* * * * *",
"log_enabled": false,
"updated": "2026-06-03T00:00:00+00:00"
},
"Tools\/remote_arr_cache_writer.sh": {
"id": "Tools\/remote_arr_cache_writer.sh",
"enabled": true,
"cron": "0 *\/2 * * *",
"log_enabled": false,
"updated": "2026-06-03T00:00:00+00:00"
}
}
@@ -0,0 +1,86 @@
{
"Orchestrators\/array_started.sh": {
"id": "Orchestrators\/array_started.sh",
"enabled": true,
"cron": "array_start",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/array_stopping.sh": {
"id": "Orchestrators\/array_stopping.sh",
"enabled": true,
"cron": "array_stop",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/critical_sync_maintenance.sh": {
"id": "Orchestrators\/critical_sync_maintenance.sh",
"enabled": true,
"cron": "*\/30 * * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/daily_sync_maintenance.sh": {
"id": "Orchestrators\/daily_sync_maintenance.sh",
"enabled": true,
"cron": "0 1 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/intermediate_sync_maintenance.sh": {
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
"enabled": true,
"cron": "0 *\/4 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/monthly_maintenance.sh": {
"id": "Orchestrators\/monthly_maintenance.sh",
"enabled": true,
"cron": "0 0 15 * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/sunday_morning_coffee_report.sh": {
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
"enabled": true,
"cron": "0 7 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/transcode_management.sh": {
"id": "Orchestrators\/transcode_management.sh",
"enabled": true,
"cron": "*\/7 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:55-04:00"
},
"Orchestrators\/watchdog_orchestrator.sh": {
"id": "Orchestrators\/watchdog_orchestrator.sh",
"enabled": true,
"cron": "*\/15 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:56-04:00"
},
"Orchestrators\/weekly_sync_maintenance.sh": {
"id": "Orchestrators\/weekly_sync_maintenance.sh",
"enabled": true,
"cron": "30 2 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Plugin\/unraid\/tools\/api_cache_writer.sh": {
"id": "Plugin\/unraid\/tools\/api_cache_writer.sh",
"enabled": true,
"cron": "* * * * *",
"log_enabled": false,
"updated": "2026-06-05T00:00:00+00:00"
},
"Plugin\/unraid\/tools\/remote_arr_cache_writer.sh": {
"id": "Plugin\/unraid\/tools\/remote_arr_cache_writer.sh",
"enabled": true,
"cron": "0 *\/2 * * *",
"log_enabled": false,
"updated": "2026-06-05T00:00:00+00:00"
}
}
@@ -0,0 +1,86 @@
{
"Orchestrators\/array_started.sh": {
"id": "Orchestrators\/array_started.sh",
"enabled": true,
"cron": "array_start",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/array_stopping.sh": {
"id": "Orchestrators\/array_stopping.sh",
"enabled": true,
"cron": "array_stop",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/critical_sync_maintenance.sh": {
"id": "Orchestrators\/critical_sync_maintenance.sh",
"enabled": true,
"cron": "*\/30 * * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/daily_sync_maintenance.sh": {
"id": "Orchestrators\/daily_sync_maintenance.sh",
"enabled": true,
"cron": "0 1 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/intermediate_sync_maintenance.sh": {
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
"enabled": true,
"cron": "0 *\/4 * * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/monthly_maintenance.sh": {
"id": "Orchestrators\/monthly_maintenance.sh",
"enabled": true,
"cron": "0 0 15 * *",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/sunday_morning_coffee_report.sh": {
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
"enabled": true,
"cron": "0 7 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Orchestrators\/transcode_management.sh": {
"id": "Orchestrators\/transcode_management.sh",
"enabled": true,
"cron": "*\/7 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:55-04:00"
},
"Orchestrators\/watchdog_orchestrator.sh": {
"id": "Orchestrators\/watchdog_orchestrator.sh",
"enabled": true,
"cron": "*\/15 * * * *",
"log_enabled": false,
"updated": "2026-06-01T20:57:56-04:00"
},
"Orchestrators\/weekly_sync_maintenance.sh": {
"id": "Orchestrators\/weekly_sync_maintenance.sh",
"enabled": true,
"cron": "30 2 * * 0",
"log_enabled": false,
"updated": "2026-06-01T00:00:00+00:00"
},
"Plugin\/unraid\/Tools\/api_cache_writer.sh": {
"id": "Plugin\/unraid\/Tools\/api_cache_writer.sh",
"enabled": true,
"cron": "* * * * *",
"log_enabled": false,
"updated": "2026-06-05T00:00:00+00:00"
},
"Plugin\/unraid\/Tools\/remote_arr_cache_writer.sh": {
"id": "Plugin\/unraid\/Tools\/remote_arr_cache_writer.sh",
"enabled": true,
"cron": "0 *\/2 * * *",
"log_enabled": false,
"updated": "2026-06-05T00:00:00+00:00"
}
}
@@ -0,0 +1,6 @@
# Memory Index
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
- [User Profile](user_profile.md) — user context and preferences
@@ -0,0 +1,7 @@
# Memory Index
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
- [Feedback: Dev vs Prod](feedback_dev_vs_prod.md) — only ever work in /boot/config/plugins/varaverk; dev folder is stale, ignore it
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
- [User Profile](user_profile.md) — user context and preferences
@@ -0,0 +1,917 @@
<style>
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.vv-au-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap; }
.vv-au-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
.vv-au-tab { font-size:11px;padding:3px 12px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#666;cursor:pointer;white-space:nowrap; }
.vv-au-tab:hover{ background:#222;color:#aaa; }
.vv-au-tab.active { background:#0d1f2a;border-color:#1a3a5a;color:#5c9fd4; }
.vv-au-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
.vv-au-btn:hover{ background:#222;color:#bbb; }
.vv-au-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
.vv-au-btn.warn { border-color:#3a2000;background:#1f1200;color:#ff9800; }
.vv-au-btn.danger{border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
.vv-au-btn:disabled { opacity:.4;cursor:default; }
/* ── Panels ──────────────────────────────────────────────────────────────── */
.vv-au-panel { display:none; }
.vv-au-panel.active { display:block; }
/* ── Tables ──────────────────────────────────────────────────────────────── */
.vv-au-tbl { width:100%;border-collapse:collapse;font-size:11px; }
.vv-au-tbl th { text-align:left;padding:5px 10px;font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #1e1e1e;white-space:nowrap; }
.vv-au-tbl td { padding:7px 10px;border-bottom:1px solid #161616;vertical-align:middle; }
.vv-au-tbl tr:hover td { background:#141414; }
.vv-au-tbl tr:last-child td { border-bottom:none; }
/* ── Badges ──────────────────────────────────────────────────────────────── */
.vv-au-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;display:inline-block; }
.vv-au-badge.ssl { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
.vv-au-badge.on { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.off { background:#1a0a0a;border:1px solid #2a1a1a;color:#555; }
.vv-au-badge.bypass { background:#0a1a2a;border:1px solid #1a3a5a;color:#5c9fd4; }
.vv-au-badge.one_factor { background:#1a1a0a;border:1px solid #3a3a1a;color:#cddc39; }
.vv-au-badge.two_factor { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.deny { background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350; }
.vv-au-badge.grp { background:#1a0d2a;border:1px solid #2a1a4a;color:#9c6ff7;margin:1px 2px; }
.vv-au-badge.host { background:#111;border:1px solid #1e1e1e;color:#444; }
/* ── Cards ───────────────────────────────────────────────────────────────── */
.vv-au-card { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
.vv-au-card-h { display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
.vv-au-card-title { font-size:11px;font-weight:bold;color:#666;text-transform:uppercase;letter-spacing:.06em;flex:1; }
/* ── Users / Groups split ────────────────────────────────────────────────── */
.vv-au-ug-grid { display:grid;grid-template-columns:3fr 2fr;gap:12px; }
@media (max-width:700px) { .vv-au-ug-grid { grid-template-columns:1fr; } }
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
.vv-au-user-row:last-child { border-bottom:none; }
.vv-au-user-row:hover { background:#141414; }
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px; }
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
.vv-au-grp-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;cursor:pointer; }
.vv-au-grp-row:last-child { border-bottom:none; }
.vv-au-grp-row:hover { background:#141414; }
.vv-au-grp-name { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-grp-cnt { font-size:10px;color:#444;margin-top:2px; }
.vv-au-grp-acts { float:right;display:flex;gap:4px;margin-top:1px; }
.vv-au-grp-members { padding:6px 12px 10px 20px;background:#0f0f0f;border-bottom:1px solid #1a1a1a; display:none; }
.vv-au-grp-members.open { display:block; }
.vv-au-grp-member { font-size:11px;color:#666;padding:2px 0;display:flex;align-items:center;gap:6px; }
.vv-au-grp-member-rm { font-size:11px;color:#3a1a1a;cursor:pointer;padding:0 2px; }
.vv-au-grp-member-rm:hover { color:#ef5350; }
/* ── Access Control ──────────────────────────────────────────────────────── */
.vv-au-ac-defpol { display:flex;align-items:center;gap:10px;padding:8px 12px;background:#111;border:1px solid #222;border-radius:4px;margin-bottom:10px;font-size:11px;color:#555; }
.vv-au-ac-defpol select { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#888;font-size:11px;padding:2px 6px; }
.vv-au-rule-num { color:#333;font-size:10px;width:24px;text-align:right;flex-shrink:0; }
.vv-au-rule-acts { display:flex;gap:3px;white-space:nowrap; }
.vv-au-icon-btn { font-size:12px;color:#444;cursor:pointer;padding:1px 3px;border-radius:2px;background:none;border:none;line-height:1; }
.vv-au-icon-btn:hover { color:#bbb;background:#222; }
.vv-au-icon-btn.del:hover { color:#ef5350;background:#1a0808; }
/* ── Section toolbar (within panel) ─────────────────────────────────────── */
.vv-au-sec-bar { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-sec-title{ font-size:11px;color:#555;flex:1; }
/* ── Toggle switch ───────────────────────────────────────────────────────── */
.vv-au-tog { width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;position:relative;cursor:pointer;display:inline-block;flex-shrink:0; }
.vv-au-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-au-tog::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#444;transition:left .12s,background .12s; }
.vv-au-tog.on::after { left:14px;background:#4caf50; }
/* ── Modal ───────────────────────────────────────────────────────────────── */
.vv-au-overlay { position:fixed;inset:0;background:#0009;z-index:9000;display:none;align-items:center;justify-content:center; }
.vv-au-overlay.open { display:flex; }
.vv-au-modal { background:#181818;border:1px solid #2a2a2a;border-radius:6px;padding:18px 20px;min-width:360px;max-width:520px;width:90vw;max-height:85vh;overflow-y:auto; }
.vv-au-modal h3 { font-size:12px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px; }
.vv-au-field { margin-bottom:10px; }
.vv-au-label { display:block;font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.04em; }
.vv-au-input { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-input:focus { border-color:#1a3a5a; }
.vv-au-select { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-hint { font-size:10px;color:#333;margin-top:2px; }
.vv-au-modal-acts { display:flex;justify-content:flex-end;gap:8px;margin-top:16px; }
.vv-au-tog-row { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-tog-lbl { font-size:11px;color:#666; }
.vv-au-adv-toggle { font-size:10px;color:#333;cursor:pointer;margin-bottom:8px; }
.vv-au-adv-toggle:hover { color:#666; }
.vv-au-adv-section { display:none; }
.vv-au-adv-section.open { display:block; }
.vv-au-err { font-size:11px;color:#ef5350;margin-top:8px;display:none; }
.vv-au-err.show { display:block; }
/* ── Misc ────────────────────────────────────────────────────────────────── */
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-fwd { font-size:10px;color:#444; }
.vv-au-loading { color:#333;font-size:11px;padding:16px;text-align:center; }
</style>
<?php
require_once __DIR__ . '/../include/auth.php';
$isOwner = vv_is_owner();
?>
<div class="vv-au-toolbar">
<span class="vv-au-title">Auth Stack</span>
<button class="vv-au-tab active" data-tab="proxies">Proxies</button>
<button class="vv-au-tab" data-tab="users">Users &amp; Groups</button>
<button class="vv-au-tab" data-tab="acl">Access Control</button>
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">&#8635; Refresh</button>
</div>
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel active" id="vv-au-panel-proxies">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-proxy-count"></span>
<button class="vv-au-btn prim" id="vv-au-proxy-add">+ Add Proxy</button>
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-proxy-tbl" style="display:none">
<thead><tr>
<th>Domain</th><th>Forward</th><th>SSL</th><th>Status</th><th></th>
</tr></thead>
<tbody id="vv-au-proxy-body"></tbody>
</table>
<div class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
</div>
</div>
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-users">
<div class="vv-au-ug-grid">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Users</span>
<button class="vv-au-btn prim" id="vv-au-user-add">+ Add User</button>
</div>
<div class="vv-au-loading" id="vv-au-users-loading">Loading…</div>
<div id="vv-au-users-list"></div>
<div class="vv-au-empty" id="vv-au-users-empty" style="display:none">No users found.</div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Groups</span>
<button class="vv-au-btn prim" id="vv-au-group-add">+ Add Group</button>
</div>
<div class="vv-au-loading" id="vv-au-groups-loading">Loading…</div>
<div id="vv-au-groups-list"></div>
<div class="vv-au-empty" id="vv-au-groups-empty" style="display:none">No groups found.</div>
</div>
</div>
</div>
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-acl">
<div class="vv-au-ac-defpol" id="vv-au-ac-defpol-bar">
<span>Default policy:</span>
<select id="vv-au-ac-defpol" <?= $isOwner ? '' : 'disabled' ?>>
<option value="deny">deny</option>
<option value="two_factor">two_factor</option>
<option value="one_factor">one_factor</option>
<option value="bypass">bypass</option>
</select>
<?php if (!$isOwner): ?>
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
<?php endif; ?>
<button class="vv-au-btn green" id="vv-au-ac-save" style="margin-left:auto">Save &amp; Restart Authelia</button>
</div>
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
<button class="vv-au-btn prim" id="vv-au-rule-add">+ Add Rule</button>
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-acl-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-acl-tbl" style="display:none">
<thead><tr>
<th style="width:24px">#</th>
<th>Domain</th><th>Policy</th><th>Subject</th><th></th>
</tr></thead>
<tbody id="vv-au-acl-body"></tbody>
</table>
<div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div>
</div>
</div>
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
<div class="vv-au-overlay" id="vv-au-overlay">
<div class="vv-au-modal" id="vv-au-modal"></div>
</div>
<script>
(function () {
'use strict';
const API = '/plugins/varaverk/api/auth.php';
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
// ── State ─────────────────────────────────────────────────────────────────────
let _proxies = [], _certs = [];
let _users = [], _groups = [];
let _rules = [], _defaultPolicy = 'deny';
let _activeTab = 'proxies';
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _get(action, cb) {
fetch(API + '?action=' + action)
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _post(params, cb) {
const fd = new FormData();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fetch(API, { method: 'POST', body: fd })
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _modal(html) {
document.getElementById('vv-au-modal').innerHTML = html;
document.getElementById('vv-au-overlay').classList.add('open');
}
function _closeModal() {
document.getElementById('vv-au-overlay').classList.remove('open');
}
function _togHtml(id, on, title) {
return `<span class="vv-au-tog${on?' on':''}" data-tog="${_esc(id)}" title="${_esc(title)}"></span>`;
}
function _policyBadge(p) {
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
}
function _normSubject(val) {
if (!val) return [];
if (Array.isArray(val)) return val;
return [val];
}
function _normDomain(val) {
if (!val) return [];
if (Array.isArray(val)) return val;
return [val];
}
// ── Tab switching ─────────────────────────────────────────────────────────────
document.querySelectorAll('.vv-au-tab').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
document.querySelectorAll('.vv-au-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vv-au-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('vv-au-panel-' + tab).classList.add('active');
_activeTab = tab;
_loadTab(tab);
});
});
document.getElementById('vv-au-refresh').addEventListener('click', () => _loadTab(_activeTab));
function _loadTab(tab) {
if (tab === 'proxies') _loadProxies();
if (tab === 'users') { _loadUsers(); _loadGroups(); }
if (tab === 'acl') _loadAcl();
}
// ── Proxies ───────────────────────────────────────────────────────────────────
function _loadProxies() {
const loading = document.getElementById('vv-au-proxy-loading');
const tbl = document.getElementById('vv-au-proxy-tbl');
const empty = document.getElementById('vv-au-proxy-empty');
loading.style.display = 'block';
tbl.style.display = 'none';
empty.style.display = 'none';
// Load certs and proxies in parallel
let certsLoaded = false, proxiesLoaded = false;
function _check() {
if (!certsLoaded || !proxiesLoaded) return;
loading.style.display = 'none';
if (!_proxies.length) { empty.style.display = 'block'; return; }
tbl.style.display = 'table';
_renderProxies();
}
_get('npm_certs', r => { _certs = r.certs || []; certsLoaded = true; _check(); });
_get('npm_proxies', r => {
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; return; }
_proxies = r.proxies || [];
proxiesLoaded = true;
_check();
});
}
function _renderProxies() {
const count = document.getElementById('vv-au-proxy-count');
count.textContent = _proxies.length + ' host' + (_proxies.length !== 1 ? 's' : '');
const body = document.getElementById('vv-au-proxy-body');
body.innerHTML = _proxies.map(p => {
const domains = (p.domain_names || []).join(', ');
const fwd = p.forward_scheme + '://' + p.forward_host + ':' + p.forward_port;
const hasSsl = p.certificate_id && p.certificate_id !== '0';
const sslBadge = hasSsl
? `<span class="vv-au-badge ssl">SSL</span>`
: `<span class="vv-au-badge nossl">none</span>`;
const enabled = p.enabled;
return `<tr>
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
<td><div class="vv-au-fwd">${_esc(fwd)}</div></td>
<td>${sslBadge}</td>
<td>${_togHtml('proxy-' + p.id, enabled, enabled ? 'Enabled — click to disable' : 'Disabled — click to enable')}</td>
<td style="text-align:right">
<div class="vv-au-rule-acts">
<button class="vv-au-icon-btn" data-proxy-edit="${p.id}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-proxy-del="${p.id}" title="Delete">✕</button>
</div>
</td>
</tr>`;
}).join('');
}
function _proxyModal(id) {
const p = id ? _proxies.find(x => x.id === id) : null;
const certOptions = _certs.map(c =>
`<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
).join('');
_modal(`<h3>${p ? 'Edit Proxy' : 'Add Proxy'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain Names</label>
<input class="vv-au-input" id="pm-domains" value="${_esc((p?.domain_names||[]).join(', '))}" placeholder="example.com, *.example.com">
<div class="vv-au-hint">Comma-separated</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr 80px;gap:8px">
<div class="vv-au-field">
<label class="vv-au-label">Scheme</label>
<select class="vv-au-select" id="pm-scheme">
<option${(!p||p.forward_scheme==='http')?' selected':''}>http</option>
<option${(p?.forward_scheme==='https')?' selected':''}>https</option>
</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Forward Host</label>
<input class="vv-au-input" id="pm-host" value="${_esc(p?.forward_host||'')}" placeholder="192.168.1.100">
</div>
<div class="vv-au-field">
<label class="vv-au-label">Port</label>
<input class="vv-au-input" id="pm-port" type="number" value="${_esc(p?.forward_port||80)}">
</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">SSL Certificate</label>
<select class="vv-au-select" id="pm-cert">
<option value="0">None</option>
${certOptions}
</select>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.ssl_forced?' on':''}" id="pm-ssl-forced"></span>
<span class="vv-au-tog-lbl">Force SSL</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.block_exploits?' on':''}" id="pm-block-exploits"></span>
<span class="vv-au-tog-lbl">Block Common Exploits</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${(!p||p.allow_websocket_upgrade!==false)?' on':''}" id="pm-websocket"></span>
<span class="vv-au-tog-lbl">WebSocket Support</span>
</div>
<div class="vv-au-err" id="pm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="pm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="pm-save">${p ? 'Save' : 'Create'}</button>
</div>`);
// Wire inline toggles
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
t.addEventListener('click', () => t.classList.toggle('on'));
});
document.getElementById('pm-cancel').onclick = _closeModal;
document.getElementById('pm-save').onclick = () => {
const domains = document.getElementById('pm-domains').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('pm-err', 'Domain required'); return; }
const host = document.getElementById('pm-host').value.trim();
if (!host) { _showModalErr('pm-err', 'Forward host required'); return; }
const data = {
domain_names: domains,
forward_scheme: document.getElementById('pm-scheme').value,
forward_host: host,
forward_port: parseInt(document.getElementById('pm-port').value) || 80,
certificate_id: parseInt(document.getElementById('pm-cert').value) || 0,
ssl_forced: document.getElementById('pm-ssl-forced').classList.contains('on'),
block_exploits: document.getElementById('pm-block-exploits').classList.contains('on'),
allow_websocket_upgrade: document.getElementById('pm-websocket').classList.contains('on'),
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
meta: {},
locations: [],
advanced_config: '',
enabled: true,
};
const btn = document.getElementById('pm-save');
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('pm-err', r.error||'Save failed'); btn.disabled=false; btn.textContent = p?'Save':'Create'; return; }
_closeModal(); _loadProxies();
};
if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
else _post({ action:'npm_create', data: JSON.stringify(data) }, done);
};
}
function _showModalErr(id, msg) {
const el = document.getElementById(id);
if (el) { el.textContent = msg; el.classList.add('show'); }
}
// Proxy event delegation
document.getElementById('vv-au-panel-proxies').addEventListener('click', e => {
// Add button
if (e.target.id === 'vv-au-proxy-add') { _proxyModal(null); return; }
// Toggle
const tog = e.target.closest('[data-tog^="proxy-"]');
if (tog) {
const id = parseInt(tog.dataset.tog.split('-')[1]);
const was = tog.classList.contains('on');
tog.classList.toggle('on');
_post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
if (!r.ok) { tog.classList.toggle('on'); }
});
return;
}
// Edit
const editBtn = e.target.closest('[data-proxy-edit]');
if (editBtn) { _proxyModal(parseInt(editBtn.dataset.proxyEdit)); return; }
// Delete
const delBtn = e.target.closest('[data-proxy-del]');
if (delBtn) {
const id = parseInt(delBtn.dataset.proxyDel);
const p = _proxies.find(x => x.id === id);
if (!confirm('Delete proxy for ' + (p?.domain_names||['this host']).join(', ') + '?')) return;
_post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
}
});
// ── Users ─────────────────────────────────────────────────────────────────────
function _loadUsers() {
const loading = document.getElementById('vv-au-users-loading');
const list = document.getElementById('vv-au-users-list');
const empty = document.getElementById('vv-au-users-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_users', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_users = r.users || [];
if (!_users.length) { empty.style.display = 'block'; return; }
list.innerHTML = _users.map(u => {
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
return `<div class="vv-au-user-row">
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}</span>
<span class="vv-au-user-email">${_esc(u.email||'')}</span>
<div style="display:flex;gap:3px;align-items:center;flex-wrap:wrap">${grpBadges}</div>
<div class="vv-au-user-acts">
<button class="vv-au-icon-btn" data-user-grp="${_esc(u.id)}" title="Add to group">+grp</button>
<button class="vv-au-icon-btn" data-user-pass="${_esc(u.id)}" title="Change password">🔑</button>
<button class="vv-au-icon-btn" data-user-edit="${_esc(u.id)}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-user-del="${_esc(u.id)}" title="Delete">✕</button>
</div>
</div>`;
}).join('');
});
}
function _userModal(uid) {
const u = uid ? _users.find(x => x.id === uid) : null;
_modal(`<h3>${u ? 'Edit User' : 'Add User'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Username (ID)</label>
<input class="vv-au-input" id="um-uid" value="${_esc(u?.id||'')}" ${u?'readonly':''} placeholder="johndoe">
${u ? '' : '<div class="vv-au-hint">Lowercase letters, digits, hyphens — cannot be changed later</div>'}
</div>
<div class="vv-au-field">
<label class="vv-au-label">Display Name</label>
<input class="vv-au-input" id="um-name" value="${_esc(u?.displayName||'')}" placeholder="John Doe">
</div>
<div class="vv-au-field">
<label class="vv-au-label">Email</label>
<input class="vv-au-input" id="um-email" type="email" value="${_esc(u?.email||'')}" placeholder="john@example.com">
</div>
${!u ? `<div class="vv-au-field">
<label class="vv-au-label">Password</label>
<input class="vv-au-input" id="um-pass" type="password" placeholder="Initial password">
</div>` : ''}
<div class="vv-au-err" id="um-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="um-cancel">Cancel</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save' : 'Create'}</button>
</div>`);
document.getElementById('um-cancel').onclick = _closeModal;
document.getElementById('um-save').onclick = () => {
const id = (document.getElementById('um-uid').value||'').trim();
const name = (document.getElementById('um-name').value||'').trim();
const email= (document.getElementById('um-email').value||'').trim();
const pass = document.getElementById('um-pass')?.value || '';
if (!id) { _showModalErr('um-err','Username required'); return; }
if (!email){ _showModalErr('um-err','Email required'); return; }
const btn = document.getElementById('um-save');
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save':'Create'; return; }
_closeModal(); _loadUsers();
};
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name }, done);
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass }, done);
};
}
function _passModal(uid) {
_modal(`<h3>Change Password</h3>
<div class="vv-au-field">
<label class="vv-au-label">User</label>
<input class="vv-au-input" value="${_esc(uid)}" readonly>
</div>
<div class="vv-au-field">
<label class="vv-au-label">New Password</label>
<input class="vv-au-input" id="pw-pass" type="password" placeholder="New password" autofocus>
</div>
<div class="vv-au-err" id="pw-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="pw-cancel">Cancel</button>
<button class="vv-au-btn prim" id="pw-save">Set Password</button>
</div>`);
document.getElementById('pw-cancel').onclick = _closeModal;
document.getElementById('pw-save').onclick = () => {
const pass = document.getElementById('pw-pass').value;
if (!pass) { _showModalErr('pw-err','Password required'); return; }
const btn = document.getElementById('pw-save');
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'lldap_set_password', uid, password:pass }, r => {
if (!r.ok) { _showModalErr('pw-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Set Password'; return; }
_closeModal();
});
};
}
function _addToGroupModal(uid) {
const user = _users.find(u => u.id === uid);
const userGids= new Set((user?.groups||[]).map(g => g.id));
const available = _groups.filter(g => !userGids.has(g.id));
if (!available.length) { alert('User is already in all groups.'); return; }
const opts = available.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('');
_modal(`<h3>Add to Group</h3>
<div class="vv-au-field">
<label class="vv-au-label">User</label>
<input class="vv-au-input" value="${_esc(user?.displayName||uid)}" readonly>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Group</label>
<select class="vv-au-select" id="ag-grp">${opts}</select>
</div>
<div class="vv-au-err" id="ag-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="ag-cancel">Cancel</button>
<button class="vv-au-btn prim" id="ag-save">Add</button>
</div>`);
document.getElementById('ag-cancel').onclick = _closeModal;
document.getElementById('ag-save').onclick = () => {
const gid = parseInt(document.getElementById('ag-grp').value);
const btn = document.getElementById('ag-save');
btn.disabled = true; btn.textContent = 'Adding…';
_post({ action:'lldap_add_to_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('ag-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Add'; return; }
_closeModal(); _loadUsers(); _loadGroups();
});
};
}
// User event delegation
document.getElementById('vv-au-panel-users').addEventListener('click', e => {
if (e.target.id === 'vv-au-user-add') { _userModal(null); return; }
const editBtn = e.target.closest('[data-user-edit]');
if (editBtn) { _userModal(editBtn.dataset.userEdit); return; }
const passBtn = e.target.closest('[data-user-pass]');
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
const grpBtn = e.target.closest('[data-user-grp]');
if (grpBtn) { _addToGroupModal(grpBtn.dataset.userGrp); return; }
const delBtn = e.target.closest('[data-user-del]');
if (delBtn) {
const uid = delBtn.dataset.userDel;
const user = _users.find(u => u.id === uid);
if (!confirm('Delete user "' + (user?.displayName||uid) + '"?')) return;
_post({ action:'lldap_delete_user', uid }, r => { if (r.ok) _loadUsers(); });
return;
}
const rmBadge = e.target.closest('[data-rm-from-group]');
if (rmBadge) {
const uid = rmBadge.dataset.rmFromGroup;
const gid = parseInt(rmBadge.dataset.gid);
const grp = _groups.find(g => g.id === gid);
if (!confirm('Remove from group "' + (grp?.displayName||gid) + '"?')) return;
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
}
});
// ── Groups ────────────────────────────────────────────────────────────────────
function _loadGroups() {
const loading = document.getElementById('vv-au-groups-loading');
const list = document.getElementById('vv-au-groups-list');
const empty = document.getElementById('vv-au-groups-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_groups', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_groups = r.groups || [];
if (!_groups.length) { empty.style.display = 'block'; return; }
list.innerHTML = _groups.map(g => {
const members = (g.users||[]).map(u =>
`<div class="vv-au-grp-member">
<span>${_esc(u.displayName||u.id)}</span>
<span class="vv-au-grp-member-rm" data-rm-user="${_esc(u.id)}" data-gid="${g.id}" title="Remove from group">✕</span>
</div>`
).join('');
return `<div class="vv-au-grp-row" data-grp="${g.id}">
<div class="vv-au-grp-acts">
<button class="vv-au-icon-btn del" data-group-del="${g.id}" title="Delete group">✕</button>
</div>
<div class="vv-au-grp-name">${_esc(g.displayName)}</div>
<div class="vv-au-grp-cnt">${(g.users||[]).length} member${(g.users||[]).length!==1?'s':''}</div>
</div>
<div class="vv-au-grp-members" id="gm-${g.id}">
${members || '<div style="font-size:11px;color:#333;padding:2px 0">No members</div>'}
</div>`;
}).join('');
});
}
// Group event delegation
document.getElementById('vv-au-panel-users').addEventListener('click', e => {
if (e.target.id === 'vv-au-group-add') {
_modal(`<h3>Add Group</h3>
<div class="vv-au-field">
<label class="vv-au-label">Group Name</label>
<input class="vv-au-input" id="gm-name" placeholder="admins" autofocus>
</div>
<div class="vv-au-err" id="gm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="gm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="gm-save">Create</button>
</div>`);
document.getElementById('gm-cancel').onclick = _closeModal;
document.getElementById('gm-save').onclick = () => {
const name = (document.getElementById('gm-name').value||'').trim();
if (!name) { _showModalErr('gm-err','Name required'); return; }
const btn = document.getElementById('gm-save');
btn.disabled = true; btn.textContent = 'Creating…';
_post({ action:'lldap_create_group', name }, r => {
if (!r.ok) { _showModalErr('gm-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Create'; return; }
_closeModal(); _loadGroups();
});
};
return;
}
const grpRow = e.target.closest('[data-grp]');
if (grpRow && !e.target.closest('button') && !e.target.closest('.vv-au-icon-btn')) {
const panel = document.getElementById('gm-' + grpRow.dataset.grp);
if (panel) panel.classList.toggle('open');
return;
}
const delGrp = e.target.closest('[data-group-del]');
if (delGrp) {
e.stopPropagation();
const id = parseInt(delGrp.dataset.groupDel);
const grp = _groups.find(g => g.id === id);
if (!confirm('Delete group "' + (grp?.displayName||id) + '"?')) return;
_post({ action:'lldap_delete_group', id }, r => { if (r.ok) _loadGroups(); });
return;
}
const rmUser = e.target.closest('[data-rm-user]');
if (rmUser) {
e.stopPropagation();
const uid = rmUser.dataset.rmUser;
const gid = parseInt(rmUser.dataset.gid);
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
}
});
// ── Access Control ────────────────────────────────────────────────────────────
function _loadAcl() {
const loading = document.getElementById('vv-au-acl-loading');
const tbl = document.getElementById('vv-au-acl-tbl');
const empty = document.getElementById('vv-au-acl-empty');
loading.style.display = 'block';
tbl.style.display = 'none';
empty.style.display = 'none';
_get('authelia_rules', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_rules = r.rules || [];
_defaultPolicy = r.default_policy || 'deny';
const sel = document.getElementById('vv-au-ac-defpol');
if (sel) sel.value = _defaultPolicy;
if (!_rules.length) { empty.style.display = 'block'; return; }
tbl.style.display = 'table';
_renderAcl();
});
}
function _renderAcl() {
const count = document.getElementById('vv-au-acl-count');
count.textContent = _rules.length + ' rule' + (_rules.length !== 1 ? 's' : '');
const body = document.getElementById('vv-au-acl-body');
body.innerHTML = _rules.map((rule, i) => {
const domains = _normDomain(rule.domain).join(', ');
const subjects = _normSubject(rule.subject).join(', ');
const upBtn = i === 0 ? '<span style="width:18px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-up="${i}" title="Move up">↑</button>`;
const dnBtn = i === _rules.length-1 ? '<span style="width:18px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
return `<tr>
<td class="vv-au-rule-num">${i+1}</td>
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
<td>${_policyBadge(rule.policy)}</td>
<td style="font-size:10px;color:#555">${_esc(subjects||'—')}</td>
<td>
<div class="vv-au-rule-acts">
${upBtn}${dnBtn}
<button class="vv-au-icon-btn" data-rule-edit="${i}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-rule-del="${i}" title="Delete">✕</button>
</div>
</td>
</tr>`;
}).join('');
}
function _ruleModal(idx) {
const rule = idx !== null ? _rules[idx] : null;
const domains = _normDomain(rule?.domain).join(', ');
const subjects = _normSubject(rule?.subject).join(', ');
const nets = (rule?.networks||[]).join(', ');
const resources= (rule?.resources||[]).join(', ');
const policyOpts = ['bypass','one_factor','two_factor','deny'].map(p =>
`<option value="${p}"${(rule?.policy||'two_factor')===p?' selected':''}>${p}</option>`
).join('');
_modal(`<h3>${rule ? 'Edit Rule' : 'Add Rule'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain</label>
<input class="vv-au-input" id="rm-domain" value="${_esc(domains)}" placeholder="*.example.com, example.com">
<div class="vv-au-hint">Comma-separated; wildcards supported</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Policy</label>
<select class="vv-au-select" id="rm-policy">${policyOpts}</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Subject <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-subject" value="${_esc(subjects)}" placeholder="group:admins, user:john">
<div class="vv-au-hint">Comma-separated; prefix with group: or user:</div>
</div>
<div class="vv-au-adv-toggle" id="rm-adv-toggle">▶ Advanced (networks, resources)</div>
<div class="vv-au-adv-section" id="rm-adv">
<div class="vv-au-field">
<label class="vv-au-label">Networks <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-networks" value="${_esc(nets)}" placeholder="192.168.1.0/24, 10.0.0.0/8">
<div class="vv-au-hint">Comma-separated CIDR ranges</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Resources <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-resources" value="${_esc(resources)}" placeholder="^/api, ^/admin">
<div class="vv-au-hint">Comma-separated regex patterns</div>
</div>
</div>
<div class="vv-au-err" id="rm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="rm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="rm-save">${rule ? 'Save' : 'Add Rule'}</button>
</div>`);
document.getElementById('rm-adv-toggle').addEventListener('click', () => {
const sec = document.getElementById('rm-adv');
const tog = document.getElementById('rm-adv-toggle');
const open = sec.classList.toggle('open');
tog.textContent = (open ? '▼' : '▶') + ' Advanced (networks, resources)';
});
document.getElementById('rm-cancel').onclick = _closeModal;
document.getElementById('rm-save').onclick = () => {
const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('rm-err','Domain required'); return; }
const subjects = document.getElementById('rm-subject').value.split(',').map(s=>s.trim()).filter(Boolean);
const networks = document.getElementById('rm-networks').value.split(',').map(s=>s.trim()).filter(Boolean);
const resources = document.getElementById('rm-resources').value.split(',').map(s=>s.trim()).filter(Boolean);
const newRule = {
domain: domains.length === 1 ? domains[0] : domains,
policy: document.getElementById('rm-policy').value,
};
if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects;
if (networks.length) newRule.networks = networks;
if (resources.length) newRule.resources = resources;
if (idx !== null) _rules[idx] = newRule;
else _rules.push(newRule);
_closeModal();
_renderAcl();
};
}
// ACL event delegation
document.getElementById('vv-au-panel-acl').addEventListener('click', e => {
if (e.target.id === 'vv-au-rule-add') { _ruleModal(null); return; }
if (e.target.id === 'vv-au-ac-save') {
const dp = document.getElementById('vv-au-ac-defpol').value;
const btn = document.getElementById('vv-au-ac-save');
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'authelia_save', rules: JSON.stringify(_rules), default_policy: dp }, r => {
btn.disabled = false; btn.textContent = 'Save & Restart Authelia';
if (!r.ok) { alert('Save failed: ' + (r.error||'unknown error')); return; }
// brief visual confirmation
btn.textContent = 'Saved ✓';
setTimeout(() => { btn.textContent = 'Save & Restart Authelia'; }, 2000);
});
return;
}
const editBtn = e.target.closest('[data-rule-edit]');
if (editBtn) { _ruleModal(parseInt(editBtn.dataset.ruleEdit)); return; }
const delBtn = e.target.closest('[data-rule-del]');
if (delBtn) {
const i = parseInt(delBtn.dataset.ruleDel);
if (!confirm('Delete this rule?')) return;
_rules.splice(i, 1);
_renderAcl();
return;
}
const upBtn = e.target.closest('[data-rule-up]');
if (upBtn) {
const i = parseInt(upBtn.dataset.ruleUp);
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _renderAcl(); }
return;
}
const dnBtn = e.target.closest('[data-rule-dn]');
if (dnBtn) {
const i = parseInt(dnBtn.dataset.ruleDn);
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _renderAcl(); }
}
});
// ── Close modal on overlay click ──────────────────────────────────────────────
document.getElementById('vv-au-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
});
// ── Boot ──────────────────────────────────────────────────────────────────────
_loadProxies();
})();
</script>
@@ -0,0 +1,119 @@
<?php
// Diagnostic endpoint — tests the Unraid GraphQL API and returns raw results.
// Hit from browser: /plugins/varaverk/api/api_test.php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$hostId = vv_detect_host();
$vars = vv_conf_vars();
$key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '';
$result = [
'host' => $hostId,
'key_present' => $key !== '',
'key_prefix' => $key ? substr($key, 0, 8) . '...' : null,
'curl_available' => function_exists('curl_init'),
'allow_url_fopen'=> (bool)ini_get('allow_url_fopen'),
'debug_log' => null,
'probe' => null,
'probe_raw' => null,
];
// Show last debug log if present
$debugFile = '/tmp/vv_api_debug.json';
if (file_exists($debugFile)) {
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
}
// Run a minimal probe query
if ($key) {
$url = 'http://localhost/graphql';
$body = json_encode(['query' => '{ info { os { hostname } } }']);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => 5,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
$httpCode = $raw !== false ? 200 : 0;
$curlErr = '';
}
$result['probe'] = [
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr ?: null,
'decoded' => json_decode((string)$raw, true),
];
$result['probe_raw'] = substr((string)$raw, 0, 1000);
// ── Schema introspection — discover actual field names ────────────────────────
if ($key) {
// ArrayParity and ArrayCache were separate named types in 7.2.5 — removed in 7.3 (now same as ArrayDisk)
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','VmDomain'];
$introspectGql = '{ ' . implode(' ', array_map(fn($t) =>
"{$t}: __type(name: \"{$t}\") { fields { name type { name kind ofType { name kind } } } }",
$types
)) . ' }';
$body2 = json_encode(['query' => $introspectGql]);
$ch2 = curl_init('http://localhost/graphql');
curl_setopt_array($ch2, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body2,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
]);
$raw2 = curl_exec($ch2);
curl_close($ch2);
$result['schema'] = json_decode((string)$raw2, true)['data'] ?? null;
// Pool drive names — what does the API actually return for cache/pool drives?
$ch_pools = curl_init('http://localhost/graphql');
curl_setopt_array($ch_pools, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => json_encode(['query' => '{ array { caches { name device type status fsType } } }']),
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
]);
$result['pool_drives'] = json_decode((string)curl_exec($ch_pools), true)['data'] ?? null;
curl_close($ch_pools);
// Round 2: introspect CpuUtilization and MemoryUtilization field names
$types2 = ['CpuUtilization','MemoryUtilization','TemperatureMetrics'];
$gql2 = '{ ' . implode(' ', array_map(fn($t) =>
"{$t}: __type(name: \"{$t}\") { kind fields { name type { name kind ofType { name kind } } } }",
$types2
)) . ' }';
$ch3 = curl_init('http://localhost/graphql');
curl_setopt_array($ch3, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => json_encode(['query' => $gql2]),
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
]);
$result['schema2'] = json_decode((string)curl_exec($ch3), true)['data'] ?? null;
curl_close($ch3);
}
}
echo json_encode($result, JSON_PRETTY_PRINT);
@@ -0,0 +1,119 @@
<?php
// Diagnostic endpoint — tests the Unraid GraphQL API and returns raw results.
// Hit from browser: /plugins/varaverk/api/api_test.php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$hostId = vv_detect_host();
$vars = vv_conf_vars();
$key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '';
$result = [
'host' => $hostId,
'key_present' => $key !== '',
'key_prefix' => $key ? substr($key, 0, 8) . '...' : null,
'curl_available' => function_exists('curl_init'),
'allow_url_fopen'=> (bool)ini_get('allow_url_fopen'),
'debug_log' => null,
'probe' => null,
'probe_raw' => null,
];
// Show last debug log if present
$debugFile = VV_CACHE_DIR . '/vv_api_debug.json';
if (file_exists($debugFile)) {
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
}
// Run a minimal probe query
if ($key) {
$url = 'http://localhost/graphql';
$body = json_encode(['query' => '{ info { os { hostname } } }']);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => 5,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
$httpCode = $raw !== false ? 200 : 0;
$curlErr = '';
}
$result['probe'] = [
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr ?: null,
'decoded' => json_decode((string)$raw, true),
];
$result['probe_raw'] = substr((string)$raw, 0, 1000);
// ── Schema introspection — discover actual field names ────────────────────────
if ($key) {
// ArrayParity and ArrayCache were separate named types in 7.2.5 — removed in 7.3 (now same as ArrayDisk)
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','VmDomain'];
$introspectGql = '{ ' . implode(' ', array_map(fn($t) =>
"{$t}: __type(name: \"{$t}\") { fields { name type { name kind ofType { name kind } } } }",
$types
)) . ' }';
$body2 = json_encode(['query' => $introspectGql]);
$ch2 = curl_init('http://localhost/graphql');
curl_setopt_array($ch2, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body2,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
]);
$raw2 = curl_exec($ch2);
curl_close($ch2);
$result['schema'] = json_decode((string)$raw2, true)['data'] ?? null;
// Pool drive names — what does the API actually return for cache/pool drives?
$ch_pools = curl_init('http://localhost/graphql');
curl_setopt_array($ch_pools, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => json_encode(['query' => '{ array { caches { name device type status fsType } } }']),
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
]);
$result['pool_drives'] = json_decode((string)curl_exec($ch_pools), true)['data'] ?? null;
curl_close($ch_pools);
// Round 2: introspect CpuUtilization and MemoryUtilization field names
$types2 = ['CpuUtilization','MemoryUtilization','TemperatureMetrics'];
$gql2 = '{ ' . implode(' ', array_map(fn($t) =>
"{$t}: __type(name: \"{$t}\") { kind fields { name type { name kind ofType { name kind } } } }",
$types2
)) . ' }';
$ch3 = curl_init('http://localhost/graphql');
curl_setopt_array($ch3, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => json_encode(['query' => $gql2]),
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
]);
$result['schema2'] = json_decode((string)curl_exec($ch3), true)['data'] ?? null;
curl_close($ch3);
}
}
echo json_encode($result, JSON_PRETTY_PRINT);
@@ -0,0 +1,644 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
# ==============================================================================================
# HOST2-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST1 never receives this file.
# HOST1 never sees HOST2 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST1 variables here — they belong in host1.conf.
#
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST2
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
# RSYNC WRITEBACK HOST2 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
HOST2_OWNER="jayred365"
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
# ━━━ Unraid API ━━━
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST2_EMBY_CONTAINER="Emby-Jayred365"
HOST2_EMBY_URL="http://localhost:8096"
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST2_JELLYFIN_CONTAINER="Jellyfin"
HOST2_JELLYFIN_URL="http://localhost:8095"
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST2_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
# fill in when HOST2 is back online
# "NginxProxyManager|81"
)
# Containers to stop on this server before the owner deploys the auth stack during onboard.
# List whatever auth/proxy containers are currently running here.
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
"NginxProxyManager"
"Authelia"
"Authelia-Secondary"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Lldap"
)
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
# "Sonarr"
# "Radarr"
# "Lidarr"
# "Prowlarr"
# "Bazarr"
)
# Paths HOST1 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
# fill in when HOST2 is back online
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# This server's desired Emby admin account on the shared Emby instance.
# Set these — owner reads them during --onboard to create the account.
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST2_DAILY_SYNC_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST2_PERSONAL_SHARES=(
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
HOST2_WEEKLY_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/Media_Server/Emby"
# "/mnt/user/appdata-Fallback/Critical-Data"
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST2_INTERMEDIATE_SYNC_SHARES=(
# fill in when HOST2 is back online
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
HOST2_CRITICAL_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
# "/mnt/user/Media_Server/Emby|emby-fallback"
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST2_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST2.
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
PROFILE_BW_LIMIT[host2-appdata]=8000
PROFILE_RETRY_COUNT[host2-appdata]=3
PROFILE_SLEEP[host2-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
PROFILE_CONTAINER_DELAY[host2-appdata]=5
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
HOST2_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
# add HOST2 daily restart containers here
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST2_WEEKLY_RESTART_CONTAINERS=(
# add HOST2 weekly restart containers here
)
# ━━━ Docker Watchdog ━━━
# Per-HOST2 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST2_WATCHDOG_CONTAINERS=(
["Emby"]=16384 # fill in correct limit when HOST2 is back online
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST2.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
# add HOST2 required containers here when back online
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST2_WATCHDOG_SCAN_IGNORE=(
# add HOST2 scan ignore containers here when back online
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting dependent services before their dependencies are up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
# add HOST2 dependencies here when containers are defined
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use when a container legitimately has large stable data and you want to suppress false-positive
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
# add HOST2 suppress entries here only as needed
)
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST2_NETWORK_CONNECT_CONTAINERS=(
# fill in when HOST2 is back online
)
HOST2_NETWORK_CONNECT_NETWORKS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
HOST2_DDNS_CONTAINERS=(
"Gmer4Lfe.us"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST2 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST2_STOP_ON_NO_NET=(
"Gmer4Lfe.us"
)
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
# Containers HOST2 starts when HOST1 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
"Gmer4Lfe.com"
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
"Emby"
"VaultWarden-Gmer4Lfe"
"Dispatcharr"
"Dispatcharr-Basic"
"Dispatcharr-Iptv-Users"
"ErsatzTV-Emby"
)
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
"Postgres-NextCloud"
"NextCloud"
"PostgreSQL_Immich"
"Immich-Gmer4Lfe"
)
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
"Gitea"
)
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
"Sonarr"
"Radarr"
"Lidarr"
"Readarr"
"Prowlarr"
"Bazarr"
"SABnzbd-Gmer4Lfe"
"Qbittorrent-Gmer4Lfe"
"LidaTube"
"Pinchflat"
"ChannelTube"
)
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST2_WRITEBACK_TIER1=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
FALLBACK_HOST2_WRITEBACK_TIER2=(
# "/mnt/user/appdata-Fallback/Jayred365-Important"
)
FALLBACK_HOST2_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST2_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST2_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST2_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
HOST2_MEDIA_CLEAN_FOLDERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST2_CERT_MONITOR_DOMAINS=(
# fill in when HOST2 is back online
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST2_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# Pool health thresholds defined in master.conf.
HOST2_ZFS_REPORT_IGNORE_POOLS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
HOST2_RAMDISK_SIZE="8G"
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST2.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST2_TRANSCODE_SERVERS=(
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST2 vars when running on HOST2.
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
# ━━━ Sonarr ━━━
HOST2_SONARR_URL="http://localhost:8989"
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
declare -A HOST2_SONARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/tv"]="/mnt/user/Anime_Shows"
)
# ━━━ Radarr ━━━
HOST2_RADARR_URL="http://localhost:7878"
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
declare -A HOST2_RADARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/anime-movies"]="/mnt/user/Anime_Movies"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST2_SONARR_RECOVERY=true
HOST2_RADARR_RECOVERY=true
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
#
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
# Three-tier response — all critical checks enabled regardless of rebuild state:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): selectively disabled during rebuild
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST2_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# All critical checks always enabled — these protect against acute failure regardless of
# rebuild state. Disabling any is not recommended.
# Docker daemon unresponsive → try restart, reboot if restart fails.
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Both must be enabled for Tier 2 bypass to function.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
HOST2_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
HOST2_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
HOST2_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory check.
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
HOST2_SYS_WATCHDOG_CHECK_ARC=false
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
# docker_watchdog.sh persistent skip list check.
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
# /tmp filesystem usage with auto-clear attempt.
HOST2_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat.
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — restart attempt before escalating.
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection.
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST2_RW_PAUSE_CONTAINERS=(
# fill in when HOST2 is back online
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST2_RW_STOP_CONTAINERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,644 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
# ==============================================================================================
# HOST2-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST1 never receives this file.
# HOST1 never sees HOST2 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST1 variables here — they belong in host1.conf.
#
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST2
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
# RSYNC WRITEBACK HOST2 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
HOST2_OWNER="jayred365"
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
# ━━━ Unraid API ━━━
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST2_EMBY_CONTAINER="Emby-Jayred365"
HOST2_EMBY_URL="http://localhost:8096"
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST2_JELLYFIN_CONTAINER="Jellyfin"
HOST2_JELLYFIN_URL="http://localhost:8095"
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST2_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
# fill in when HOST2 is back online
# "NginxProxyManager|81"
)
# Containers to stop on this server before the owner deploys the auth stack during onboard.
# List whatever auth/proxy containers are currently running here.
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
"NginxProxyManager"
"Authelia"
"Authelia-Secondary"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Lldap-Gmer4Lfe"
)
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
# "Sonarr"
# "Radarr"
# "Lidarr"
# "Prowlarr"
# "Bazarr"
)
# Paths HOST1 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
# fill in when HOST2 is back online
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# This server's desired Emby admin account on the shared Emby instance.
# Set these — owner reads them during --onboard to create the account.
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST2_DAILY_SYNC_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST2_PERSONAL_SHARES=(
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
HOST2_WEEKLY_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/Media_Server/Emby"
# "/mnt/user/appdata-Fallback/Critical-Data"
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST2_INTERMEDIATE_SYNC_SHARES=(
# fill in when HOST2 is back online
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
HOST2_CRITICAL_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
# "/mnt/user/Media_Server/Emby|emby-fallback"
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST2_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST2.
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
PROFILE_BW_LIMIT[host2-appdata]=8000
PROFILE_RETRY_COUNT[host2-appdata]=3
PROFILE_SLEEP[host2-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
PROFILE_CONTAINER_DELAY[host2-appdata]=5
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
HOST2_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
# add HOST2 daily restart containers here
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST2_WEEKLY_RESTART_CONTAINERS=(
# add HOST2 weekly restart containers here
)
# ━━━ Docker Watchdog ━━━
# Per-HOST2 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST2_WATCHDOG_CONTAINERS=(
["Emby"]=16384 # fill in correct limit when HOST2 is back online
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST2.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
# add HOST2 required containers here when back online
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST2_WATCHDOG_SCAN_IGNORE=(
# add HOST2 scan ignore containers here when back online
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting dependent services before their dependencies are up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
# add HOST2 dependencies here when containers are defined
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use when a container legitimately has large stable data and you want to suppress false-positive
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
# add HOST2 suppress entries here only as needed
)
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST2_NETWORK_CONNECT_CONTAINERS=(
# fill in when HOST2 is back online
)
HOST2_NETWORK_CONNECT_NETWORKS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
HOST2_DDNS_CONTAINERS=(
"Gmer4Lfe.us"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST2 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST2_STOP_ON_NO_NET=(
"Gmer4Lfe.us"
)
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
# Containers HOST2 starts when HOST1 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
"Gmer4Lfe.com"
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
"Emby"
"VaultWarden-Gmer4Lfe"
"Dispatcharr"
"Dispatcharr-Basic"
"Dispatcharr-Iptv-Users"
"ErsatzTV-Emby"
)
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
"Postgres-NextCloud"
"NextCloud"
"PostgreSQL_Immich"
"Immich-Gmer4Lfe"
)
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
"Gitea"
)
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
"Sonarr"
"Radarr"
"Lidarr"
"Readarr"
"Prowlarr"
"Bazarr"
"SABnzbd-Gmer4Lfe"
"Qbittorrent-Gmer4Lfe"
"LidaTube"
"Pinchflat"
"ChannelTube"
)
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST2_WRITEBACK_TIER1=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
FALLBACK_HOST2_WRITEBACK_TIER2=(
# "/mnt/user/appdata-Fallback/Jayred365-Important"
)
FALLBACK_HOST2_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST2_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST2_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST2_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
HOST2_MEDIA_CLEAN_FOLDERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST2_CERT_MONITOR_DOMAINS=(
# fill in when HOST2 is back online
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST2_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# Pool health thresholds defined in master.conf.
HOST2_ZFS_REPORT_IGNORE_POOLS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
HOST2_RAMDISK_SIZE="8G"
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST2.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST2_TRANSCODE_SERVERS=(
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST2 vars when running on HOST2.
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
# ━━━ Sonarr ━━━
HOST2_SONARR_URL="http://localhost:8989"
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
declare -A HOST2_SONARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/tv"]="/mnt/user/Anime_Shows"
)
# ━━━ Radarr ━━━
HOST2_RADARR_URL="http://localhost:7878"
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
declare -A HOST2_RADARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/anime-movies"]="/mnt/user/Anime_Movies"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST2_SONARR_RECOVERY=true
HOST2_RADARR_RECOVERY=true
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
#
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
# Three-tier response — all critical checks enabled regardless of rebuild state:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): selectively disabled during rebuild
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST2_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# All critical checks always enabled — these protect against acute failure regardless of
# rebuild state. Disabling any is not recommended.
# Docker daemon unresponsive → try restart, reboot if restart fails.
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Both must be enabled for Tier 2 bypass to function.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
HOST2_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
HOST2_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
HOST2_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory check.
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
HOST2_SYS_WATCHDOG_CHECK_ARC=false
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
# docker_watchdog.sh persistent skip list check.
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
# /tmp filesystem usage with auto-clear attempt.
HOST2_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat.
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — restart attempt before escalating.
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection.
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST2_RW_PAUSE_CONTAINERS=(
# fill in when HOST2 is back online
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST2_RW_STOP_CONTAINERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,664 @@
#!/bin/bash
# ==============================================================================================
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
# ==============================================================================================
# HOST2-specific variables — credentials, container names, share paths, failover lists.
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
# identity, credentials, and container configuration.
#
# Sparse checkout (git) ensures HOST1 never receives this file.
# HOST1 never sees HOST2 credentials — clean separation at the file level.
#
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
# DO NOT put HOST1 variables here — they belong in host1.conf.
#
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
#
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
#
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
# IDENTITY hostname, SSH key
# EMBY container name, URL, API key
# NOTIFICATIONS Discord webhook
# PARTNERSHIP auth containers, backup paths
#
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
# BACKUP VERIFY shares for checksum verification against remote
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
#
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
# DOCKER DAILY RESTART containers restarted daily
# DOCKER WEEKLY RESTART containers restarted weekly
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
#
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
# DDNS DDNS containers managed by HOST2
# INTERNET LOSS containers stopped when internet is lost
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
# RSYNC WRITEBACK HOST2 appdata synced back on handback
#
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
# MEDIA CLEANER folder lists for media_cleaner.sh
#
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
# CERTIFICATE MONITOR domains checked for SSL expiry
# SMART HEALTH drives to skip in SMART monitoring
# ZFS REPORT pools to exclude from ZFS health report
#
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
# TRANSCODES ramdisk size, thresholds, SSD path, server array
#
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
# SONARR URL, API key, path map
# RADARR URL, API key, path map
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
#
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
#
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
# RESOURCE MANAGER containers paused/stopped under memory pressure
#
# ==============================================================================================
# ==============================================================================================
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Identity ━━━
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
# SSH key used for all server-to-server operations — rsync, failover container commands.
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
HOST2_OWNER="jayred365"
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
# ━━━ Unraid API ━━━
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
# ━━━ Emby ━━━
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
# API key: Emby Dashboard → API Keys → + New Key
HOST2_EMBY_CONTAINER="Emby-Jayred365"
HOST2_EMBY_URL="http://localhost:8096"
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
# ━━━ Jellyfin ━━━
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
HOST2_JELLYFIN_CONTAINER="Jellyfin"
HOST2_JELLYFIN_URL="http://localhost:8095"
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
# ━━━ Notifications ━━━
# Discord webhook — leave blank to disable.
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
HOST2_DISCORD_WEBHOOK=""
# ━━━ Partnership ━━━
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
# Auth containers reconfigured on onboard/offboard.
# Format: "ContainerName|WebUIPort"
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
# On offboard → WebUI pointed back at localhost
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
# fill in when HOST2 is back online
# "NginxProxyManager|81"
)
# Containers to stop on this server before the owner deploys the auth stack during onboard.
# List whatever auth/proxy containers are currently running here.
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
"NginxProxyManager"
"Authelia"
"Authelia-Secondary"
"Mariadb-Authelia"
"Mariadb-Authelia-Secondary"
"Redis-Authelia"
"Redis-Authelia-Secondary"
"Lldap-Gmer4Lfe"
)
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
# "Sonarr"
# "Radarr"
# "Lidarr"
# "Prowlarr"
# "Bazarr"
)
# Paths HOST1 should collect during the grace window after offboard.
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
# fill in when HOST2 is back online
)
# Containers parked on this server when partnership is active.
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
# "Emby"
# "NginxProxyManager"
)
# This server's desired Emby admin account on the shared Emby instance.
# Set these — owner reads them during --onboard to create the account.
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
# ==============================================================================================
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Daily Sync Shares ━━━
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
# Mesh model: every node pushes every media share — no ownership, no mirrors.
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
# arr_cleanup removes true orphans based on local arr state.
# Any node can download content to any share — it propagates to all nodes on the next cycle.
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
# For shares needing container stops or custom options — add a profile in master.conf.
HOST2_DAILY_SYNC_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
/mnt/user/Books
/mnt/user/Intros
/mnt/user/Kids_Movies
/mnt/user/Kids_Tv_Shows
/mnt/user/Movies
/mnt/user/Music
/mnt/user/Music_Videos
/mnt/user/stand-up_comedy
/mnt/user/Sports
/mnt/user/Tv_Shows
/mnt/user/Anime_Shows-Old
/mnt/user/Anime_Movies-Old
)
# Personal encrypted shares — synced for offsite backup, independent of media shares.
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
HOST2_PERSONAL_SHARES=(
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
)
# ━━━ Weekly Sync Shares ━━━
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
# Containers stopped both sides before sync — full clean state guaranteed.
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
HOST2_WEEKLY_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/Media_Server/Emby"
# "/mnt/user/appdata-Fallback/Critical-Data"
)
# ━━━ Intermediate Sync Shares ━━━
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
HOST2_INTERMEDIATE_SYNC_SHARES=(
# fill in when HOST2 is back online
# Example: "/mnt/user/Emby_Metadata"
)
# ━━━ Critical Sync Shares ━━━
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
# Format: "/path/to/share" or "/path/to/share|profile-name"
HOST2_CRITICAL_SYNC_SHARES=(
# fill in when HOST2 is back online
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
# "/mnt/user/Media_Server/Emby|emby-fallback"
)
# ━━━ Backup Verify ━━━
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
# Sample size and minimum file size defined in master.conf.
HOST2_BACKUP_VERIFY_SHARES=(
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
)
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
# Use for appdata unique to HOST2.
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
PROFILE_BW_LIMIT[host2-appdata]=8000
PROFILE_RETRY_COUNT[host2-appdata]=3
PROFILE_SLEEP[host2-appdata]=300
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
PROFILE_CONTAINER_DELAY[host2-appdata]=5
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
# ==============================================================================================
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Docker Daily Restart ━━━
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
HOST2_DAILY_RESTART_CONTAINERS=(
"NginxProxyManager"
# add HOST2 daily restart containers here
)
# ━━━ Docker Weekly Restart ━━━
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
# Containers already stopped for weekly sync — restart adds zero extra downtime.
HOST2_WEEKLY_RESTART_CONTAINERS=(
# add HOST2 weekly restart containers here
)
# ━━━ Docker Watchdog ━━━
# Per-HOST2 container configuration for docker_watchdog.sh.
# Shared thresholds and toggles live in master.conf.
# Memory hard limits in MB — immediate restart if exceeded.
# Set at "container is clearly broken" not "container is busy".
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
declare -A HOST2_WATCHDOG_CONTAINERS=(
["Emby"]=16384 # fill in correct limit when HOST2 is back online
)
# HTTP health check URLs — checked every cycle, strike system before restart.
# Only add containers with a meaningful web interface to check.
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
["Emby"]="http://localhost:8096"
)
# Required containers — must always be running on HOST2.
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
# Listed in dependency order — dependencies before dependents.
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
"NginxProxyManager"
# add HOST2 required containers here when back online
)
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
HOST2_WATCHDOG_SCAN_IGNORE=(
# add HOST2 scan ignore containers here when back online
)
# Dependency ordering — skip restarting a container if its dependency is also down.
# Prevents watchdog from restarting dependent services before their dependencies are up.
# SPACE-SEPARATED STRINGS — converted to array at runtime.
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
# add HOST2 dependencies here when containers are defined
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
)
# Per-container appdata growth suppress ceilings in MB.
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
# Use when a container legitimately has large stable data and you want to suppress false-positive
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
# add HOST2 suppress entries here only as needed
)
# ━━━ Docker Network Connect ━━━
# Containers connected to custom networks at array start by docker_network_connect.sh.
# Networks created if they don't exist — idempotent, safe to re-run.
HOST2_NETWORK_CONNECT_CONTAINERS=(
# fill in when HOST2 is back online
)
HOST2_NETWORK_CONNECT_NETWORKS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ DDNS ━━━
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
# Internet loss → stop immediately
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
HOST2_DDNS_CONTAINERS=(
"Gmer4Lfe.us"
)
# ━━━ Internet Loss ━━━
# Containers stopped immediately on HOST2 when internet connection is lost.
# Prevents external-facing services from operating without connectivity.
FALLBACK_HOST2_STOP_ON_NO_NET=(
"Gmer4Lfe.us"
)
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
# Containers HOST2 starts when HOST1 goes down.
# Tier 1 is always immediate — vital services cannot wait.
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
"Gmer4Lfe.com"
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
"Emby"
"VaultWarden-Gmer4Lfe"
"Dispatcharr"
"Dispatcharr-Basic"
"Dispatcharr-Iptv-Users"
"ErsatzTV-Emby"
)
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
"Postgres-NextCloud"
"NextCloud"
"PostgreSQL_Immich"
"Immich-Gmer4Lfe"
)
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
"Gitea"
)
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
"Sonarr"
"Radarr"
"Lidarr"
"Readarr"
"Prowlarr"
"Bazarr"
"SABnzbd-Gmer4Lfe"
"Qbittorrent-Gmer4Lfe"
"LidaTube"
"Pinchflat"
"ChannelTube"
)
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
# Tier 1 is always immediate — no delay var needed.
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
# Containers stopped before writeback — clean source, no competing writes.
#
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
# is more reliable than dirty sync data for brief outages.
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
FALLBACK_HOST2_WRITEBACK_TIER1=(
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
)
FALLBACK_HOST2_WRITEBACK_TIER2=(
# "/mnt/user/appdata-Fallback/Jayred365-Important"
)
FALLBACK_HOST2_WRITEBACK_TIER3=(
# "location-placeholder"
)
FALLBACK_HOST2_WRITEBACK_TIER4=(
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
)
# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Media Permissions ━━━
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
HOST2_MEDIA_PERMISSION_SHARES=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
# ━━━ Media Cleaner ━━━
# Folder lists for media_cleaner.sh — two profiles: anime and media.
# File patterns shared across all servers — defined in master.conf.
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
HOST2_ANIME_CLEAN_FOLDERS=(
/mnt/user/Anime_Movies
/mnt/user/Anime_Shows
)
HOST2_MEDIA_CLEAN_FOLDERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
HOST2_CERT_MONITOR_DOMAINS=(
# fill in when HOST2 is back online
)
# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
HOST2_SMART_IGNORE_DRIVES=(
"sda" # boot USB — SMART not meaningful on flash drives
)
# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# Pool health thresholds defined in master.conf.
HOST2_ZFS_REPORT_IGNORE_POOLS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
HOST2_RAMDISK_SIZE="8G"
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
# Must be on cache pool — array disks too slow for active transcode writes.
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
# Media servers sharing the ramdisk transcode space on HOST2.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
HOST2_TRANSCODE_SERVERS=(
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
)
# ==============================================================================================
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
# detect_hosts() selects HOST2 vars when running on HOST2.
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
#
# PATH MAPS — container path → host path translation.
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
# ━━━ Sonarr ━━━
HOST2_SONARR_URL="http://localhost:8989"
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
declare -A HOST2_SONARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/tv"]="/mnt/user/Anime_Shows"
)
# ━━━ Radarr ━━━
HOST2_RADARR_URL="http://localhost:7878"
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
declare -A HOST2_RADARR_PATH_MAP=(
# fill in when HOST2 is back online
# ["/anime-movies"]="/mnt/user/Anime_Movies"
)
# ━━━ Arr Recovery Toggles ━━━
# false = skip that arr on this host — exits cleanly without error
HOST2_SONARR_RECOVERY=true
HOST2_RADARR_RECOVERY=true
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
#
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
# Three-tier response — all critical checks enabled regardless of rebuild state:
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
# Tier 3 (standard strike system): selectively disabled during rebuild
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
HOST2_SYS_WATCHDOG_NIC="eth0"
# ━━━ Tier 1 — Critical Checks ━━━
# All critical checks always enabled — these protect against acute failure regardless of
# rebuild state. Disabling any is not recommended.
# Docker daemon unresponsive → try restart, reboot if restart fails.
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_FD=true
# /boot read-only detected → reboot immediately.
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
# ━━━ Tier 2 — Urgent OOM Check ━━━
# Both must be enabled for Tier 2 bypass to function.
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
HOST2_SYS_WATCHDOG_CHECK_OOM=true
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
HOST2_SYS_WATCHDOG_CHECK_RAM=true
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
HOST2_SYS_WATCHDOG_CHECK_LOG=true
# ZFS ARC memory check.
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
HOST2_SYS_WATCHDOG_CHECK_ARC=false
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
# docker_watchdog.sh persistent skip list check.
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
# /tmp filesystem usage with auto-clear attempt.
HOST2_SYS_WATCHDOG_CHECK_TMP=true
# Array disk error count delta in /proc/mdstat.
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
# sshd running check — restart attempt before escalating.
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
# Runaway process detection.
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
HOST2_RW_PAUSE_CONTAINERS=(
# fill in when HOST2 is back online
)
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
HOST2_RW_STOP_CONTAINERS=(
# fill in when HOST2 is back online
)
# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Fill in when HOST2 is back online.
# ━━━ NginxProxyManager ━━━
HOST2_NPM_URL="http://localhost:81"
HOST2_NPM_USER="" # NPM admin email
HOST2_NPM_PASS="" # NPM admin password
# ━━━ lldap ━━━
HOST2_LLDAP_URL="http://localhost:17170"
HOST2_LLDAP_USER="admin" # lldap admin username
HOST2_LLDAP_PASS="" # lldap admin password
# ━━━ Authelia ━━━
HOST2_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
HOST2_AUTHELIA_CONTAINER="Authelia"
# ==============================================================================================
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
# ==============================================================================================
@@ -0,0 +1,91 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
? trim($_POST['action'] ?? '')
: trim($_GET['action'] ?? '');
$cacheFile = STATE_DIR . '/cert_status.json';
// ── Read configured domains (without running checks) ─────────────────────────
if ($action === 'domains') {
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$master = vv_read_conf_raw('master.conf');
// Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
// Extract domains array from host conf
$domains = [];
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
$domains = $dd[1] ?? [];
}
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'domains' => $domains,
'warn_days' => (int)($w[1] ?? 30),
'crit_days' => (int)($c[1] ?? 7),
]);
exit;
}
// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
if ($action === 'run') {
$script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
exit;
}
set_time_limit(180);
exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
// Read freshly written cache
$data = file_exists($cacheFile)
? (json_decode(file_get_contents($cacheFile), true) ?: null)
: null;
echo json_encode([
'ok' => true,
'data' => $data,
'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
'rc' => $rc,
]);
exit;
}
// ── Default: return cached status ─────────────────────────────────────────────
if (!file_exists($cacheFile)) {
// No cache yet — return configured domains so UI can show them unchecked
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$master = vv_read_conf_raw('master.conf');
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
$domains = [];
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
foreach ($dd[1] ?? [] as $d) {
$domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
}
}
echo json_encode([
'ok' => true,
'checked_at' => null,
'host' => $hostId !== 'unknown' ? strtoupper($hostId) : null,
'warn_days' => (int)($w[1] ?? 30),
'crit_days' => (int)($c[1] ?? 7),
'domains' => $domains,
]);
exit;
}
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));
@@ -0,0 +1,329 @@
#!/bin/bash
# ==============================================================================================
# ================================= Storage Migration ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Migrates Varaverk between internal NVMe and USB flash storage modes.
#
# Internal mode: SCRIPTS_DIR = /boot/config/plugins/varaverk
# All scripts, conf, state, and git repo live on fast internal storage.
# Direct git pull/push. Zero write-wear concern.
#
# Flash mode: SCRIPTS_DIR = /mnt/user/appdata/Varaverk
# All scripts, conf, state, and git repo live in appdata.
# Preserves USB flash lifetime. Array must be started for Varaverk to function.
# git_pull_execute.sh syncs Plugin/ back to /boot/ after each pull so the
# Unraid webUI always serves current PHP files.
#
# What this script updates:
# varaverk.cfg SCRIPTS_DIR
# master.conf TARGET_DIR
# host*.conf HOST*_STORAGE_MODE_INTERNAL
# varaverk.cron rebuilt via PHP (job paths regenerated for new SCRIPTS_DIR)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# storage_migrate.sh --to=internal
# Migrate to /boot/config/plugins/varaverk
#
# storage_migrate.sh --to=flash
# Migrate to /mnt/user/appdata/Varaverk
#
# storage_migrate.sh --dry-run --to=<mode>
# Show what would happen — no changes made
#
# storage_migrate.sh --status
# Show current mode, paths, and boot device info
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
acquire_lock
VV_CFG="/boot/config/plugins/varaverk/varaverk.cfg"
INTERNAL_DIR="/boot/config/plugins/varaverk"
FLASH_DIR="/mnt/user/appdata/Varaverk"
# ──────────────────────────────────────────────────────────────────────────────
# Parse --to= from raw args (parse_args doesn't handle this flag)
TO_MODE=""
for arg in "$@"; do
case "$arg" in
--to=internal) TO_MODE="internal" ;;
--to=flash) TO_MODE="flash" ;;
esac
done
# ──────────────────────────────────────────────────────────────────────────────
# Boot device detection
detect_boot_storage() {
local boot_part boot_disk transport
boot_part=$(findmnt -n -o SOURCE /boot 2>/dev/null)
boot_disk=$(lsblk -no pkname "$boot_part" 2>/dev/null)
transport=$(lsblk -dno TRAN "/dev/$boot_disk" 2>/dev/null | tr '[:upper:]' '[:lower:]')
echo "${transport:-unknown}"
}
# ──────────────────────────────────────────────────────────────────────────────
# Status
if [[ "$SHOW_STATUS" == true ]]; then
TRANSPORT=$(detect_boot_storage)
DETECTED=$([[ "$TRANSPORT" == "usb" ]] && echo "flash" || echo "internal")
echo ""
echo "━━━━━ $ICON_SUMMARY STORAGE STATUS ━━━━━"
echo "$ICON_GEAR SCRIPTS_DIR: $SCRIPTS_DIR"
echo "$ICON_GEAR varaverk.cfg: $VV_CFG"
echo "$ICON_HOST Boot device: transport=$TRANSPORT → detected=$DETECTED"
echo "$ICON_GEAR Target dirs:"
echo " internal: $INTERNAL_DIR"
echo " flash: $FLASH_DIR"
CONF_MODE=$(grep -m1 "${MY_ID}_STORAGE_MODE_INTERNAL" "$CONF_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' | tr -d '[:space:]')
echo "$ICON_GEAR conf setting: ${MY_ID}_STORAGE_MODE_INTERNAL=${CONF_MODE:-not set}"
if [[ "$SCRIPTS_DIR" == "$INTERNAL_DIR" ]]; then
echo "$ICON_DONE Current mode: INTERNAL ✅"
elif [[ "$SCRIPTS_DIR" == "$FLASH_DIR" ]]; then
echo "$ICON_DONE Current mode: FLASH ✅"
else
echo "$ICON_WARN Current mode: CUSTOM ($SCRIPTS_DIR)"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
if [[ -z "$TO_MODE" ]]; then
error "Usage: storage_migrate.sh --to=internal|flash [--dry-run] [--log]"
exit 1
fi
SRC="$SCRIPTS_DIR"
DST=$([[ "$TO_MODE" == "internal" ]] && echo "$INTERNAL_DIR" || echo "$FLASH_DIR")
NEW_INTERNAL=$([[ "$TO_MODE" == "internal" ]] && echo "true" || echo "false")
log "$ICON_GEAR Config: to=${TO_MODE} src=${SRC} dst=${DST} dry-run=${DRY_RUN}"
echo ""
echo "━━━━━ $ICON_SYNC Storage Migration ━━━━━"
echo "$ICON_GEAR From: $SRC"
echo "$ICON_GEAR To: $DST"
echo "$ICON_GEAR Mode: $TO_MODE"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
echo ""
# ──────────────────────────────────────────────────────────────────────────────
# Pre-flight checks
if [[ "$SRC" == "$DST" ]]; then
info "Already in $TO_MODE mode — nothing to do"
exit 0
fi
if [[ "$TO_MODE" == "flash" ]]; then
if ! mountpoint -q /mnt/user 2>/dev/null; then
error "Array not started — /mnt/user is not mounted. Start the array before migrating to flash."
exit 1
fi
fi
if [[ ! -f "$SRC/load_config.sh" ]]; then
error "Source directory looks invalid: $SRC (load_config.sh not found)"
exit 1
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 1: Git push — ensure remote has everything before we touch the local repo
echo "━━━ $ICON_SYNC Step 1: Git push ━━━"
if [[ "$DRY_RUN" == false ]]; then
if [[ -d "$SRC/.git" ]]; then
log "Pushing to Gitea before migration..."
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" \
git -C "$SRC" push origin main 2>&1 | while IFS= read -r line; do echo " $line"; done; then
echo " Git push complete ✅"
else
warn "Git push failed — continuing (data safe locally, push manually after migration)"
fi
else
warn "No .git directory in $SRC — skipping push"
fi
else
warn "DRY RUN — would push $SRC to Gitea"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 2: Rsync content to destination
echo ""
echo "━━━ $ICON_SYNC Step 2: Copy files ━━━"
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$DST"
echo " rsync: $SRC/ → $DST/"
if rsync -av --delete \
--exclude='.git' \
"$SRC/" "$DST/" 2>&1 | \
grep -v "/$" | \
while IFS= read -r line; do log "$line"; done; then
echo " Files copied ✅"
else
error "rsync failed — aborting migration"
exit 1
fi
# Copy .git separately (rsync --exclude='.git' above skipped it)
echo " Copying .git..."
if cp -a "$SRC/.git" "$DST/.git" 2>/dev/null || \
rsync -a "$SRC/.git/" "$DST/.git/" 2>/dev/null; then
echo " .git copied ✅"
else
error ".git copy failed — aborting"
exit 1
fi
# Mark git safe directory
git config --global --add safe.directory "$DST" 2>/dev/null
else
warn "DRY RUN — would rsync $SRC/ → $DST/ (including .git)"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 3: Update varaverk.cfg
echo ""
echo "━━━ $ICON_GEAR Step 3: Update varaverk.cfg ━━━"
if [[ "$DRY_RUN" == false ]]; then
if grep -q '^SCRIPTS_DIR' "$VV_CFG"; then
sed -i "s|^SCRIPTS_DIR=.*|SCRIPTS_DIR=\"$DST\"|" "$VV_CFG"
else
echo "SCRIPTS_DIR=\"$DST\"" >> "$VV_CFG"
fi
echo " SCRIPTS_DIR → $DST ✅"
else
warn "DRY RUN — would set SCRIPTS_DIR=\"$DST\" in $VV_CFG"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 4: Update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf (new location)
echo ""
echo "━━━ $ICON_GEAR Step 4: Update master.conf paths ━━━"
NEW_MASTER="$DST/Configurations/master.conf"
if [[ "$DRY_RUN" == false ]]; then
if [[ -f "$NEW_MASTER" ]]; then
sed -i "s|^\(\s*TARGET_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST\"|" "$NEW_MASTER"
sed -i "s|^\(\s*DATA_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/data\"|" "$NEW_MASTER"
sed -i "s|^\(\s*STATE_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/State_Files\"|" "$NEW_MASTER"
echo " TARGET_DIR → $DST ✅"
echo " DATA_DIR → $DST/data ✅"
echo " STATE_DIR → $DST/State_Files ✅"
else
error "master.conf not found at $NEW_MASTER"
exit 1
fi
else
warn "DRY RUN — would update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 5: Update STORAGE_MODE_INTERNAL in host*.conf (new location)
echo ""
echo "━━━ $ICON_GEAR Step 5: Update STORAGE_MODE_INTERNAL ━━━"
NEW_CONF="$DST/Configurations/${MY_ID,,}.conf"
if [[ "$DRY_RUN" == false ]]; then
if [[ -f "$NEW_CONF" ]]; then
if grep -q "${MY_ID}_STORAGE_MODE_INTERNAL" "$NEW_CONF"; then
sed -i "s|^\(\s*${MY_ID}_STORAGE_MODE_INTERNAL\s*=\s*\).*|\1${NEW_INTERNAL}|" "$NEW_CONF"
else
sed -i "/# ━━━ Storage mode/a\\ ${MY_ID}_STORAGE_MODE_INTERNAL=${NEW_INTERNAL}" "$NEW_CONF"
fi
echo " ${MY_ID}_STORAGE_MODE_INTERNAL → $NEW_INTERNAL ✅"
else
warn "${MY_ID,,}.conf not found at $NEW_CONF — skipping conf update"
fi
else
warn "DRY RUN — would set ${MY_ID}_STORAGE_MODE_INTERNAL=$NEW_INTERNAL"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 6: Rebuild cron (paths must reference new SCRIPTS_DIR)
echo ""
echo "━━━ $ICON_GEAR Step 6: Rebuild cron ━━━"
if [[ "$DRY_RUN" == false ]]; then
RESULT=$(php -r "
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
\$_c = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', \$_c['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
require_once '/usr/local/emhttp/plugins/varaverk/include/confform.php';
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$ok = vv_cron_rebuild(vv_schedule_load());
echo \$ok ? 'ok' : 'fail';
" 2>/dev/null)
if [[ "$RESULT" == "ok" ]]; then
echo " Cron rebuilt ✅"
else
warn "Cron rebuild failed — run Settings → Scheduler → Save to regenerate"
fi
else
warn "DRY RUN — would rebuild cron with new SCRIPTS_DIR paths"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 7: Flash mode — sync Plugin/ to /boot/ so webUI is current
if [[ "$TO_MODE" == "flash" && "$DRY_RUN" == false ]]; then
echo ""
echo "━━━ $ICON_SYNC Step 7: Sync Plugin/ → /boot/ ━━━"
if rsync -a --delete "$DST/Plugin/" "/boot/config/plugins/varaverk/Plugin/" 2>/dev/null; then
echo " Plugin/ synced to /boot/ ✅"
else
warn "Plugin/ sync to /boot/ failed — webUI may be stale"
fi
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 8: Delete old location
echo ""
echo "━━━ $ICON_GEAR Step $([[ "$TO_MODE" == "flash" ]] && echo 8 || echo 7): Clean up old location ━━━"
if [[ "$DRY_RUN" == false ]]; then
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
# Migrating internal→flash: keep varaverk.cfg and Plugin/ in /boot/, remove everything else
echo " Removing scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)..."
find "$SRC" -mindepth 1 -maxdepth 1 \
! -name 'Plugin' \
! -name 'varaverk.cfg' \
! -name '*.plg' \
! -name '*.txz' \
-exec rm -rf {} + 2>/dev/null
echo " /boot/ cleaned ✅ (Plugin/ and varaverk.cfg preserved)"
else
# Migrating flash→internal: remove appdata copy entirely
echo " Removing $SRC..."
rm -rf "$SRC"
echo " $SRC removed ✅"
fi
else
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
warn "DRY RUN — would remove scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)"
else
warn "DRY RUN — would remove $SRC"
fi
fi
# ──────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_DONE Migration complete ━━━━━"
echo "$ICON_GEAR Mode: $TO_MODE"
echo "$ICON_GEAR SCRIPTS_DIR: $DST"
if [[ "$TO_MODE" == "flash" ]]; then
echo ""
warn "IMPORTANT: Varaverk requires the array to be started to function in flash mode."
warn "The webUI plugin tab will load normally at all times (Plugin/ stays in /boot/)."
fi
echo ""
echo " Reload the Varaverk plugin tab to apply changes."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,329 @@
#!/bin/bash
# ==============================================================================================
# ================================= Storage Migration ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Migrates Varaverk between internal NVMe and USB flash storage modes.
#
# Internal mode: SCRIPTS_DIR = /boot/config/plugins/varaverk
# All scripts, conf, state, and git repo live on fast internal storage.
# Direct git pull/push. Zero write-wear concern.
#
# Flash mode: SCRIPTS_DIR = /mnt/user/appdata/Varaverk
# All scripts, conf, state, and git repo live in appdata.
# Preserves USB flash lifetime. Array must be started for Varaverk to function.
# git_pull_execute.sh syncs Plugin/ back to /boot/ after each pull so the
# Unraid webUI always serves current PHP files.
#
# What this script updates:
# varaverk.cfg SCRIPTS_DIR
# master.conf TARGET_DIR
# host*.conf HOST*_STORAGE_MODE_INTERNAL
# varaverk.cron rebuilt via PHP (job paths regenerated for new SCRIPTS_DIR)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# storage_migrate.sh --to=internal
# Migrate to /boot/config/plugins/varaverk
#
# storage_migrate.sh --to=flash
# Migrate to /mnt/user/appdata/Varaverk
#
# storage_migrate.sh --dry-run --to=<mode>
# Show what would happen — no changes made
#
# storage_migrate.sh --status
# Show current mode, paths, and boot device info
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
VV_CFG="/boot/config/plugins/varaverk/varaverk.cfg"
INTERNAL_DIR="/boot/config/plugins/varaverk"
FLASH_DIR="/mnt/user/appdata/Varaverk"
# ──────────────────────────────────────────────────────────────────────────────
# Parse --to= from raw args (parse_args doesn't handle this flag)
TO_MODE=""
for arg in "$@"; do
case "$arg" in
--to=internal) TO_MODE="internal" ;;
--to=flash) TO_MODE="flash" ;;
esac
done
# ──────────────────────────────────────────────────────────────────────────────
# Boot device detection
detect_boot_storage() {
local boot_part boot_disk transport
boot_part=$(findmnt -n -o SOURCE /boot 2>/dev/null)
boot_disk=$(lsblk -no pkname "$boot_part" 2>/dev/null)
transport=$(lsblk -dno TRAN "/dev/$boot_disk" 2>/dev/null | tr '[:upper:]' '[:lower:]')
echo "${transport:-unknown}"
}
# ──────────────────────────────────────────────────────────────────────────────
# Status
if [[ "$SHOW_STATUS" == true ]]; then
TRANSPORT=$(detect_boot_storage)
DETECTED=$([[ "$TRANSPORT" == "usb" ]] && echo "flash" || echo "internal")
echo ""
echo "━━━━━ $ICON_SUMMARY STORAGE STATUS ━━━━━"
echo "$ICON_GEAR SCRIPTS_DIR: $SCRIPTS_DIR"
echo "$ICON_GEAR varaverk.cfg: $VV_CFG"
echo "$ICON_HOST Boot device: transport=$TRANSPORT → detected=$DETECTED"
echo "$ICON_GEAR Target dirs:"
echo " internal: $INTERNAL_DIR"
echo " flash: $FLASH_DIR"
CONF_MODE=$(grep -m1 "${MY_ID}_STORAGE_MODE_INTERNAL" "$CONF_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' | tr -d '[:space:]')
echo "$ICON_GEAR conf setting: ${MY_ID}_STORAGE_MODE_INTERNAL=${CONF_MODE:-not set}"
if [[ "$SCRIPTS_DIR" == "$INTERNAL_DIR" ]]; then
echo "$ICON_DONE Current mode: INTERNAL ✅"
elif [[ "$SCRIPTS_DIR" == "$FLASH_DIR" ]]; then
echo "$ICON_DONE Current mode: FLASH ✅"
else
echo "$ICON_WARN Current mode: CUSTOM ($SCRIPTS_DIR)"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
if [[ -z "$TO_MODE" ]]; then
error "Usage: storage_migrate.sh --to=internal|flash [--dry-run] [--log]"
exit 1
fi
SRC="$SCRIPTS_DIR"
DST=$([[ "$TO_MODE" == "internal" ]] && echo "$INTERNAL_DIR" || echo "$FLASH_DIR")
NEW_INTERNAL=$([[ "$TO_MODE" == "internal" ]] && echo "true" || echo "false")
log "$ICON_GEAR Config: to=${TO_MODE} src=${SRC} dst=${DST} dry-run=${DRY_RUN}"
echo ""
echo "━━━━━ $ICON_SYNC Storage Migration ━━━━━"
echo "$ICON_GEAR From: $SRC"
echo "$ICON_GEAR To: $DST"
echo "$ICON_GEAR Mode: $TO_MODE"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
echo ""
# ──────────────────────────────────────────────────────────────────────────────
# Pre-flight checks
if [[ "$SRC" == "$DST" ]]; then
info "Already in $TO_MODE mode — nothing to do"
exit 0
fi
if [[ "$TO_MODE" == "flash" ]]; then
if ! mountpoint -q /mnt/user 2>/dev/null; then
error "Array not started — /mnt/user is not mounted. Start the array before migrating to flash."
exit 1
fi
fi
if [[ ! -f "$SRC/load_config.sh" ]]; then
error "Source directory looks invalid: $SRC (load_config.sh not found)"
exit 1
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 1: Git push — ensure remote has everything before we touch the local repo
echo "━━━ $ICON_SYNC Step 1: Git push ━━━"
if [[ "$DRY_RUN" == false ]]; then
if [[ -d "$SRC/.git" ]]; then
log "Pushing to Gitea before migration..."
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" \
git -C "$SRC" push origin main 2>&1 | while IFS= read -r line; do echo " $line"; done; then
echo " Git push complete ✅"
else
warn "Git push failed — continuing (data safe locally, push manually after migration)"
fi
else
warn "No .git directory in $SRC — skipping push"
fi
else
warn "DRY RUN — would push $SRC to Gitea"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 2: Rsync content to destination
echo ""
echo "━━━ $ICON_SYNC Step 2: Copy files ━━━"
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$DST"
echo " rsync: $SRC/ → $DST/"
if rsync -av --delete \
--exclude='.git' \
"$SRC/" "$DST/" 2>&1 | \
grep -v "/$" | \
while IFS= read -r line; do log "$line"; done; then
echo " Files copied ✅"
else
error "rsync failed — aborting migration"
exit 1
fi
# Copy .git separately (rsync --exclude='.git' above skipped it)
echo " Copying .git..."
if cp -a "$SRC/.git" "$DST/.git" 2>/dev/null || \
rsync -a "$SRC/.git/" "$DST/.git/" 2>/dev/null; then
echo " .git copied ✅"
else
error ".git copy failed — aborting"
exit 1
fi
# Mark git safe directory
git config --global --add safe.directory "$DST" 2>/dev/null
else
warn "DRY RUN — would rsync $SRC/ → $DST/ (including .git)"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 3: Update varaverk.cfg
echo ""
echo "━━━ $ICON_GEAR Step 3: Update varaverk.cfg ━━━"
if [[ "$DRY_RUN" == false ]]; then
if grep -q '^SCRIPTS_DIR' "$VV_CFG"; then
sed -i "s|^SCRIPTS_DIR=.*|SCRIPTS_DIR=\"$DST\"|" "$VV_CFG"
else
echo "SCRIPTS_DIR=\"$DST\"" >> "$VV_CFG"
fi
echo " SCRIPTS_DIR → $DST ✅"
else
warn "DRY RUN — would set SCRIPTS_DIR=\"$DST\" in $VV_CFG"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 4: Update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf (new location)
echo ""
echo "━━━ $ICON_GEAR Step 4: Update master.conf paths ━━━"
NEW_MASTER="$DST/Configurations/master.conf"
if [[ "$DRY_RUN" == false ]]; then
if [[ -f "$NEW_MASTER" ]]; then
sed -i "s|^\(\s*TARGET_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST\"|" "$NEW_MASTER"
sed -i "s|^\(\s*DATA_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/data\"|" "$NEW_MASTER"
sed -i "s|^\(\s*STATE_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/State_Files\"|" "$NEW_MASTER"
echo " TARGET_DIR → $DST ✅"
echo " DATA_DIR → $DST/data ✅"
echo " STATE_DIR → $DST/State_Files ✅"
else
error "master.conf not found at $NEW_MASTER"
exit 1
fi
else
warn "DRY RUN — would update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 5: Update STORAGE_MODE_INTERNAL in host*.conf (new location)
echo ""
echo "━━━ $ICON_GEAR Step 5: Update STORAGE_MODE_INTERNAL ━━━"
NEW_CONF="$DST/Configurations/${MY_ID,,}.conf"
if [[ "$DRY_RUN" == false ]]; then
if [[ -f "$NEW_CONF" ]]; then
if grep -q "${MY_ID}_STORAGE_MODE_INTERNAL" "$NEW_CONF"; then
sed -i "s|^\(\s*${MY_ID}_STORAGE_MODE_INTERNAL\s*=\s*\).*|\1${NEW_INTERNAL}|" "$NEW_CONF"
else
sed -i "/# ━━━ Storage mode/a\\ ${MY_ID}_STORAGE_MODE_INTERNAL=${NEW_INTERNAL}" "$NEW_CONF"
fi
echo " ${MY_ID}_STORAGE_MODE_INTERNAL → $NEW_INTERNAL ✅"
else
warn "${MY_ID,,}.conf not found at $NEW_CONF — skipping conf update"
fi
else
warn "DRY RUN — would set ${MY_ID}_STORAGE_MODE_INTERNAL=$NEW_INTERNAL"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 6: Rebuild cron (paths must reference new SCRIPTS_DIR)
echo ""
echo "━━━ $ICON_GEAR Step 6: Rebuild cron ━━━"
if [[ "$DRY_RUN" == false ]]; then
RESULT=$(php -r "
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
\$_c = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', \$_c['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
require_once '/usr/local/emhttp/plugins/varaverk/include/confform.php';
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
\$ok = vv_cron_rebuild(vv_schedule_load());
echo \$ok ? 'ok' : 'fail';
" 2>/dev/null)
if [[ "$RESULT" == "ok" ]]; then
echo " Cron rebuilt ✅"
else
warn "Cron rebuild failed — run Settings → Scheduler → Save to regenerate"
fi
else
warn "DRY RUN — would rebuild cron with new SCRIPTS_DIR paths"
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 7: Flash mode — sync Plugin/ to /boot/ so webUI is current
if [[ "$TO_MODE" == "flash" && "$DRY_RUN" == false ]]; then
echo ""
echo "━━━ $ICON_SYNC Step 7: Sync Plugin/ → /boot/ ━━━"
if rsync -a --delete "$DST/Plugin/" "/boot/config/plugins/varaverk/Plugin/" 2>/dev/null; then
echo " Plugin/ synced to /boot/ ✅"
else
warn "Plugin/ sync to /boot/ failed — webUI may be stale"
fi
fi
# ──────────────────────────────────────────────────────────────────────────────
# Step 8: Delete old location
echo ""
echo "━━━ $ICON_GEAR Step $([[ "$TO_MODE" == "flash" ]] && echo 8 || echo 7): Clean up old location ━━━"
if [[ "$DRY_RUN" == false ]]; then
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
# Migrating internal→flash: keep varaverk.cfg and Plugin/ in /boot/, remove everything else
echo " Removing scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)..."
find "$SRC" -mindepth 1 -maxdepth 1 \
! -name 'Plugin' \
! -name 'varaverk.cfg' \
! -name '*.plg' \
! -name '*.txz' \
-exec rm -rf {} + 2>/dev/null
echo " /boot/ cleaned ✅ (Plugin/ and varaverk.cfg preserved)"
else
# Migrating flash→internal: remove appdata copy entirely
echo " Removing $SRC..."
rm -rf "$SRC"
echo " $SRC removed ✅"
fi
else
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
warn "DRY RUN — would remove scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)"
else
warn "DRY RUN — would remove $SRC"
fi
fi
# ──────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_DONE Migration complete ━━━━━"
echo "$ICON_GEAR Mode: $TO_MODE"
echo "$ICON_GEAR SCRIPTS_DIR: $DST"
if [[ "$TO_MODE" == "flash" ]]; then
echo ""
warn "IMPORTANT: Varaverk requires the array to be started to function in flash mode."
warn "The webUI plugin tab will load normally at all times (Plugin/ stays in /boot/)."
fi
echo ""
echo " Reload the Varaverk plugin tab to apply changes."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,312 @@
<?php
// First-run setup wizard.
// HOST1 path: blank master.conf → fill hostnames → write master.conf + host1.conf → scheduler.
// HOST2 path: state file present → pull master.conf from HOST1 → fill host2.conf → scheduler.
$detectedHostname = trim(shell_exec('hostname -s') ?: '');
// Check for setup state file pushed by HOST1
$setupState = vv_setup_state_read();
$host1FromState = $setupState['host1_hostname'] ?? '';
// Determine if HOST2 scenario: state file present but master.conf has blank HOST1
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');
// Determine local host slot (if master.conf has hostnames, we may already know)
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
// If master.conf has HOST1/HOST2 filled but local host.conf is missing:
// This is the "master was pushed, just need the local conf" scenario
$myHostId = vv_detect_host(); // may be 'host2' if master.conf was already pushed
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 560px;
margin: 48px auto 0;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 36px 40px 40px;
font-family: monospace;
color: #ccc;
}
#vv-setup h1 { margin: 0 0 6px; font-size: 18px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
#vv-setup .vv-setup-sub { font-size: 12px; color: #555; margin-bottom: 32px; }
#vv-setup .vv-setup-field { margin-bottom: 20px; }
#vv-setup label { display: block; font-size: 11px; color: #888; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
#vv-setup input[type=text] { width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333; color: #ddd; padding: 7px 10px; border-radius: 3px; font-family: monospace; font-size: 13px; }
#vv-setup input[type=text]:focus { outline: none; border-color: #555; }
#vv-setup .vv-setup-hint { font-size: 11px; color: #555; margin-top: 5px; }
#vv-setup .vv-setup-role { display: flex; gap: 10px; margin-bottom: 24px; }
#vv-setup .vv-setup-role-btn { flex: 1; padding: 10px 0; background: #1a1a1a; border: 1px solid #333; border-radius: 3px; color: #888; font-family: monospace; font-size: 12px; cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
#vv-setup .vv-setup-role-btn.active { border-color: #555; color: #ccc; background: #222; }
#vv-setup .vv-setup-conditional { display: none; }
#vv-setup .vv-setup-conditional.visible { display: block; }
#vv-setup hr.vv-setup-divider { border: none; border-top: 1px solid #222; margin: 24px 0; }
#vv-setup-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444; color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px; cursor: pointer; letter-spacing: .03em; }
#vv-setup-btn:hover { border-color: #666; color: #eee; }
#vv-setup-btn:disabled { opacity: .45; cursor: default; }
#vv-setup-status { margin-top: 12px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-setup-status.ok { color: #4a8; }
#vv-setup-status.err { color: #a44; }
.vv-setup-info-box { background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px; padding: 12px 14px; margin-bottom: 24px; font-size: 12px; color: #777; line-height: 1.6; }
.vv-setup-info-box strong { color: #aaa; }
</style>
<div id="vv-setup">
<?php if ($isConfOnlyFlow): ?>
<!-- master.conf was pushed by HOST1, just need local host.conf -->
<?php $slotLabel = strtoupper($myHostId); ?>
<h1>⬡ Varaverk — <?= htmlspecialchars($slotLabel) ?> Setup</h1>
<div class="vv-setup-sub">master.conf received from HOST1. Create your local configuration to continue.</div>
<div class="vv-setup-info-box">
<strong>HOST1:</strong> <?= htmlspecialchars($masterHost1) ?><br>
<strong>This server:</strong> <?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars($slotLabel) ?><br>
<strong>Creating:</strong> <?= htmlspecialchars($myHostId) ?>.conf
</div>
<button id="vv-setup-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<div id="vv-setup-status"></div>
<script>
function vvDoConfOnly() {
const btn = document.getElementById('vv-setup-btn');
const status = document.getElementById('vv-setup-status');
btn.disabled = true; btn.textContent = 'Creating…';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
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) {
status.textContent = '✓ Created'; status.className = 'ok';
vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>));
} else {
btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
}
</script>
<?php elseif ($isHost2Flow): ?>
<!-- State file present, master.conf blank — HOST2 pull flow -->
<h1>⬡ Varaverk — Partner Setup</h1>
<div class="vv-setup-sub">HOST1 has been configured. Pull their settings to continue.</div>
<div class="vv-setup-info-box">
<strong>HOST1 detected:</strong> <?= htmlspecialchars($host1FromState) ?><br>
This server will pull master.conf from HOST1 via Tailscale + SSH.<br>
<span style="color:#555">Requires SSH keys to be exchanged first (Partnership/ssh_setup.sh).</span>
</div>
<div class="vv-setup-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-setup-hint">Must match Settings → Identification exactly</div>
</div>
<div class="vv-setup-field">
<label>Your slot</label>
<select id="vv-partner-slot" style="width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #333;color:#ddd;padding:7px 10px;border-radius:3px;font-family:monospace;font-size:13px;">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<button id="vv-setup-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
<div id="vv-setup-status"></div>
<script>
function vvDoPull() {
const btn = document.getElementById('vv-setup-btn');
const status = document.getElementById('vv-setup-status');
const hostname = document.getElementById('vv-hostname').value.trim();
const slot = document.getElementById('vv-partner-slot').value;
if (!hostname) { status.textContent = '✗ Hostname required'; status.className = 'err'; return; }
btn.disabled = true; btn.textContent = 'Pulling…';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'pull',
host1_hostname: <?= json_encode($host1FromState) ?>,
my_slot: slot,
my_hostname: hostname,
});
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) {
status.textContent = '✓ Configuration pulled'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler');
} else {
btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
}
</script>
<?php else: ?>
<!-- Standard first-run: no state file, blank master.conf -->
<h1>⬡ Varaverk — First Run</h1>
<div class="vv-setup-sub">Set up your server identity before the plugin can start.</div>
<div class="vv-setup-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" placeholder="unRAID-MyServer" autocomplete="off" spellcheck="false">
<div class="vv-setup-hint">Must match Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-setup-divider">
<label style="margin-bottom:10px;display:block;">Server role</label>
<div class="vv-setup-role">
<div class="vv-setup-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first to be set up</span>
</div>
<div class="vv-setup-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining an existing primary</span>
</div>
</div>
<div class="vv-setup-conditional" id="vv-cond-primary">
<div class="vv-setup-field">
<label>Partner's hostname <span style="color:#444">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-setup-conditional" id="vv-cond-partner">
<div class="vv-setup-field">
<label>Primary server's hostname <span style="color:#a44">*required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-setup-field">
<label>Your slot</label>
<select id="vv-partner-slot" style="width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #333;color:#ddd;padding:7px 10px;border-radius:3px;font-family:monospace;font-size:13px;">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
</div>
<button id="vv-setup-btn" onclick="vvDoSetup()">Save and continue →</button>
<div id="vv-setup-status"></div>
<script>
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary').classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner').classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary').classList.toggle('visible', role === 'primary');
document.getElementById('vv-cond-partner').classList.toggle('visible', role === 'partner');
}
function vvDoSetup() {
const hostname = document.getElementById('vv-hostname').value.trim();
const status = document.getElementById('vv-setup-status');
const btn = document.getElementById('vv-setup-btn');
if (!hostname) { status.textContent = '✗ Hostname is required'; status.className = 'err'; return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname').value.trim();
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname').value.trim();
if (!primary) { status.textContent = '✗ Primary hostname required'; status.className = 'err'; return; }
mySlot = document.getElementById('vv-partner-slot').value;
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
btn.disabled = true; btn.textContent = 'Saving…';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'save', host1, host2, my_slot: mySlot, my_hostname: hostname,
});
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) {
status.textContent = '✓ Saved'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler&vv_setup=master.conf');
} else {
btn.disabled = false; btn.textContent = 'Save and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Save and continue →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
}
</script>
<?php endif; ?>
<!-- Step 2: API key — shown after any wizard flow completes -->
<div id="vv-setup-step2" style="display:none;">
<hr class="vv-setup-divider">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Step 2 of 2 — Unraid API Key</div>
<div class="vv-setup-info-box">
Varaverk uses the local Unraid API to display live stats on the Monitor tab.
Creates a <strong>Varaverk</strong> key via <code style="color:#555;">unraid-api</code> and writes it to your host conf.
</div>
<div style="display:flex;gap:10px;align-items:center;">
<button id="vv-key-btn2" onclick="vvCreateApiKeyWizard(this)"
style="flex:1;padding:10px 0;background:#1a3a1a;border:1px solid #2e6b2e;color:#6fcf97;
font-family:monospace;font-size:13px;border-radius:3px;cursor:pointer;">
Create API Key
</button>
<a id="vv-skip-link" href="#" onclick="vvWizardContinue(event)"
style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status2" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
</div>
<script>
let _vvWizardNext = '';
function vvShowStep2(redirect) {
_vvWizardNext = redirect;
document.getElementById('vv-setup-step2').style.display = 'block';
}
function vvWizardContinue(e) {
if (e) e.preventDefault();
window.location.href = _vvWizardNext || '?tab=scheduler';
}
function vvCreateApiKeyWizard(btn) {
const status = document.getElementById('vv-key-status2');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview; status.style.color = '#4a8';
btn.textContent = 'Continue →'; btn.disabled = false;
btn.onclick = vvWizardContinue;
const skip = document.getElementById('vv-skip-link');
if (skip) skip.style.display = 'none';
} else {
status.textContent = '✗ ' + (d.error ?? 'Failed'); status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
})
.catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
</script>
</div>
@@ -0,0 +1,463 @@
<?php
// First-run setup wizard.
// Step 1: Auto-detect environment + server identity form.
// Step 2: Auto-populate + guide + checklist.
// Handles three scenarios:
// standard — blank master.conf, this is HOST1
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
// conf-only — master.conf already here, just create local host.conf
$detectedHostname = vv_get_hostname();
$setupState = vv_setup_state_read();
$host1FromState = $setupState['host1_hostname'] ?? '';
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
$myHostId = vv_detect_host();
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2: guide + checklist */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-guide a { color: #556; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk <?php
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
elseif ($isHost2Flow) echo '— Partner Setup';
else echo '— First Run';
?></h1>
<div class="vv-sub"><?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.';
?></div>
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
<div id="vv-step1">
<!-- Detection banner — populated by JS on load -->
<div id="vv-detect-banner">
<div class="loading">Detecting environment…</div>
</div>
<?php if ($isConfOnlyFlow): ?>
<!-- master.conf already here, just create the local conf -->
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<?php elseif ($isHost2Flow): ?>
<!-- HOST2 pull flow -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:16px;">
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
<?php else: ?>
<!-- Standard first-run: HOST1 -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<?php endif; ?>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<!-- Auto-populate result -->
<div id="vv-populate-block">
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
⟳ Running auto-populate…
</div>
</div>
<!-- Quick-start guide -->
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> and fill in your credentials<br>
<span style="color:#444;">(Emby key, share paths, Discord webhook)</span></li>
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
<li>Arr keys, container names, and media paths auto-populate on next array start</li>
</ol>
</div>
<!-- API key -->
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<!-- Checklist -->
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
Go to Scheduler →
</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json())
.then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
// Pre-fill hostname if blank
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
})
.catch(() => {
document.getElementById('vv-detect-banner').innerHTML =
'<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle (standard flow only) ─────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Status helper ─────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
// ── Advance to step 2 ─────────────────────────────────────────────────────────
function vvShowStep2(redirect) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Action handlers ───────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
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(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'); });
}
function vvDoPull() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
const slot = document.getElementById('vv-slot')?.value || 'host2';
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
vvSetBtn('Pulling…', true);
const params = new URLSearchParams({
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
my_slot:slot, my_hostname:hostname
});
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(d.redirect || '?tab=scheduler'); }
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
}
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>
@@ -0,0 +1,467 @@
<?php
// First-run setup wizard.
// Step 1: Auto-detect environment + server identity form.
// Step 2: Auto-populate + guide + checklist.
// Handles three scenarios:
// standard — blank master.conf, this is HOST1
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
// conf-only — master.conf already here, just create local host.conf
$detectedHostname = vv_get_hostname();
$setupState = vv_setup_state_read();
$host1FromState = $setupState['host1_hostname'] ?? '';
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
$myHostId = vv_detect_host();
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2: guide + checklist */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-guide a { color: #556; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk <?php
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
elseif ($isHost2Flow) echo '— Partner Setup';
else echo '— First Run';
?></h1>
<div class="vv-sub"><?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.';
?></div>
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
<div id="vv-step1">
<!-- Detection banner — populated by JS on load -->
<div id="vv-detect-banner">
<div class="loading">Detecting environment…</div>
</div>
<?php if ($isConfOnlyFlow): ?>
<!-- master.conf already here, just create the local conf -->
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<?php elseif ($isHost2Flow): ?>
<!-- HOST2 pull flow -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:16px;">
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
<?php else: ?>
<!-- Standard first-run: HOST1 -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<?php endif; ?>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<!-- Auto-populate result -->
<div id="vv-populate-block">
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
⟳ Running auto-populate…
</div>
</div>
<!-- Quick-start guide -->
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
<span style="color:#444;">
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
</ol>
</div>
<!-- API key -->
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<!-- Checklist -->
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
Go to Scheduler →
</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json())
.then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
// Pre-fill hostname if blank
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
})
.catch(() => {
document.getElementById('vv-detect-banner').innerHTML =
'<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle (standard flow only) ─────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Status helper ─────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
// ── Advance to step 2 ─────────────────────────────────────────────────────────
function vvShowStep2(redirect) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Action handlers ───────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
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(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'); });
}
function vvDoPull() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
const slot = document.getElementById('vv-slot')?.value || 'host2';
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
vvSetBtn('Pulling…', true);
const params = new URLSearchParams({
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
my_slot:slot, my_hostname:hostname
});
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(d.redirect || '?tab=scheduler'); }
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
}
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>
@@ -0,0 +1,473 @@
<?php
// First-run setup wizard.
// Step 1: Auto-detect environment + server identity form.
// Step 2: Auto-populate + guide + checklist.
// Handles three scenarios:
// standard — blank master.conf, this is HOST1
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
// conf-only — master.conf already here, just create local host.conf
$detectedHostname = vv_get_hostname();
$setupState = vv_setup_state_read();
$host1FromState = $setupState['host1_hostname'] ?? '';
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
$myHostId = vv_detect_host();
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2: guide + checklist */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-guide a { color: #556; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk <?php
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
elseif ($isHost2Flow) echo '— Partner Setup';
else echo '— First Run';
?></h1>
<div class="vv-sub"><?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.';
?></div>
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
<div id="vv-step1">
<!-- Detection banner — populated by JS on load -->
<div id="vv-detect-banner">
<div class="loading">Detecting environment…</div>
</div>
<?php if ($isConfOnlyFlow): ?>
<!-- master.conf already here, just create the local conf -->
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<?php elseif ($isHost2Flow): ?>
<!-- HOST2 pull flow -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:16px;">
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
<?php else: ?>
<!-- Standard first-run: HOST1 -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<?php endif; ?>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<!-- Auto-populate result -->
<div id="vv-populate-block">
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
⟳ Running auto-populate…
</div>
</div>
<!-- Quick-start guide -->
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
<span style="color:#444;">
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
</ol>
</div>
<!-- API key -->
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<!-- Checklist -->
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
Go to Scheduler →
</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json())
.then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
// Pre-fill hostname if blank
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
})
.catch(() => {
document.getElementById('vv-detect-banner').innerHTML =
'<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle (standard flow only) ─────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Status helper ─────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
// ── Advance to step 2 ─────────────────────────────────────────────────────────
function vvShowStep2(redirect, apiKey) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
}
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Action handlers ───────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
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(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'); });
}
function vvDoPull() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
const slot = document.getElementById('vv-slot')?.value || 'host2';
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
vvSetBtn('Pulling…', true);
const params = new URLSearchParams({
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
my_slot:slot, my_hostname:hostname
});
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(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'); });
}
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') ?>), 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'); });
}
</script>
@@ -0,0 +1,529 @@
<?php
// First-run setup wizard.
// Step 1: Auto-detect environment + server identity form.
// Step 2: Auto-populate + guide + checklist.
// Handles three scenarios:
// standard — blank master.conf, this is HOST1
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
// conf-only — master.conf already here, just create local host.conf
$detectedHostname = vv_get_hostname();
$setupState = vv_setup_state_read();
$host1FromState = $setupState['host1_hostname'] ?? '';
$_master = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
$myHostId = vv_detect_host();
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2: guide + checklist */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-guide a { color: #556; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk <?php
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
elseif ($isHost2Flow) echo '— Partner Setup';
else echo '— First Run';
?></h1>
<div class="vv-sub"><?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.';
?></div>
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
<div id="vv-step1">
<!-- Detection banner — populated by JS on load -->
<div id="vv-detect-banner">
<div class="loading">Detecting environment…</div>
</div>
<?php if ($isConfOnlyFlow): ?>
<!-- master.conf already here, just create the local conf -->
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<?php elseif ($isHost2Flow): ?>
<!-- HOST2 pull flow -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:14px;">
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span>
</div>
<!-- SSH key generation -->
<div id="vv-ssh-block" style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:12px 14px;margin-bottom:16px;">
<div style="font-size:11px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;">SSH key</div>
<div id="vv-ssh-state" style="font-size:12px;color:#666;margin-bottom:10px;">Required to pull config from HOST1</div>
<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;">
<div style="font-size:11px;color:#555;margin-bottom:5px;">Add to HOST1's authorized_keys — paste this on HOST1:</div>
<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>
</div>
<div style="font-size:10px;color:#444;margin-top:5px;">Once added, the pull button below will work.</div>
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()" disabled>Pull configuration from HOST1 →</button>
<?php else: ?>
<!-- Standard first-run: HOST1 -->
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<?php endif; ?>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<!-- Auto-populate result -->
<div id="vv-populate-block">
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
⟳ Running auto-populate…
</div>
</div>
<!-- Quick-start guide -->
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
<span style="color:#444;">
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
</ol>
</div>
<!-- API key -->
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<!-- Checklist -->
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
Go to Scheduler →
</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json())
.then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
// Pre-fill hostname if blank
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
})
.catch(() => {
document.getElementById('vv-detect-banner').innerHTML =
'<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle (standard flow only) ─────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Status helper ─────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
// ── Advance to step 2 ─────────────────────────────────────────────────────────
function vvShowStep2(redirect, apiKey) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
}
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── 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) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Action handlers ───────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
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(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'); });
}
function vvDoPull() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
const slot = document.getElementById('vv-slot')?.value || 'host2';
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
vvSetBtn('Pulling…', true);
const params = new URLSearchParams({
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
my_slot:slot, my_hostname:hostname
});
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(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'); });
}
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') ?>), 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'); });
}
</script>
@@ -0,0 +1,384 @@
<?php
// First-run setup wizard — uniform flow for all hosts.
// Step 1: auto-detect environment + server identity form.
// Step 2: auto-populate + guide + checklist.
// master.conf pull (for partner servers) lives in the checklist, not here.
$detectedHostname = vv_get_hostname();
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
<style>
#vv-setup {
max-width: 580px; margin: 40px auto 0;
background: #141414; border: 1px solid #2a2a2a;
border-radius: 6px; padding: 36px 40px 40px;
font-family: monospace; color: #ccc;
}
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
.vv-field { margin-bottom: 18px; }
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
.vv-field input[type=text],
.vv-field select {
width: 100%; box-sizing: border-box; background: #0d0d0d;
border: 1px solid #333; color: #ddd; padding: 7px 10px;
border-radius: 3px; font-family: monospace; font-size: 13px;
}
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
.vv-cond { display: none; }
.vv-cond.show { display: block; }
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
cursor: pointer; letter-spacing: .03em; }
.vv-btn:hover { border-color: #666; color: #eee; }
.vv-btn:disabled { opacity: .4; cursor: default; }
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
#vv-status.ok { color: #4a8; }
#vv-status.err { color: #a44; }
/* Detection banner */
#vv-detect-banner {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8; color: #666;
}
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
#vv-detect-banner .vv-det-val { color: #999; }
#vv-detect-banner .loading { color: #444; font-style: italic; }
/* Step 2 */
#vv-step2 { display: none; }
.vv-guide {
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
}
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
.vv-guide li { margin-bottom: 3px; }
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
.vv-cl-item:last-child { border-bottom: none; }
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
.vv-cl-body { flex: 1; }
.vv-cl-label { color: #bbb; }
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
.vv-cl-act { margin-top: 5px; }
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
.vv-cl-err { font-size: 11px; color: #a44; margin-top: 4px; }
</style>
<div id="vv-setup">
<h1>⬡ Varaverk — First Run</h1>
<div class="vv-sub">Set up this server before the plugin can start.</div>
<!-- ── Step 1: Detection + identity ──────────────────────────────────────── -->
<div id="vv-step1">
<div id="vv-detect-banner"><div class="loading">Detecting environment…</div></div>
<div class="vv-field">
<label>This server's hostname</label>
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
</div>
<hr class="vv-hr">
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
<div class="vv-role-row">
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
</div>
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
</div>
</div>
<div class="vv-cond" id="vv-cond-primary">
<div class="vv-field">
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vv-cond" id="vv-cond-partner">
<div class="vv-field">
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
</div>
<div class="vv-field">
<label>Your slot</label>
<select id="vv-partner-slot">
<option value="host2">HOST2</option>
<option value="host3">HOST3</option>
<option value="host4">HOST4</option>
</select>
</div>
<div style="font-size:11px;color:#555;margin-bottom:4px;">
SSH key and master.conf pull are handled automatically after save.
</div>
</div>
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<div id="vv-status"></div>
</div>
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
<div id="vv-step2">
<hr class="vv-hr">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">⟳ Running auto-populate…</div>
<div class="vv-guide">
<strong style="color:#888;">Quick start</strong>
<ol>
<li>Create your Unraid API key below — needed for live monitor stats</li>
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
<span style="color:#444;">
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
Everything else was auto-populated or has working defaults
</span></li>
<li>If partnering: the checklist below will guide you through pulling HOST1's config and running onboard</li>
</ol>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
Create API Key
</button>
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
<hr class="vv-hr">
<div class="vv-cl-title">Setup checklist</div>
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
<div style="margin-top:18px;text-align:right;">
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">Go to Scheduler →</a>
</div>
</div>
</div>
<script>
let _vvRedirect = '?tab=scheduler';
// ── Detection banner ──────────────────────────────────────────────────────────
(function() {
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
.then(r => r.json()).then(d => {
const b = document.getElementById('vv-detect-banner');
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
const modeLabel = d.mode === 'internal'
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
: '<span style="color:#a84">flash mode (USB boot)</span>';
b.innerHTML =
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver||'') + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
const hf = document.getElementById('vv-hostname');
if (hf && !hf.value.trim()) hf.value = d.hostname;
}).catch(() => {
document.getElementById('vv-detect-banner').innerHTML = '<span style="color:#444">Detection unavailable</span>';
});
})();
// ── Role toggle ───────────────────────────────────────────────────────────────
let vvRole = 'primary';
function vvSetRole(role) {
vvRole = role;
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function vvSetStatus(msg, cls) {
const s = document.getElementById('vv-status');
s.textContent = msg; s.className = cls || '';
}
function vvSetBtn(text, disabled) {
const b = document.getElementById('vv-main-btn');
if (b) { b.textContent = text; b.disabled = disabled; }
}
function vvGoScheduler(e) {
if (e) e.preventDefault();
window.location.href = _vvRedirect || '?tab=scheduler';
}
// ── Step 2 ────────────────────────────────────────────────────────────────────
function vvShowStep2(redirect, apiKey) {
_vvRedirect = redirect || '?tab=scheduler';
document.getElementById('vv-step1').style.display = 'none';
document.getElementById('vv-step2').style.display = 'block';
if (apiKey && apiKey.ok) {
const btn = document.getElementById('vv-key-btn');
const status = document.getElementById('vv-key-status');
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
}
vvRunPopulate();
vvLoadChecklist();
}
// ── Populate ──────────────────────────────────────────────────────────────────
function vvRunPopulate() {
const el = document.getElementById('vv-populate-status');
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(r => r.json()).then(d => {
if (d.ok) {
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
el.textContent = found.length
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
: '✓ Auto-populate ran — arr keys will fill once services are running';
el.style.color = '#4a8';
} else {
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
el.style.color = '#555';
}
vvLoadChecklist();
}).catch(() => {
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
el.style.color = '#555';
});
}
// ── Checklist ─────────────────────────────────────────────────────────────────
const vvActionLabels = {
create_key: 'Create API key',
ssh_setup: 'SSH guide →',
run_populate: 'Run now',
pull_master: 'Pull from HOST1',
onboard: 'Partnership tab →',
};
const vvActionHref = {
ssh_setup: '?tab=partnership',
onboard: '?tab=partnership',
};
function vvLoadChecklist() {
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
.then(r => r.json()).then(d => {
const el = document.getElementById('vv-checklist');
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
el.innerHTML = d.items.map(item => {
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
let act = '';
if (item.action) {
const lbl = vvActionLabels[item.action] || item.action;
const href = vvActionHref[item.action];
if (href) {
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
} else if (item.action === 'create_key') {
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
} else if (item.action === 'run_populate') {
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
} else if (item.action === 'pull_master') {
act = `<div class="vv-cl-act"><button onclick="vvPullMaster(this)">${lbl}</button><div id="vv-pull-err" class="vv-cl-err"></div></div>`;
}
}
return `<div class="vv-cl-item">
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
<div class="vv-cl-body">
<div class="vv-cl-label">${item.label}</div>
<div class="vv-cl-detail">${item.detail || ''}</div>
${act}
</div>
</div>`;
}).join('');
}).catch(() => {});
}
function vvRunPopulateBtn(btn) {
btn.disabled = true; btn.textContent = '…';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'populate'})
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
function vvPullMaster(btn) {
btn.disabled = true; btn.textContent = '⟳ Pulling…';
const errEl = document.getElementById('vv-pull-err');
if (errEl) errEl.textContent = '';
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action: 'pull'})
}).then(r => r.json()).then(d => {
if (d.ok) {
btn.textContent = '✓ Done';
setTimeout(vvLoadChecklist, 600);
} else {
if (errEl) errEl.textContent = d.error || 'Pull failed';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
}
// ── API key ───────────────────────────────────────────────────────────────────
function vvCreateKey(btn) {
const status = document.getElementById('vv-key-status');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview;
status.style.color = '#4a8';
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
vvLoadChecklist();
} else {
status.textContent = '✗ ' + (d.error || 'Failed');
status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
}).catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
// ── Save ──────────────────────────────────────────────────────────────────────
function vvDoSave() {
const hostname = document.getElementById('vv-hostname')?.value.trim();
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
let host1 = '', host2 = '', mySlot = 'host1';
if (vvRole === 'primary') {
host1 = hostname;
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
mySlot = 'host1';
} else {
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
host1 = primary;
if (mySlot === 'host2') host2 = hostname;
}
vvSetBtn('Saving…', true);
fetch('/plugins/varaverk/api/setup.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname})
}).then(r => r.json()).then(d => {
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'); });
}
</script>
@@ -0,0 +1,302 @@
#!/bin/bash
# ==============================================================================================
# ============================= SMART Extended Self-Test =======================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs a SMART extended (long) self-test on all drives (or a specific drive),
# waits for completion, and reports results. Extended tests do a full read-scan
# of every sector — catches bad sectors that the short test skips. Monthly cadence
# via the monthly maintenance orchestrator.
#
# NVMe drives are included — smartctl supports NVMe self-test via the same
# -t long interface (NVMe 1.3+ specification).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Starts long self-tests on all drives sequentially (NOT in parallel) to avoid
# saturating I/O. One drive at a time: start, wait for completion, move to next.
# Sequential order keeps test duration predictable and avoids thermal stacking.
#
# Drives already running a self-test are skipped. If a previous test did not
# finish (interrupted mid-way), it is reported and the drive is re-tested.
#
# Each test is polled every 60 seconds. Typical durations:
# HDD 24 TB : 60120 min
# HDD 812 TB: 90180 min
# SSD any : 515 min
# NVMe any : 510 min
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent test runs — one long-test pass per server.
#
# Sequential Execution
# Drives tested one at a time. Parallel long-tests thrash I/O and inflate temps.
#
# SIGTERM Trap
# Poll loop exits cleanly on signal. Tests continue running in drive firmware.
#
# Tool Validation
# platform_require_cmd confirms smartctl is present before use.
#
# Silent When Clean
# Only failures and warnings produce notifications.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_SMART_IGNORE_DRIVES
# Drives skipped in SMART testing. Aliased by detect_hosts() →
# SMART_IGNORE_DRIVES. Typically includes the boot USB flash drive.
#
# master.conf
#
# SMART_TEMP_WARN / SMART_TEMP_CRIT
# Fallback thresholds used for post-test temperature reporting if
# dynamix.cfg is not found.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# smart_long_test.sh
# Run extended self-test on all drives not in SMART_IGNORE_DRIVES.
#
# smart_long_test.sh /dev/sda
# Run extended self-test on a specific drive. Bypasses the ignore list.
#
# smart_long_test.sh --status
# Show last self-test result for all drives and exit.
#
# smart_long_test.sh --dry-run
# Show which drives would be tested. No tests started.
#
# smart_long_test.sh --log
# Verbose progress output during polling.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
TARGET_DRIVE="${PARSED_ARGS[0]:-}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"$(command -v smartctl 2>/dev/null || echo /usr/bin/smartctl)" \
"--version" "smartmontools" \
"smartctl" || {
error "smartctl not found — install smartmontools"
notify "SMART long test failed on $(hostname) — smartmontools not installed" \
"SMART Long Test" "warning"
exit 1
}
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
detect_hosts
get_unraid_temp_thresholds
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ignore: ${SMART_IGNORE_DRIVES[*]:-none}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no tests will be started"
trap 'warn "SMART long test script interrupted — tests continue in drive firmware"; exit 0' \
SIGTERM SIGINT
# ==============================================================================================
# ── Build drive list ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_build_drive_list() {
local drives=()
if [[ -n "$TARGET_DRIVE" ]]; then
if [[ ! -e "$TARGET_DRIVE" ]]; then
error "Drive not found: $TARGET_DRIVE"
exit 1
fi
drives=("$TARGET_DRIVE")
else
for drive in /dev/sd? /dev/nvme?; do
[[ ! -e "$drive" ]] && continue
local drive_name
drive_name=$(basename "$drive")
local ignored=false
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
done
[[ "$ignored" == true ]] && { log "Skipping $drive_name (SMART_IGNORE_DRIVES)"; continue; }
if ! smartctl -i "$drive" 2>/dev/null | grep -q "SMART support is: Enabled"; then
log "Skipping $drive_name — SMART not enabled"
continue
fi
drives+=("$drive")
done
fi
echo "${drives[@]}"
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY SMART LONG TEST STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo ""
for drive in /dev/sd? /dev/nvme?; do
[[ ! -e "$drive" ]] && continue
drive_name=$(basename "$drive")
local_result=$(smartctl -l selftest "$drive" 2>/dev/null | \
grep -m1 "Extended" | awk '{print $NF}')
ignored=false
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
done
if [[ "$ignored" == true ]]; then
echo " $ICON_WARN $drive_name — ignored"
else
echo " $ICON_SMART $drive_name — last extended: ${local_result:-no result}"
fi
done
echo ""
echo " Ignored: ${SMART_IGNORE_DRIVES[*]:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Run Extended Tests — Sequential ━━━
# ==============================================================================================
read -r -a DRIVES_TO_TEST <<< "$(_build_drive_list)"
if [[ ${#DRIVES_TO_TEST[@]} -eq 0 ]]; then
warn "No drives to test — all may be on ignore list or SMART not enabled"
exit 0
fi
echo ""
echo "━━━ $ICON_SMART SMART Extended Self-Test — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST ${#DRIVES_TO_TEST[@]} drive(s) to test — running sequentially"
echo ""
WINDOW_START=$(date +%s)
DRIVES_OK=()
DRIVES_FAIL=()
DRIVES_SKIP=()
for drive in "${DRIVES_TO_TEST[@]}"; do
drive_name=$(basename "$drive")
echo "━━━ $ICON_SMART $drive_name ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run extended self-test on $drive_name"
echo ""
continue
fi
# Check if test already in progress
if smartctl -l selftest "$drive" 2>/dev/null | grep -q "Self-test routine in progress"; then
warn "$drive_name — self-test already in progress — skipping"
DRIVES_SKIP+=("$drive_name")
echo ""
continue
fi
# Start extended test
if ! smartctl -t long "$drive" >/dev/null 2>&1; then
error "$drive_name — failed to start extended self-test"
DRIVES_FAIL+=("$drive_name")
echo ""
continue
fi
echo " Extended test started on $drive_name"
# Poll until complete
DRIVE_START=$(date +%s)
while true; do
sleep 60
STATUS_LINE=$(smartctl -l selftest "$drive" 2>/dev/null | grep -m1 "Extended")
if echo "$STATUS_LINE" | grep -q "Self-test routine in progress"; then
PCT=$(echo "$STATUS_LINE" | grep -oE "[0-9]+% of test remaining" || true)
log "$drive_name — test in progress ${PCT:+($PCT)}"
continue
fi
# Test finished — determine result
DRIVE_ELAPSED=$(format_duration $(( $(date +%s) - DRIVE_START )))
if echo "$STATUS_LINE" | grep -iq "Completed without error\|Successful"; then
echo " $ICON_DONE $drive_name — extended test passed ✅ ($DRIVE_ELAPSED)"
DRIVES_OK+=("$drive_name")
elif echo "$STATUS_LINE" | grep -iq "Failed\|failed"; then
FAIL_DETAIL=$(echo "$STATUS_LINE" | awk '{print $NF}')
error "$drive_name — extended test FAILED — $FAIL_DETAIL ($DRIVE_ELAPSED)"
DRIVES_FAIL+=("$drive_name")
elif [[ -z "$STATUS_LINE" ]]; then
warn "$drive_name — no test result found — may not support extended test"
DRIVES_SKIP+=("$drive_name")
else
warn "$drive_name — unexpected result: $STATUS_LINE ($DRIVE_ELAPSED)"
DRIVES_SKIP+=("$drive_name")
fi
echo ""
break
done
done
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY SMART LONG TEST SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo "$ICON_SMART Drives: ${#DRIVES_TO_TEST[@]} tested"
[[ ${#DRIVES_OK[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${DRIVES_OK[*]}"
[[ ${#DRIVES_FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${DRIVES_FAIL[*]}"
[[ ${#DRIVES_SKIP[@]} -gt 0 ]] && echo "$ICON_WARN Skipped: ${DRIVES_SKIP[*]}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no tests started"
elif [[ ${#DRIVES_FAIL[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: FAILURES — ${DRIVES_FAIL[*]}"
notify "SMART long test failures on $(hostname) ($MY_ID) — drives: ${DRIVES_FAIL[*]}" \
"SMART Long Test" "warning"
elif [[ ${#DRIVES_OK[@]} -gt 0 ]]; then
echo "$ICON_DONE Status: all ${#DRIVES_OK[@]} drive(s) passed ✅"
else
warn "Status: no results — all drives skipped"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#DRIVES_FAIL[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,159 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
exit;
}
$action = trim($_POST['action'] ?? 'save');
// ── 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) {
$hostname = $myHostname ?: vv_get_hostname();
$sshKeyPath = $sshKey;
$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);
vv_write_conf_raw($confFile, $conf);
}
}
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
'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';
$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);
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]);
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);
@@ -0,0 +1,213 @@
<?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);
}
}
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
'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]);
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);
@@ -0,0 +1,220 @@
<?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',
]);
@@ -0,0 +1,241 @@
<?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;
}
// ── 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') {
$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',
]);
@@ -0,0 +1,251 @@
<?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;
}
// ── 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') {
$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;
}
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
// ── 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);
}
}
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'] ?? '');
$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-generate SSH keypair (local only — remote copy happens during onboarding)
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);
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'api_key' => $apiKeyResult,
'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);
@@ -0,0 +1,259 @@
<?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;
}
// ── 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') {
$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;
}
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
// ── Pull master.conf from HOST1 via SSH (wizard or checklist) ────────────────────────────────
if ($action === 'pull') {
$mySlot = trim($_POST['my_slot'] ?? '') ?: strtolower(vv_detect_host());
$myHostname = trim($_POST['my_hostname'] ?? '') ?: vv_get_hostname();
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
if (!$host1Hostname) {
$masterRaw = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $masterRaw, $_mh);
$host1Hostname = trim($_mh[1] ?? '');
}
if (!$host1Hostname) {
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname not set — fill in master.conf first']);
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);
}
}
if (file_exists($sshScript)) {
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
$state = vv_setup_state_read();
$state['master_conf_pulled'] = 'true';
vv_setup_state_write($state);
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-generate SSH keypair (local only — remote copy happens during onboarding)
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);
echo json_encode([
'ok' => true,
'host_id' => $hostId,
'api_key' => $apiKeyResult,
'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);
@@ -0,0 +1,143 @@
<style>
/* ── Cert page ───────────────────────────────────────────────── */
.vv-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px; }
.vv-cert-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 14px; }
.vv-cert-domain { font-size:12px;font-weight:700;color:#aaa;word-break:break-all;margin-bottom:8px; }
.vv-cert-days { font-size:28px;font-weight:700;line-height:1;margin-bottom:2px; }
.vv-cert-sub { font-size:10px;color:#444;margin-bottom:6px; }
.vv-cert-pill { display:inline-block;font-size:9px;padding:2px 8px;border-radius:3px;font-weight:700; }
.vv-cert-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
.vv-cert-pill.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
.vv-cert-pill.crit { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-cert-pill.fail { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-cert-pill.unkn { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; }
.vv-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
.vv-cert-run-btn { background:#1a1a1a;border:1px solid #333;color:#888;font-size:10px;
padding:4px 12px;border-radius:3px;cursor:pointer;transition:border-color .15s; }
.vv-cert-run-btn:hover { border-color:#555;color:#aaa; }
.vv-cert-run-btn:disabled { opacity:.4;cursor:default; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Cert Monitor</span>
<div style="display:flex;align-items:center;gap:10px;">
<span id="vv-cert-ts" style="font-size:11px;color:#3a3a3a;"></span>
<button class="vv-cert-run-btn" id="vv-cert-run" onclick="vvCertRunNow()">Run now</button>
</div>
</div>
<div id="vv-cert-grid" class="vv-cert-grid">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">Loading…</div>
</div>
<div id="vv-cert-log" style="display:none;margin-top:12px;background:#0d0d0d;border:1px solid #1e1e1e;
border-radius:4px;padding:10px 12px;font-size:10px;color:#555;font-family:monospace;
max-height:160px;overflow-y:auto;white-space:pre-wrap;"></div>
<div id="vv-cert-cfg" style="margin-top:12px;font-size:10px;color:#3a3a3a;padding:0 2px;"></div>
<script>
(function() {
function _pillCls(s) {
return ({OK:'ok', WARN:'warn', CRIT:'crit', FAIL:'fail'})[s] || 'unkn';
}
function _pillTxt(s) {
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
}
function _dayColor(days, warnDays, critDays) {
if (days == null) return '#3a3a3a';
if (days <= critDays) return '#ef5350';
if (days <= warnDays) return '#ffb74d';
return '#4caf50';
}
function _barColor(days, warnDays, critDays) {
if (days == null) return '#1e1e1e';
if (days <= critDays) return '#ef5350';
if (days <= warnDays) return '#ffb74d';
return '#4caf50';
}
function _rel(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
if (d < 604800)return Math.floor(d/86400) + 'd ago';
return Math.floor(d/604800) + 'w ago';
}
function _render(data) {
const grid = document.getElementById('vv-cert-grid');
const ts = document.getElementById('vv-cert-ts');
const cfg = document.getElementById('vv-cert-cfg');
const warnDays = data.warn_days || 30;
const critDays = data.crit_days || 7;
const checked = data.checked_at;
ts.textContent = checked ? 'Last checked: ' + _rel(checked) : '';
cfg.textContent = `Warn: ${warnDays}d · Crit: ${critDays}d`;
const domains = data.domains || [];
if (!domains.length) {
grid.innerHTML = `<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to your host.conf</div>`;
return;
}
grid.innerHTML = domains.map(d => {
const s = d.status || 'UNKN';
const days = d.days;
const barPct = (days != null && days >= 0) ? Math.min(Math.round(days / 90 * 100), 100) : 0;
const dayCol = _dayColor(days, warnDays, critDays);
const barCol = _barColor(days, warnDays, critDays);
const pillCls = _pillCls(s);
const pillTxt = _pillTxt(s);
const daysStr = days != null ? String(days) : '—';
const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : 'Unknown expiry');
return `<div class="vv-cert-card">
<div class="vv-cert-domain">${d.domain}</div>
<div class="vv-cert-days" style="color:${dayCol};">${daysStr}</div>
<div class="vv-cert-sub">${days != null ? 'days remaining' : ''}</div>
<span class="vv-cert-pill ${pillCls}">${pillTxt}</span>
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${expStr}</div>
${days != null ? `<div class="vv-cert-bar"><div class="vv-cert-fill" style="width:${barPct}%;background:${barCol};"></div></div>` : ''}
</div>`;
}).join('');
}
function vvCertLoad() {
fetch('/plugins/varaverk/api/cert.php')
.then(r => r.json()).then(d => { if (d.ok) _render(d); })
.catch(() => {});
}
vvCertLoad();
window.vvCertRunNow = function() {
const btn = document.getElementById('vv-cert-run');
const log = document.getElementById('vv-cert-log');
btn.disabled = true; btn.textContent = 'Checking…';
log.style.display = 'none'; log.textContent = '';
const fd = new FormData();
fd.append('action', 'run');
fetch('/plugins/varaverk/api/cert.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Run now';
if (d.data) _render(d.data);
if (d.output && d.output.length) {
log.style.display = 'block';
// Strip ANSI escape codes
log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
};
})();
</script>
@@ -0,0 +1,142 @@
#!/bin/bash
# ==============================================================================================
# ============================= Remote Arr Cache Writer ========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# SSHes to each remote partner host, calls vv_arrs_local_node() on their PHP
# stack, and caches the result locally in /tmp/vv_cache/arrs_remote_hostN.json.
#
# The arrs page reads these files for instant initial load without hitting the
# remote arr APIs on every page view. This script runs every 2 hours so remote
# library counts stay reasonably current without hammering the network.
#
# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
#
# ==============================================================================================
# USAGE
# ==============================================================================================
#
# remote_arr_cache_writer.sh — refresh all remote hosts
# remote_arr_cache_writer.sh --host=HOST2 — refresh one host only
# remote_arr_cache_writer.sh --dry-run — show what would happen
# remote_arr_cache_writer.sh --log — verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
acquire_lock
detect_hosts
# Parse --host= from raw args
TARGET_HOST=""
for arg in "$@"; do
case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
done
mkdir -p /tmp/vv_cache
log "$ICON_GEAR Config: target=${TARGET_HOST:-all hosts} ssh-key=${SSH_KEY}"
FETCH_OK=0
FETCH_FAIL=0
FETCH_SKIP=0
echo ""
echo "━━━ $ICON_SYNC Remote Arr Cache Writer ━━━"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
[[ -n "$TARGET_HOST" ]] && echo " Target: $TARGET_HOST"
echo ""
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$host_var" == "$MY_ID" ]] && continue
hostname="${!host_var:-}"
[[ -z "$hostname" ]] && continue
# If targeting a specific host, skip others
if [[ -n "$TARGET_HOST" ]]; then
[[ "${host_var,,}" != "${TARGET_HOST,,}" && "$host_var" != "$TARGET_HOST" ]] && continue
fi
host_id="${host_var,,}" # host1, host2, …
cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
echo " $host_var ($hostname)…"
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would SSH to $hostname and cache arr data"
(( FETCH_SKIP++ ))
continue
fi
# Resolve Tailscale IP
REMOTE_IP=$(resolve_tailscale_ip "$hostname")
if [[ -z "$REMOTE_IP" ]]; then
warn " $host_var: cannot resolve Tailscale IP for $hostname — skipping"
(( FETCH_FAIL++ ))
continue
fi
log " $host_var: resolved $hostname → $REMOTE_IP"
# Single SSH connection — fetch arrs + monitor stats together
RESULT=$(ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
"root@${REMOTE_IP}" \
"php -r \"
require_once '/usr/local/emhttp/plugins/varaverk/include/arrs.php';
require_once '/usr/local/emhttp/plugins/varaverk/include/unraid_api.php';
echo json_encode([
'arrs' => vv_arrs_local_node(),
'monitor' => vv_local_host_stats(),
]);
\"" 2>/dev/null)
if [[ -z "$RESULT" ]]; then
warn " $host_var: empty SSH response — skipping"
(( FETCH_FAIL++ ))
continue
fi
# Validate combined JSON
if ! echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
exit(!is_array(\$d) || !isset(\$d['arrs'], \$d['monitor']) ? 1 : 0);
" 2>/dev/null; then
warn " $host_var: invalid JSON — skipping"
log " Response: ${RESULT:0:200}"
(( FETCH_FAIL++ ))
continue
fi
# Save arr cache
echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
file_put_contents('$cache_file', json_encode(\$d['arrs']));
" 2>/dev/null
# Save monitor cache
MONITOR_CACHE="/tmp/vv_cache/monitor_remote_${host_id}.json"
echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
file_put_contents('$MONITOR_CACHE', json_encode(\$d['monitor']));
" 2>/dev/null
CACHED_TYPES=$(echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
echo implode(', ', array_column(\$d['arrs']['arrs'] ?? [], 'type'));
" 2>/dev/null)
echo " $host_var: arrs [${CACHED_TYPES:-none}] + monitor stats cached ✅"
(( FETCH_OK++ ))
done
echo ""
echo "━━━━━ $ICON_SUMMARY Cache Writer Summary ━━━━━"
echo " $FETCH_OK updated · $FETCH_FAIL failed · $FETCH_SKIP skipped"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,142 @@
#!/bin/bash
# ==============================================================================================
# ============================= Remote Arr Cache Writer ========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# SSHes to each remote partner host, calls vv_arrs_local_node() on their PHP
# stack, and caches the result locally in /tmp/vv_cache/arrs_remote_hostN.json.
#
# The arrs page reads these files for instant initial load without hitting the
# remote arr APIs on every page view. This script runs every 2 hours so remote
# library counts stay reasonably current without hammering the network.
#
# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
#
# ==============================================================================================
# USAGE
# ==============================================================================================
#
# remote_arr_cache_writer.sh — refresh all remote hosts
# remote_arr_cache_writer.sh --host=HOST2 — refresh one host only
# remote_arr_cache_writer.sh --dry-run — show what would happen
# remote_arr_cache_writer.sh --log — verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
detect_hosts
# Parse --host= from raw args
TARGET_HOST=""
for arg in "$@"; do
case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
done
mkdir -p /tmp/vv_cache
log "$ICON_GEAR Config: target=${TARGET_HOST:-all hosts} ssh-key=${SSH_KEY}"
FETCH_OK=0
FETCH_FAIL=0
FETCH_SKIP=0
echo ""
echo "━━━ $ICON_SYNC Remote Arr Cache Writer ━━━"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
[[ -n "$TARGET_HOST" ]] && echo " Target: $TARGET_HOST"
echo ""
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$host_var" == "$MY_ID" ]] && continue
hostname="${!host_var:-}"
[[ -z "$hostname" ]] && continue
# If targeting a specific host, skip others
if [[ -n "$TARGET_HOST" ]]; then
[[ "${host_var,,}" != "${TARGET_HOST,,}" && "$host_var" != "$TARGET_HOST" ]] && continue
fi
host_id="${host_var,,}" # host1, host2, …
cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
echo " $host_var ($hostname)…"
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would SSH to $hostname and cache arr data"
(( FETCH_SKIP++ ))
continue
fi
# Resolve Tailscale IP
REMOTE_IP=$(resolve_tailscale_ip "$hostname")
if [[ -z "$REMOTE_IP" ]]; then
warn " $host_var: cannot resolve Tailscale IP for $hostname — skipping"
(( FETCH_FAIL++ ))
continue
fi
log " $host_var: resolved $hostname → $REMOTE_IP"
# Single SSH connection — fetch arrs + monitor stats together
RESULT=$(ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
"root@${REMOTE_IP}" \
"php -r \"
require_once '/usr/local/emhttp/plugins/varaverk/include/arrs.php';
require_once '/usr/local/emhttp/plugins/varaverk/include/unraid_api.php';
echo json_encode([
'arrs' => vv_arrs_local_node(),
'monitor' => vv_local_host_stats(),
]);
\"" 2>/dev/null)
if [[ -z "$RESULT" ]]; then
warn " $host_var: empty SSH response — skipping"
(( FETCH_FAIL++ ))
continue
fi
# Validate combined JSON
if ! echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
exit(!is_array(\$d) || !isset(\$d['arrs'], \$d['monitor']) ? 1 : 0);
" 2>/dev/null; then
warn " $host_var: invalid JSON — skipping"
log " Response: ${RESULT:0:200}"
(( FETCH_FAIL++ ))
continue
fi
# Save arr cache
echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
file_put_contents('$cache_file', json_encode(\$d['arrs']));
" 2>/dev/null
# Save monitor cache
MONITOR_CACHE="/tmp/vv_cache/monitor_remote_${host_id}.json"
echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
file_put_contents('$MONITOR_CACHE', json_encode(\$d['monitor']));
" 2>/dev/null
CACHED_TYPES=$(echo "$RESULT" | php -r "
\$d = json_decode(file_get_contents('php://stdin'), true);
echo implode(', ', array_column(\$d['arrs']['arrs'] ?? [], 'type'));
" 2>/dev/null)
echo " $host_var: arrs [${CACHED_TYPES:-none}] + monitor stats cached ✅"
(( FETCH_OK++ ))
done
echo ""
echo "━━━━━ $ICON_SUMMARY Cache Writer Summary ━━━━━"
echo " $FETCH_OK updated · $FETCH_FAIL failed · $FETCH_SKIP skipped"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,368 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = trim($m[2][$i]);
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}
@@ -0,0 +1,367 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = trim($m[2][$i]);
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}
@@ -0,0 +1,399 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = trim($m[2][$i]);
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
function vv_auto_create_api_key(string $hostId, string $confFile): array {
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
if (!$output) {
return ['ok' => false, 'error' => 'unraid-api returned no output'];
}
$data = json_decode(trim($output), true);
$key = $data['key'] ?? null;
if (!$key) {
return ['ok' => false, 'error' => 'No key in response'];
}
$raw = vv_read_conf_raw($confFile);
if ($raw === '') {
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
}
if (!str_contains($raw, $varName)) {
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
if (str_contains($raw, $anchor)) {
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
'$1' . "\n " . $varName . '=""', $raw, 1);
break;
}
}
}
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
'${1}"' . $key . '"', $raw);
vv_write_conf_raw($confFile, $raw);
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}
@@ -0,0 +1,399 @@
<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
define('VV_CACHE_DIR', '/tmp/vv_cache');
// Read the setup state file into a key=>value array.
function vv_setup_state_read(): array {
$out = [];
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
if ($k !== '') $out[$k] = $v;
}
return $out;
}
// Write the setup state file (creates or overwrites).
function vv_setup_state_write(array $data): void {
$content = '';
foreach ($data as $k => $v) $content .= "$k=$v\n";
file_put_contents(VV_SETUP_STATE_FILE, $content);
}
// Push the setup state file to all remote hosts via scp.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
function vv_push_setup_state(): void {
if (!file_exists(VV_SETUP_STATE_FILE)) return;
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return;
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
if (!$hostname) continue;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) continue;
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
// Ensure the target dir exists (it always should on Unraid, but be safe)
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
exec('scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
}
}
// Push master.conf to all remote hosts via scp after a local save.
// Returns one result entry per remote found in master.conf.
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
function vv_push_master_conf(): array {
$myHostId = vv_detect_host();
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
if (!$sshKey || !file_exists($sshKey)) return [];
$localPath = CONF_DIR . '/master.conf';
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$results = [];
$seen = [];
foreach ($m[1] as $i => $hostKey) {
$hostId = strtolower($hostKey);
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
$seen[$hostId] = true;
$hostname = trim($m[2][$i]);
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
continue;
}
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
// Configurations/ exists, and master.conf is already present.
// Any missing piece means the remote isn't ready — skip rather than push blind.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$probe = trim(shell_exec(
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
. ' && test -d \"${sd}/Configurations\"'
. ' && test -f \"${sd}/Configurations/master.conf\"'
. ' && echo \"$sd\""'
) ?: '');
if ($probe === '') {
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
continue;
}
$remoteConf = rtrim($probe, '/') . '/Configurations';
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
exec($cmd, $out, $rc);
$results[] = [
'host' => $hostKey,
'ok' => $rc === 0,
'error' => $rc !== 0 ? implode('; ', $out) : '',
];
}
return $results;
}
function vv_get_hostname(): string {
return trim(shell_exec('hostname -s') ?: '');
}
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
function vv_resolve_tailscale_ip(string $hostname): string {
$h = strtolower($hostname);
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
if ($ip) return $ip;
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
foreach (explode("\n", $out) as $line) {
$cols = preg_split('/\s+/', trim($line));
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
}
return '';
}
function vv_detect_host(): string {
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
$master = vv_read_conf_raw('master.conf');
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
$hostname = vv_get_hostname();
foreach ($m[1] as $i => $key) {
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
}
return 'unknown';
}
function vv_is_owner(): bool {
return vv_detect_host() === 'host1';
}
function vv_read_conf_raw(string $filename): string {
$path = CONF_DIR . '/' . $filename;
return file_exists($path) ? file_get_contents($path) : '';
}
function vv_write_conf_raw(string $filename, string $content): bool {
$path = CONF_DIR . '/' . $filename;
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) return false;
return rename($tmp, $path);
}
function vv_get_conf_files(): array {
// Returns conf files this host is allowed to view/edit
$host = vv_detect_host();
$files = [];
if ($host === 'host1') {
// Owner sees master.conf + their own host conf
$files[] = 'master.conf';
$files[] = 'host1.conf';
} elseif (preg_match('/^host(\d+)$/', $host)) {
// Any other numbered host sees only their own conf
$files[] = $host . '.conf';
} else {
// Unknown host — show all for dev/debug
foreach (glob(CONF_DIR . '/*.conf') as $f) {
$files[] = basename($f);
}
}
return $files;
}
// Parse conf into key=>value map for $VAR substitution in docs
function vv_conf_vars(): array {
$vars = [];
$files = ['master.conf'];
$host = vv_detect_host();
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
foreach ($files as $f) {
$raw = vv_read_conf_raw($f);
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
}
}
return $vars;
}
// Query the Unraid GraphQL API for a given host.
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
// since vv_conf_vars() only loads the current host's conf file).
// Returns the decoded 'data' object on success, null on any failure.
// Debug log written to /tmp/vv_api_debug.json on failure.
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
$vars = vv_conf_vars();
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
if (!$key) return null;
$myHostId = vv_detect_host();
if (strtolower($hostId) === strtolower($myHostId)) {
$url = 'http://localhost/graphql';
} else {
$hostname = $vars[strtoupper($hostId)] ?? '';
if (!$hostname) return null;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return null;
$url = "http://{$ip}/graphql";
}
$body = json_encode(['query' => $gql]);
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSec,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
} else {
// Fallback to file_get_contents if curl is unavailable.
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
'content' => $body,
'timeout' => $timeoutSec,
'ignore_errors' => true,
]]);
$resp = @file_get_contents($url, false, $ctx);
$httpCode = $resp !== false ? 200 : 0;
$curlErr = '';
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'curl_err' => $curlErr,
'response' => substr((string)$resp, 0, 800),
], JSON_PRETTY_PRINT));
return null;
}
$decoded = json_decode((string)$resp, true);
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
'http_code' => $httpCode,
'errors' => $decoded['errors'],
'data' => $decoded['data'] ?? null,
], JSON_PRETTY_PRINT));
}
// data key present (even if null means query ran but returned nothing useful).
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
}
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
function vv_cache_read(string $key, int $maxAge = 90): ?array {
$f = VV_CACHE_DIR . '/' . $key . '.json';
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
$raw = file_get_contents($f);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// Write a payload atomically (tmp + rename) so readers never see a partial file.
function vv_cache_write(string $key, array $data): void {
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
$f = VV_CACHE_DIR . '/' . $key . '.json';
$tmp = $f . '.tmp';
file_put_contents($tmp, json_encode($data));
rename($tmp, $f);
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
function vv_format_uptime(int $seconds): string {
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
}
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
function vv_parse_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
function vv_parse_kv_db(string $text): array {
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
if ($k !== '') $out[trim($k)] = trim($v);
}
return $out;
}
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
function vv_known_hosts(): array {
$vars = vv_conf_vars();
$hosts = [];
foreach ($vars as $k => $v) {
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
$hosts['host' . $m[1]] = $v;
}
}
ksort($hosts);
return $hosts ?: ['host1' => 'HOST1'];
}
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
function vv_auto_create_api_key(string $hostId, string $confFile): array {
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
if (!$output) {
return ['ok' => false, 'error' => 'unraid-api returned no output'];
}
$data = json_decode(trim($output), true);
$key = $data['key'] ?? null;
if (!$key) {
return ['ok' => false, 'error' => 'No key in response'];
}
$raw = vv_read_conf_raw($confFile);
if ($raw === '') {
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
}
if (!str_contains($raw, $varName)) {
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
if (str_contains($raw, $anchor)) {
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
'$1' . "\n " . $varName . '=""', $raw, 1);
break;
}
}
}
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
'${1}"' . $key . '"', $raw);
vv_write_conf_raw($confFile, $raw);
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
}
// Local LAN IP via routing table — static-cached per request.
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
function vv_local_ip(): string {
static $ip = null;
if ($ip !== null) return $ip;
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
return $ip;
}
@@ -0,0 +1,157 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
// ── Boot device detection ─────────────────────────────────────────────────────
function vv_storage_detect_transport(): string {
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
if (!$part) return 'unknown';
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
if (!$disk) return 'unknown';
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
}
// ── Current mode status ───────────────────────────────────────────────────────
if ($action === 'status') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'flash' : 'internal';
$currentDir = SCRIPTS_DIR;
$internalDir = '/boot/config/plugins/varaverk';
$flashDir = '/mnt/user/appdata/Varaverk';
$currentMode = ($currentDir === $internalDir) ? 'internal'
: ($currentDir === $flashDir ? 'flash' : 'custom');
$myHost = vv_detect_host();
$vars = vv_conf_vars();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confVal = $vars[$confKey] ?? null;
// Boot device name for display
$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") ?: '') : '';
echo json_encode([
'ok' => true,
'current_mode' => $currentMode,
'current_dir' => $currentDir,
'internal_dir' => $internalDir,
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
]);
exit;
}
// ── Run migration ─────────────────────────────────────────────────────────────
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$to = trim($_POST['to'] ?? '');
if (!in_array($to, ['internal', 'flash'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
exit;
}
$script = SCRIPTS_DIR . '/Tools/storage_migrate.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
exit;
}
set_time_limit(300);
$output = [];
$exit = 0;
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
echo json_encode([
'ok' => $exit === 0,
'exit' => $exit,
'output' => implode("\n", $output),
]);
exit;
}
// ── Auto-detect and write to conf ─────────────────────────────────────────────
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'false' : 'true';
$myHost = vv_detect_host();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confFile = $myHost . '.conf';
$results = vv_conf_write_changes([[
'file' => $confFile,
'key' => $confKey,
'value' => $detected,
'type' => 'scalar',
]]);
$ok = !in_array(false, $results, true);
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
exit;
}
// ── Unraid API key status ─────────────────────────────────────────────────────
// Keys live in host*.conf (private, not master.conf):
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
if ($action === 'api_status') {
require_once dirname(__DIR__) . '/include/unraid_api.php';
$localStatus = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$myId = strtoupper($myHost);
$hosts = [];
foreach ($vars as $k => $v) {
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
$id = 'HOST' . $m[1];
$keyVar = $id . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$isLocal = ($id === $myId);
// For local: key is Varaverk_HOST1 registered on own machine
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
$hosts[] = [
'host_id' => $id,
'hostname' => $v,
'is_local' => $isLocal,
'key_var' => $keyVar,
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => $isLocal
? (!$localStatus['key_missing'] && $localStatus['available'])
: !empty($key),
];
}
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
echo json_encode([
'ok' => true,
'my_id' => $myId,
'hosts' => $hosts,
'fallbacks' => $localStatus['fallbacks'],
]);
exit;
}
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$script = SCRIPTS_DIR . '/System_Essentials/unraid_api_key_renew.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
$flags = $allHosts ? ' --all-hosts' : '';
set_time_limit(60);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
@@ -0,0 +1,157 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
// ── Boot device detection ─────────────────────────────────────────────────────
function vv_storage_detect_transport(): string {
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
if (!$part) return 'unknown';
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
if (!$disk) return 'unknown';
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
}
// ── Current mode status ───────────────────────────────────────────────────────
if ($action === 'status') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'flash' : 'internal';
$currentDir = SCRIPTS_DIR;
$internalDir = '/boot/config/plugins/varaverk';
$flashDir = '/mnt/user/appdata/Varaverk';
$currentMode = ($currentDir === $internalDir) ? 'internal'
: ($currentDir === $flashDir ? 'flash' : 'custom');
$myHost = vv_detect_host();
$vars = vv_conf_vars();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confVal = $vars[$confKey] ?? null;
// Boot device name for display
$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") ?: '') : '';
echo json_encode([
'ok' => true,
'current_mode' => $currentMode,
'current_dir' => $currentDir,
'internal_dir' => $internalDir,
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
]);
exit;
}
// ── Run migration ─────────────────────────────────────────────────────────────
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$to = trim($_POST['to'] ?? '');
if (!in_array($to, ['internal', 'flash'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
exit;
}
$script = dirname(__DIR__) . '/tools/storage_migrate.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
exit;
}
set_time_limit(300);
$output = [];
$exit = 0;
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
echo json_encode([
'ok' => $exit === 0,
'exit' => $exit,
'output' => implode("\n", $output),
]);
exit;
}
// ── Auto-detect and write to conf ─────────────────────────────────────────────
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'false' : 'true';
$myHost = vv_detect_host();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confFile = $myHost . '.conf';
$results = vv_conf_write_changes([[
'file' => $confFile,
'key' => $confKey,
'value' => $detected,
'type' => 'scalar',
]]);
$ok = !in_array(false, $results, true);
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
exit;
}
// ── Unraid API key status ─────────────────────────────────────────────────────
// Keys live in host*.conf (private, not master.conf):
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
if ($action === 'api_status') {
require_once dirname(__DIR__) . '/include/unraid_api.php';
$localStatus = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$myId = strtoupper($myHost);
$hosts = [];
foreach ($vars as $k => $v) {
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
$id = 'HOST' . $m[1];
$keyVar = $id . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$isLocal = ($id === $myId);
// For local: key is Varaverk_HOST1 registered on own machine
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
$hosts[] = [
'host_id' => $id,
'hostname' => $v,
'is_local' => $isLocal,
'key_var' => $keyVar,
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => $isLocal
? (!$localStatus['key_missing'] && $localStatus['available'])
: !empty($key),
];
}
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
echo json_encode([
'ok' => true,
'my_id' => $myId,
'hosts' => $hosts,
'fallbacks' => $localStatus['fallbacks'],
]);
exit;
}
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$script = SCRIPTS_DIR . '/System_Essentials/unraid_api_key_renew.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
$flags = $allHosts ? ' --all-hosts' : '';
set_time_limit(60);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
@@ -0,0 +1,157 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
// ── Boot device detection ─────────────────────────────────────────────────────
function vv_storage_detect_transport(): string {
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
if (!$part) return 'unknown';
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
if (!$disk) return 'unknown';
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
}
// ── Current mode status ───────────────────────────────────────────────────────
if ($action === 'status') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'flash' : 'internal';
$currentDir = SCRIPTS_DIR;
$internalDir = '/boot/config/plugins/varaverk';
$flashDir = '/mnt/user/appdata/Varaverk';
$currentMode = ($currentDir === $internalDir) ? 'internal'
: ($currentDir === $flashDir ? 'flash' : 'custom');
$myHost = vv_detect_host();
$vars = vv_conf_vars();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confVal = $vars[$confKey] ?? null;
// Boot device name for display
$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") ?: '') : '';
echo json_encode([
'ok' => true,
'current_mode' => $currentMode,
'current_dir' => $currentDir,
'internal_dir' => $internalDir,
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
]);
exit;
}
// ── Run migration ─────────────────────────────────────────────────────────────
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$to = trim($_POST['to'] ?? '');
if (!in_array($to, ['internal', 'flash'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
exit;
}
$script = dirname(__DIR__) . '/Tools/storage_migrate.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
exit;
}
set_time_limit(300);
$output = [];
$exit = 0;
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
echo json_encode([
'ok' => $exit === 0,
'exit' => $exit,
'output' => implode("\n", $output),
]);
exit;
}
// ── Auto-detect and write to conf ─────────────────────────────────────────────
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'false' : 'true';
$myHost = vv_detect_host();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confFile = $myHost . '.conf';
$results = vv_conf_write_changes([[
'file' => $confFile,
'key' => $confKey,
'value' => $detected,
'type' => 'scalar',
]]);
$ok = !in_array(false, $results, true);
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
exit;
}
// ── Unraid API key status ─────────────────────────────────────────────────────
// Keys live in host*.conf (private, not master.conf):
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
if ($action === 'api_status') {
require_once dirname(__DIR__) . '/include/unraid_api.php';
$localStatus = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$myId = strtoupper($myHost);
$hosts = [];
foreach ($vars as $k => $v) {
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
$id = 'HOST' . $m[1];
$keyVar = $id . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$isLocal = ($id === $myId);
// For local: key is Varaverk_HOST1 registered on own machine
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
$hosts[] = [
'host_id' => $id,
'hostname' => $v,
'is_local' => $isLocal,
'key_var' => $keyVar,
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => $isLocal
? (!$localStatus['key_missing'] && $localStatus['available'])
: !empty($key),
];
}
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
echo json_encode([
'ok' => true,
'my_id' => $myId,
'hosts' => $hosts,
'fallbacks' => $localStatus['fallbacks'],
]);
exit;
}
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
$flags = $allHosts ? ' --all-hosts' : '';
set_time_limit(60);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
@@ -0,0 +1,800 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = '/tmp/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = '/tmp/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = '/tmp/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = "/tmp/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$stateFile = '/tmp/transcode_state.db';
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,802 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = '/tmp/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = '/tmp/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = '/tmp/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = "/tmp/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? '/boot/config/plugins/varaverk/State_Files', '/');
$stateFile = $v['TRANSCODE_STATE_FILE'] ?? "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,802 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = '/tmp/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = '/tmp/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = '/tmp/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = "/tmp/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? '/boot/config/plugins/varaverk/State_Files', '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,802 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,92 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$master = vv_read_conf_raw('master.conf');
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$items = [];
// ── Identity ──────────────────────────────────────────────────────────────────
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
$host1 = trim($m1[1] ?? '');
$items[] = [
'id' => 'identity',
'label' => 'Server identity',
'ok' => !empty($host1),
'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
];
// ── Host conf ─────────────────────────────────────────────────────────────────
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
$items[] = [
'id' => 'host_conf',
'label' => 'Host configuration',
'ok' => $confExists,
'detail' => $confExists
? "$hostId.conf present"
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
];
// ── Unraid API key ─────────────────────────────────────────────────────────────
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
$items[] = [
'id' => 'api_key',
'label' => 'Unraid API key',
'ok' => !empty($apiKey),
'detail' => $apiKey ? 'Key present' : 'Not set',
'action' => $apiKey ? null : 'create_key',
];
// ── SSH key ────────────────────────────────────────────────────────────────────
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
$sshOk = $sshPath && file_exists($sshPath);
$items[] = [
'id' => 'ssh_key',
'label' => 'SSH key',
'ok' => $sshOk,
'detail' => $sshOk
? basename($sshPath)
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
'action' => $sshOk ? null : 'ssh_setup',
];
// ── Auto-populate (any service key or container detected) ──────────────────────
$populated = false;
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
$populated = true;
break;
}
}
$items[] = [
'id' => 'populated',
'label' => 'Auto-populate',
'ok' => $populated,
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
'action' => $populated ? null : 'run_populate',
];
// ── Partnership (only if a partner is configured) ──────────────────────────────
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
$host2 = trim($m2[1] ?? '');
if (!empty($host2)) {
$state = vv_setup_state_read();
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
$items[] = [
'id' => 'partnership',
'label' => 'Partnership',
'ok' => $p1done && $p2done,
'detail' => ($p1done && $p2done)
? "Active with $host2"
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
'action' => (!$p1done) ? 'onboard' : null,
];
}
$allOk = !in_array(false, array_column($items, 'ok'), true);
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);
@@ -0,0 +1,107 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$master = vv_read_conf_raw('master.conf');
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$items = [];
// ── Identity ──────────────────────────────────────────────────────────────────
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
$host1 = trim($m1[1] ?? '');
$items[] = [
'id' => 'identity',
'label' => 'Server identity',
'ok' => !empty($host1),
'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
];
// ── Host conf ─────────────────────────────────────────────────────────────────
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
$items[] = [
'id' => 'host_conf',
'label' => 'Host configuration',
'ok' => $confExists,
'detail' => $confExists
? "$hostId.conf present"
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
];
// ── Unraid API key ─────────────────────────────────────────────────────────────
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
$items[] = [
'id' => 'api_key',
'label' => 'Unraid API key',
'ok' => !empty($apiKey),
'detail' => $apiKey ? 'Key present' : 'Not set',
'action' => $apiKey ? null : 'create_key',
];
// ── SSH key ────────────────────────────────────────────────────────────────────
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
$sshOk = $sshPath && file_exists($sshPath);
$items[] = [
'id' => 'ssh_key',
'label' => 'SSH key',
'ok' => $sshOk,
'detail' => $sshOk
? basename($sshPath)
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
'action' => $sshOk ? null : 'ssh_setup',
];
// ── Auto-populate (any service key or container detected) ──────────────────────
$populated = false;
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
$populated = true;
break;
}
}
$items[] = [
'id' => 'populated',
'label' => 'Auto-populate',
'ok' => $populated,
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
'action' => $populated ? null : 'run_populate',
];
// ── master.conf pull (partner servers only) ───────────────────────────────────────────────────
if ($hostId !== 'host1' && $hostId !== 'unknown') {
$state = vv_setup_state_read();
$pulled = !empty($state['master_conf_pulled']);
$items[] = [
'id' => 'master_conf',
'label' => 'master.conf',
'ok' => $pulled,
'detail' => $pulled
? 'Synced from HOST1'
: ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf'),
'action' => (!$pulled && $host1) ? 'pull_master' : null,
];
}
// ── Partnership (only if a partner is configured) ──────────────────────────────
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
$host2 = trim($m2[1] ?? '');
if (!empty($host2)) {
$state = vv_setup_state_read();
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
$items[] = [
'id' => 'partnership',
'label' => 'Partnership',
'ok' => $p1done && $p2done,
'detail' => ($p1done && $p2done)
? "Active with $host2"
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
'action' => (!$p1done) ? 'onboard' : null,
];
}
$allOk = !in_array(false, array_column($items, 'ok'), true);
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);

Some files were not shown because too many files have changed in this diff Show More