Auth stack certs tab, arrs db fallbacks, cert monitor cache, conf parser fix

- Auth stack: fold cert monitor into Auth Stack page as fourth tab (Certs);
  remove standalone cert page and top-level tab
- cert_monitor.sh: write JSON status cache to State_Files/cert_status.json
  after each run; expose per-domain days/expiry via _CERT_DAYS/_CERT_EXPIRY globals
- api/cert.php: new — serves cached cert status; falls back to configured
  domains as UNKN when no cache exists; POST action=run triggers live check
- arrs db fallbacks: vv_arr_cleanup_stats/discovery_stats/recovery_stats now
  read from data/*.db files when log JSON files don't yet exist
- config.php vv_conf_vars(): unescape bash \$ → $ so passwords with dollar
  signs read correctly from conf files
- host1.conf: fill in HOST1_NPM_USER/PASS and HOST1_LLDAP_USER/PASS
- Partnership adapter pattern: Unraid-specific container logic extracted to
  Plugin/unraid/Partnership/; platform-agnostic structure stays in Partnership/
- First-run wizard: uniform multi-step flow for all hosts; HOST2 pull moved
  to checklist; auto SSH keygen and API key creation on save
- api/checklist.php: live setup checklist with pull_master action
- Fullscreen toggle: hide Unraid header/menu; state persists via localStorage
This commit is contained in:
Gmer4Lfe
2026-06-05 23:17:30 -04:00
parent 17012bcd8c
commit 9c3ace95a7
43 changed files with 1605 additions and 1167 deletions
+4 -4
View File
@@ -283,7 +283,7 @@
ARRAY_START_SCRIPTS=(
"Transcodes/ramdisk_setup.sh" # creates ramdisk + symlink before Emby starts
"System_Essentials/docker_syslog_filter.sh" # suppress veth noise before logs fill
"System_Essentials/php_fpm_max_children.sh" # WebGUI performance tuning
"Plugin/unraid/System_Essentials/php_fpm_max_children.sh" # WebGUI performance tuning
"System_Essentials/inotify_tuning.sh" # bump inotify limits — containers miss events if exhausted
"Docker_Essentials/docker_network_connect.sh" # ensure networks exist + connect containers
"Fallback/fallback.sh" # mutual failover — continuous
@@ -294,10 +294,10 @@
# Run sequentially (foreground) — each must complete before the next starts.
# Order matters: user scripts first (prevents new ops), then data movement, then containers.
ARRAY_STOP_SCRIPTS=(
"System_Essentials/user_scripts_stop.sh" # stop background scripts before they start new ops
"Plugin/unraid/System_Essentials/user_scripts_stop.sh" # stop background scripts before they start new ops
"Fallback/fallback.sh --stop" # gracefully stop fallback (not caught by user_scripts_stop)
"System_Essentials/rsync_stop.sh --rsync-only" # kill rsync; skip container recovery (handled below)
"System_Essentials/mover_stop.sh" # stop mover after rsync (they conflict on same files)
"Plugin/unraid/System_Essentials/mover_stop.sh" # stop mover after rsync (they conflict on same files)
"Docker_Essentials/docker_container_stop.sh" # stop all containers last
)
@@ -319,7 +319,7 @@
# Called by watchdog_orchestrator.sh — not scheduled directly.
SYSTEM_WATCHDOG_SCRIPTS=(
"Watchdogs/System/storage_watchdog.sh" # pool growth + runaway log detection
"Watchdogs/System/webgui_watchdog.sh" # WebGUI availability — nginx → php-fpm → emhttp
"Plugin/unraid/Watchdogs/System/webgui_watchdog.sh" # WebGUI availability — nginx → php-fpm → emhttp
"Watchdogs/System/network_watchdog.sh" # internet, DDNS, Tailscale, NPM proxy
)
+26 -1
View File
@@ -162,6 +162,8 @@ fi
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 \
@@ -186,6 +188,8 @@ check_cert() {
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)"
@@ -214,12 +218,14 @@ HEALTHY=()
WARNING=()
CRITICAL=()
FAILED=()
declare -A DOMAIN_STATUS
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" ;;
@@ -280,5 +286,24 @@ else
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
-1
View File
@@ -157,7 +157,6 @@ HOST2 (mirror) runs:
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
+3 -221
View File
@@ -274,8 +274,8 @@ MIRROR_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${MIRROR}.db"
OFFLINE_COUNTER="${STATE_DIR:-/boot/config}/partnership_offline_days.db"
# ── Exit Trap — restart locally stopped containers if script crashes mid-cleanup ──────────────
# Used by folderview3_remove_partner_folder() and cleanup_partner_containers() — also shared
# with partnership_offboard.sh which sources this file and registers the same trap.
# Used by cleanup_partner_containers() — also shared with partnership_offboard.sh which
# sources this file and registers the same trap.
declare -a _PM_TRAP_STOPPED=()
_pm_trap_restart_stopped() {
[[ ${#_PM_TRAP_STOPPED[@]} -eq 0 ]] && return
@@ -573,166 +573,6 @@ do_ssh_key_revocation() {
fi
}
# ── FolderView3 integration ───────────────────────────────────────────────────────────────────
FOLDERVIEW3_DIR="/usr/local/emhttp/plugins/folder.view3"
FOLDERVIEW3_JSON="/boot/config/plugins/folder.view3/docker.json"
# Derive short partner name: strip unraid- prefix (case-insensitive) if present
derive_partner_folder_name() {
local hostname="$1"
local short="${hostname,,}"
[[ "$short" == unraid-* ]] && short="${short:7}"
# Capitalise first char for readability: jayred365 → Jayred365-fallback
echo "${short^}-Fallback"
}
folderview3_ensure_plugin() {
if [[ -d "$FOLDERVIEW3_DIR" ]]; then
log "FolderView3 plugin present ✅"
return 0
fi
if [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
warn "FolderView3 plugin not installed and PARTNERSHIP_FOLDERVIEW3_URL is empty"
warn "Install manually from Community Applications or set PARTNERSHIP_FOLDERVIEW3_URL in master.conf"
return 1
fi
warn "FolderView3 not found — installing from CA..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run: plugin install $PARTNERSHIP_FOLDERVIEW3_URL"
return 0
fi
plugin install "$PARTNERSHIP_FOLDERVIEW3_URL" 2>/dev/null && \
log "FolderView3 installed ✅" || {
warn "FolderView3 install failed — folder will not be created"
return 1
}
}
folderview3_create_partner_folder() {
local partner_name="$1"
shift
local containers=("$@")
log "FolderView3: creating folder '$partner_name' with ${#containers[@]} container(s)..."
folderview3_ensure_plugin || return 1
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create FolderView3 folder: $partner_name"
for c in "${containers[@]}"; do
[[ -n "$c" ]] && warn " DRY RUN — container: $c"
done
return 0
fi
# Initialise JSON if missing
if [[ ! -f "$FOLDERVIEW3_JSON" ]]; then
mkdir -p "$(dirname "$FOLDERVIEW3_JSON")"
echo '{}' > "$FOLDERVIEW3_JSON"
fi
# Check folder doesn't already exist
local existing
existing=$(jq -r --arg name "$partner_name" \
'to_entries[] | select(.value.name == $name) | .key' \
"$FOLDERVIEW3_JSON" 2>/dev/null)
if [[ -n "$existing" ]]; then
log "FolderView3: folder '$partner_name' already exists (id: $existing) — skipping"
return 0
fi
# Build containers JSON array
local containers_json
containers_json=$(printf '%s\n' "${containers[@]}" | \
grep -v '^$' | jq -R . | jq -s .)
# Generate a stable random ID from timestamp+name
local folder_id
folder_id=$(echo "${partner_name}$(date +%s%N)" | md5sum | head -c 12)
jq --arg id "$folder_id" --arg name "$partner_name" \
--argjson containers "$containers_json" \
'.[$id] = {"name": $name, "containers": $containers, "containerImages": {}}' \
"$FOLDERVIEW3_JSON" > "${FOLDERVIEW3_JSON}.tmp" && \
mv "${FOLDERVIEW3_JSON}.tmp" "$FOLDERVIEW3_JSON" && \
log "FolderView3: folder '$partner_name' created with ${#containers[@]} container(s) ✅" || {
warn "FolderView3: failed to write JSON — check $FOLDERVIEW3_JSON"
return 1
}
}
folderview3_remove_partner_folder() {
local partner_name="$1"
log "FolderView3: removing folder '$partner_name' and stopping its containers..."
if [[ ! -f "$FOLDERVIEW3_JSON" ]]; then
log "FolderView3: JSON not found — nothing to remove"
return 0
fi
if ! command -v jq >/dev/null 2>&1; then
warn "jq not found — cannot manage FolderView3 JSON"
return 1
fi
# Get containers from this folder
local containers_json
containers_json=$(jq -r --arg name "$partner_name" \
'to_entries[] | select(.value.name == $name) | .value.containers[]' \
"$FOLDERVIEW3_JSON" 2>/dev/null)
if [[ -z "$containers_json" ]]; then
log "FolderView3: folder '$partner_name' not found — nothing to remove"
return 0
fi
# Stop and remove each container in the folder
local stopped=0 removed=0 failed=0
while IFS= read -r container; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop + rm: $container"
continue
fi
# Stop if running
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$container" >/dev/null 2>&1; then
if timeout "${DOCKER_TIMEOUT:-30}" docker stop "$container" >/dev/null 2>&1; then
log "$container stopped ✅"
_PM_TRAP_STOPPED+=("$container")
(( stopped++ ))
else
warn "$container stop failed"
(( failed++ ))
fi
if timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1; then
log "$container removed ✅"
(( removed++ ))
else
warn "$container rm failed (may not exist)"
fi
else
log "$container not found locally — skipping"
fi
done <<< "$containers_json"
# Remove folder entry from JSON
if [[ "$DRY_RUN" == false ]]; then
jq --arg name "$partner_name" \
'with_entries(select(.value.name != $name))' \
"$FOLDERVIEW3_JSON" > "${FOLDERVIEW3_JSON}.tmp" && \
mv "${FOLDERVIEW3_JSON}.tmp" "$FOLDERVIEW3_JSON" && \
log "FolderView3: folder '$partner_name' removed ✅" || \
warn "FolderView3: failed to remove folder from JSON"
else
warn "DRY RUN — would remove folder '$partner_name' from $FOLDERVIEW3_JSON"
fi
[[ "$DRY_RUN" == false ]] && \
log "FolderView3 cleanup: $stopped stopped, $removed removed, $failed failed"
return 0
}
# Gather all partner fallback containers for this server (all tiers)
gather_partner_fallback_containers() {
local out_var="$1"
@@ -803,19 +643,14 @@ start_own_stack() {
}
# Remove partnership containers on this server + their appdata bind-mount paths.
# Uses FolderView3 folder if enabled (precise list), else falls back to FALLBACK_*_COVERS_* config.
# Appdata paths collected via docker inspect BEFORE removal — inspect fails on removed containers.
# Safety gate: only paths matching /mnt/*/appdata* are deleted.
cleanup_partner_containers() {
local folder_name="$1"
declare -a containers=()
gather_partner_fallback_containers containers
if [[ ${#containers[@]} -eq 0 ]]; then
log "No partner containers found to remove"
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
folderview3_remove_partner_folder "$folder_name"
fi
return 0
fi
@@ -832,9 +667,6 @@ cleanup_partner_containers() {
done
# Remove containers
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
folderview3_remove_partner_folder "$folder_name"
else
for container in "${containers[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
@@ -850,7 +682,6 @@ cleanup_partner_containers() {
log "$container not found — skipping"
fi
done
fi
# Delete appdata after containers are gone
while IFS= read -r path; do
@@ -1271,27 +1102,6 @@ if [[ "$MODE" == "status" ]]; then
echo " ${entry%%|*} → port ${entry##*|}"
done
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
echo ""
FOLDER_STATUS_NAME=$(derive_partner_folder_name "$REMOTE_SERVER_NAME")
if [[ -f "$FOLDERVIEW3_JSON" ]]; then
FOLDER_EXISTS=$(jq -r --arg name "$FOLDER_STATUS_NAME" \
'to_entries[] | select(.value.name == $name) | .key' \
"$FOLDERVIEW3_JSON" 2>/dev/null)
if [[ -n "$FOLDER_EXISTS" ]]; then
FOLDER_CONTAINERS=$(jq -r --arg name "$FOLDER_STATUS_NAME" \
'[to_entries[] | select(.value.name == $name) | .value.containers[]] | join(", ")' \
"$FOLDERVIEW3_JSON" 2>/dev/null)
echo " FolderView3: $FOLDER_STATUS_NAME"
echo " Containers: $FOLDER_CONTAINERS"
else
echo " FolderView3: $FOLDER_STATUS_NAME — not found in JSON"
fi
else
echo " FolderView3: config not found ($FOLDERVIEW3_JSON)"
fi
fi
if [[ -f "$OFFLINE_COUNTER" ]]; then
OFFLINE_DAYS=$(cat "$OFFLINE_COUNTER" 2>/dev/null || echo 0)
[[ "$OFFLINE_DAYS" -gt 0 ]] && \
@@ -1414,26 +1224,12 @@ if [[ "$MODE" == "onboard" ]]; then
# ── LOCAL-ONLY PATH ──────────────────────────────────────────────────────────
# HOST1-local setup steps that don't need HOST2 present. Called from
# partnership_onboard.sh --phase1-only so HOST1 can complete its own side
# (FolderView3, setup.db flag) while waiting for HOST2 to install and onboard.
# (PARTNERSHIP_ENABLED flag, setup.db) while waiting for HOST2 to install and onboard.
if [[ "$LOCAL_ONLY" == true ]]; then
log "Mode: local-only — skipping remote pre-flight and WebUI steps"
log "Owner: $OWNER_ID ($OWNER) · Mirror: $MIRROR_ID ($MIRROR)"
echo ""
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
echo "FolderView3 Integration"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
declare -a PARTNER_CONTAINERS=()
gather_partner_fallback_containers PARTNER_CONTAINERS
if [[ ${#PARTNER_CONTAINERS[@]} -gt 0 ]]; then
folderview3_create_partner_folder "$PARTNER_FOLDER_NAME" "${PARTNER_CONTAINERS[@]}"
else
log "No FALLBACK_${MY_ID}_COVERS_${MIRROR_ID}_TIER* containers — skipping folder"
fi
else
log "FolderView3 not configured — skipping"
fi
# Enable partnership in master.conf + push to all hosts
echo ""
echo "Enabling partnership in master.conf..."
@@ -1570,20 +1366,6 @@ if (vv_write_conf_raw('master.conf', \$master)) {
warn "DRY RUN — would write ACTIVE state and push to remote"
fi
# FolderView3 — create partner folder with this server's fallback containers for remote
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
echo ""
echo "━━━ $ICON_CONTAINERS FolderView3 Integration ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
declare -a PARTNER_CONTAINERS=()
gather_partner_fallback_containers PARTNER_CONTAINERS
if [[ ${#PARTNER_CONTAINERS[@]} -gt 0 ]]; then
folderview3_create_partner_folder "$PARTNER_FOLDER_NAME" "${PARTNER_CONTAINERS[@]}"
else
log "No FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER* containers configured — skipping folder creation"
fi
fi
# Emby admin provisioning — runs after container deployment (deploy step not yet built)
provision_emby_admin "$MIRROR_IP"
+3 -161
View File
@@ -71,10 +71,10 @@
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"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
@@ -148,140 +148,6 @@ echo " Reason: $REASON"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
# container names from local XML templates. SSHes to remote to stop, remove, and delete
# appdata. Appdata paths are collected via docker inspect before removal so they aren't
# lost once the container is gone. 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
# Collect appdata paths via docker inspect before removal
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
}
# ==============================================================================================
# ── HELPER: 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
}
# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
#
@@ -401,8 +267,7 @@ if [[ "$AM_MIRROR" == true ]]; then
echo ""
echo "━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
cleanup_partner_containers "$PARTNER_FOLDER_NAME" || STEP_FALLBACK_CLEANUP_OK=false
cleanup_partner_containers || STEP_FALLBACK_CLEANUP_OK=false
# ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
echo ""
@@ -559,8 +424,7 @@ fi
echo ""
echo "━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
cleanup_partner_containers "$PARTNER_FOLDER_NAME"
cleanup_partner_containers
# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────
start_own_stack
@@ -574,26 +438,6 @@ if [[ "$MIRROR_REACHABLE" == true ]]; then
cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
# Remove fallback coverage containers (by *-owner_short naming pattern)
cleanup_owner_containers_on_mirror "$MIRROR_IP"
# Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"fv3='/boot/config/plugins/folder.view3/docker.json'
[[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
jq --arg n '$OWNER_FOLDER_ON_MIRROR' \
'with_entries(select(.value.name != \$n))' \
\"\$fv3\" > \"\${fv3}.tmp\" && \
mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
grep -q removed && \
log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR" || \
warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"
else
warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"
fi
fi
else
warn "$MIRROR unreachable — remote container cleanup skipped"
warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"
@@ -696,8 +540,6 @@ echo " Step 9 — Keys revoked: $(_revoke_status)"
echo " Step 10 — State: INACTIVE ✅"
echo ""
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
echo " FolderView3: ${PARTNER_FOLDER_NAME:-} (local) + mirror remote cleaned ✅"
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
echo " Tailscale: $MIRROR removed ✅"
echo ""
+25 -254
View File
@@ -20,15 +20,14 @@
#
# 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
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 3: Deploy auth stackpush 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
# 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
@@ -137,10 +136,10 @@
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"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
@@ -224,165 +223,6 @@ echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" ||
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 ────────────────────
#
@@ -427,46 +267,6 @@ stop_mirror_stack() {
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 ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -541,7 +341,6 @@ log "Mirror: $MIRROR ($MIRROR_IP)"
echo ""
STEP_SSH_OK=false
STEP_PLUGINS_OK=true
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
@@ -620,7 +419,7 @@ if [[ "$PHASE1_ONLY" == true ]]; then
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"
warn "Local setup had issues — check partnership_manager.sh output above"
END=$(date +%s)
echo ""
@@ -674,7 +473,7 @@ exit(\$failed > 0 ? 1 : 0);
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"
warn "Local setup had issues — check partnership_manager.sh output above"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
@@ -693,36 +492,9 @@ exit(\$failed > 0 ? 1 : 0);
exit 0
fi
# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
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 ━━━"
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
@@ -732,7 +504,7 @@ fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Deploy Auth Stack on Mirror ━━━"
echo "━━━ Step 3 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
@@ -750,7 +522,7 @@ fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Stop Mirror Arr Stack ━━━"
echo "━━━ Step 4 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
@@ -763,7 +535,7 @@ fi
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Deploy Arr Stack on Mirror ━━━"
echo "━━━ Step 5 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
@@ -777,7 +549,7 @@ fi
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Partnership Onboard ━━━"
echo "━━━ Step 6 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
@@ -789,7 +561,7 @@ fi
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 8 — Arr Library Bootstrap ━━━"
echo "━━━ Step 7 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
@@ -809,7 +581,7 @@ fi
# 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 ━━━"
echo "━━━ $ICON_GEAR Step 8 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
@@ -857,14 +629,13 @@ _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 " 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
+15 -1
View File
@@ -45,6 +45,7 @@ source "$SCRIPTS_ROOT/load_config.sh"
# ── Parse --force before parse_args ───────────────────────────────────────────────────────────
MODE="setup"
FORCE=false
LOCAL_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
@@ -52,6 +53,7 @@ for arg in "$@"; do
--force) FORCE=true ;;
--validate) MODE="validate" ;;
--status) MODE="status" ;;
--local-only) LOCAL_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
@@ -325,6 +327,19 @@ else
fi
# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
if [[ "$LOCAL_ONLY" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY (local) ━━━━━"
echo " Key: $SSH_KEY_PATH"
echo " Pubkey: $SSH_PUB_PATH"
echo " Conf: ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
warn "Local setup complete — copy public key to remote manually:"
warn " ssh-copy-id -i $SSH_PUB_PATH root@<remote>"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"
@@ -356,7 +371,6 @@ echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"
if [[ "$DRY_RUN" == false ]]; then
if test_ssh_auth "$REMOTE_SERVER"; then
echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
# Reset any existing strikes
if [[ -f "$SSH_STRIKE_FILE" ]]; then
write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
fi
+338
View File
@@ -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
}
@@ -74,7 +74,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
@@ -91,7 +91,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
@@ -30,7 +30,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
@@ -70,7 +70,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
@@ -5,7 +5,7 @@
//
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
$_base = dirname(__DIR__) . '/Plugin/unraid';
$_base = dirname(__DIR__);
require_once $_base . '/include/monitor.php';
require_once $_base . '/include/vms.php';
require_once $_base . '/include/docker_folders.php';
@@ -16,16 +16,28 @@
# AUTO-DETECTED FIELDS
# ==============================================================================================
#
# HOSTN_OWNER from hostname (strip unRAID- prefix, lowercase)
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
# HOSTN_RADARR_URL from Radarr config.xml port
# HOSTN_SONARR_API_KEY from Sonarr config.xml
# HOSTN_SONARR_URL from Sonarr config.xml port
# 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_LIDARR_URL from Lidarr config.xml port
# 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_SABNZBD_API_KEY from sabnzbd.ini
# HOSTN_SABNZBD_URL from sabnzbd.ini port
# HOSTN_SLSKD_API_KEY from slskd config.yml
# HOSTN_SLSKD_URL from slskd config.yml port
# HOSTN_QBIT_URL from qBittorrent.conf WebUI port
# HOSTN_QBIT_USERNAME from qBittorrent.conf WebUI username
# HOSTN_QBIT_PASSWORD from qBittorrent.conf WebUI password (plaintext only)
# HOSTN_EMBY_CONTAINER fuzzy match from docker ps
# HOSTN_EMBY_URL from docker port binding
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
# HOSTN_JELLYFIN_URL from docker port binding
# HOSTN_TRANSCODE_SSD from Emby/Jellyfin container /transcode volume mount
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
#
# ==============================================================================================
@@ -78,7 +90,6 @@ _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)
@@ -93,7 +104,6 @@ _set_conf_var() {
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
@@ -104,13 +114,10 @@ _set_conf_var() {
}
# ── 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}")
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
[[ -z "$container_name" ]] && return 1
local config_path
@@ -127,8 +134,31 @@ _xml_val() {
grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
}
# ── Helper: get host-side port for a container's internal port ────────────────
_docker_host_port() {
local container="$1" container_port="$2"
docker inspect "$container" 2>/dev/null | \
jq -r --arg p "${container_port}/tcp" \
'.[0].NetworkSettings.Ports[$p]?[0].HostPort // empty' 2>/dev/null | head -1
}
# ── Helper: get host path for a container destination mount ──────────────────
_docker_volume_host() {
local container="$1" dest="$2"
docker inspect "$container" 2>/dev/null | \
jq -r --arg d "$dest" \
'.[0].Mounts[]? | select(.Destination == $d) | .Source' 2>/dev/null | head -1
}
# ==============================================================================================
# ── Arr API keys + root paths ─────────────────────────────────────────────────────────────────
# ── Owner short name ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
owner=$(echo "$LOCAL_SERVER_NAME" | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//i' | tr '[:upper:]' '[:lower:]')
_set_conf_var "${MY_ID}_OWNER" "$owner" "Owner short name"
# ==============================================================================================
# ── Arr API keys + URLs + root paths ─────────────────────────────────────────────────────────
# ==============================================================================================
for arr in radarr sonarr lidarr; do
@@ -146,13 +176,14 @@ for arr in radarr sonarr lidarr; do
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)}"
port="${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
url_base="http://localhost:${port}"
_set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key" "${arr_upper} API key"
_set_conf_var "${MY_ID}_${arr_upper}_URL" "$url_base" "${arr_upper} URL"
# 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
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)
@@ -166,44 +197,85 @@ for arr in radarr sonarr lidarr; do
done
# ==============================================================================================
# ── SABnzbd API key ───────────────────────────────────────────────────────────────────────────
# ── SABnzbd API key + URL ─────────────────────────────────────────────────────────────────────
# ==============================================================================================
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)
sab_port=$(grep -oP '(?<=^port\s*=\s*)\d+' "$sab_ini" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
_set_conf_var "${MY_ID}_SABNZBD_URL" "http://localhost:${sab_port:-8080}" "SABnzbd URL"
fi
}
# ==============================================================================================
# ── slskd API key ─────────────────────────────────────────────────────────────────────────────
# ── slskd API key + URL ───────────────────────────────────────────────────────────────────────
# ==============================================================================================
slskd_dir=$(_arr_config_dir "slskd") && {
slskd_yml=$(find "$slskd_dir" -maxdepth 2 -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -1)
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)
slskd_port=$(grep -oP '(?<=port:\s)\d+' "$slskd_yml" 2>/dev/null | head -1)
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
_set_conf_var "${MY_ID}_SLSKD_URL" "http://localhost:${slskd_port:-5030}" "slskd URL"
fi
}
# ==============================================================================================
# ── Container names ───────────────────────────────────────────────────────────────────────────
# ── qBittorrent URL + credentials ────────────────────────────────────────────────────────────
# ==============================================================================================
qbit_dir=$(_arr_config_dir "qbittorrent") && {
qbit_conf=$(find "$qbit_dir" -maxdepth 3 -name "qBittorrent.conf" 2>/dev/null | head -1)
if [[ -f "$qbit_conf" ]]; then
qbit_port=$(grep -oP '(?<=WebUI\\Port=)\d+' "$qbit_conf" 2>/dev/null | head -1)
qbit_user=$(grep -oP '(?<=WebUI\\Username=)\S+' "$qbit_conf" 2>/dev/null | head -1)
# Only capture plaintext password — PBKDF2 hashes are not usable
qbit_pass=$(grep -oP '(?<=WebUI\\Password=)[^\r\n]+' "$qbit_conf" 2>/dev/null | \
grep -v '@ByteArray' | head -1)
_set_conf_var "${MY_ID}_QBIT_URL" "http://localhost:${qbit_port:-8080}" "qBittorrent URL"
_set_conf_var "${MY_ID}_QBIT_USERNAME" "$qbit_user" "qBittorrent username"
_set_conf_var "${MY_ID}_QBIT_PASSWORD" "$qbit_pass" "qBittorrent password"
fi
}
# ==============================================================================================
# ── Media server container names + URLs + transcode path ─────────────────────────────────────
# ==============================================================================================
transcode_dir=""
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" ;;
emby)
host_port=$(_docker_host_port "$container" "8096")
_set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name"
_set_conf_var "${MY_ID}_EMBY_URL" "http://localhost:${host_port:-8096}" "Emby URL"
;;
jellyfin)
host_port=$(_docker_host_port "$container" "8096")
_set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container" "Jellyfin container name"
_set_conf_var "${MY_ID}_JELLYFIN_URL" "http://localhost:${host_port:-8095}" "Jellyfin URL"
;;
esac
# Transcode path: first container with a /transcode mount wins
if [[ -z "$transcode_dir" ]]; then
transcode_dir=$(_docker_volume_host "$container" "/transcode")
fi
done
[[ -n "$transcode_dir" ]] && \
_set_conf_var "${MY_ID}_TRANSCODE_SSD" "${transcode_dir%/}/" "Transcode SSD path"
# ==============================================================================================
# ── Network interface ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -77,7 +77,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
@@ -38,7 +38,7 @@ for arg in "$@"; do
case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
done
mkdir -p /tmp/vv_cache
mkdir -p "$VV_CACHE_DIR"
log "$ICON_GEAR Config: target=${TARGET_HOST:-all hosts} ssh-key=${SSH_KEY}"
@@ -64,7 +64,7 @@ for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
fi
host_id="${host_var,,}" # host1, host2, …
cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
cache_file="$VV_CACHE_DIR/arrs_remote_${host_id}.json"
echo " $host_var ($hostname)…"
@@ -122,7 +122,7 @@ for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
" 2>/dev/null
# Save monitor cache
MONITOR_CACHE="/tmp/vv_cache/monitor_remote_${host_id}.json"
MONITOR_CACHE="$VV_CACHE_DIR/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']));
@@ -278,7 +278,7 @@ fi
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
if rsync -a --delete "$DST/Plugin/" "$INTERNAL_DIR/Plugin/" 2>/dev/null; then
echo " Plugin/ synced to /boot/ ✅"
else
warn "Plugin/ sync to /boot/ failed — webUI may be stale"
+4 -1
View File
@@ -40,12 +40,15 @@ $tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'D
<?= $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="margin-left:auto;padding:0 10px;font-size:10px;color:#333;text-decoration:none;
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 -->
@@ -92,7 +92,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../load_config.sh"
source "$SCRIPT_DIR/../../../../load_config.sh"
parse_args "$@"
+1 -1
View File
@@ -20,7 +20,7 @@ $result = [
];
// Show last debug log if present
$debugFile = '/tmp/vv_api_debug.json';
$debugFile = VV_CACHE_DIR . '/vv_api_debug.json';
if (file_exists($debugFile)) {
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
}
+1 -1
View File
@@ -9,7 +9,7 @@ if ($_action === 'refresh_remote') {
if (!preg_match('/^host\d+$/', $host)) {
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
}
$script = dirname(__DIR__) . '/tools/remote_arr_cache_writer.sh';
$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;
}
+91
View File
@@ -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));
+107
View File
@@ -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]);
+1 -73
View File
@@ -9,76 +9,4 @@ if (!preg_match('/^host\d+$/', $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,
]);
echo json_encode(vv_auto_create_api_key($host, $host . '.conf'));
+2 -1
View File
@@ -1,7 +1,8 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$logDir = '/var/log/varaverk';
$logDir = LOG_DIR;
$runs = [];
try {
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
@@ -13,7 +14,7 @@ if (!is_dir($scriptsDir)) {
exit;
}
$cfgFile = '/boot/config/plugins/varaverk/varaverk.cfg';
$cfgFile = PLUGIN_CFG;
$cfgDir = dirname($cfgFile);
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
+109 -9
View File
@@ -2,21 +2,89 @@
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;
}
$action = trim($_POST['action'] ?? 'save');
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
// ── 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'] ?? '');
$mySlot = trim($_POST['my_slot'] ?? 'host2');
$myHostname = trim($_POST['my_hostname'] ?? '');
if (!$host1Hostname) {
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
$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)) {
@@ -71,17 +139,31 @@ if ($action === 'pull') {
if (!file_exists(CONF_DIR . '/' . $confFile)) {
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
if ($template) {
$hostname = $myHostname ?: vv_get_hostname();
$sshKeyPath = $sshKey;
$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}"' . $sshKeyPath . '"', $conf);
'${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;
}
@@ -138,10 +220,19 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
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;
@@ -152,8 +243,17 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);
// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
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',
]);
+2 -2
View File
@@ -56,7 +56,7 @@ if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
$script = dirname(__DIR__) . '/tools/storage_migrate.sh';
$script = dirname(__DIR__) . '/Tools/storage_migrate.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
exit;
@@ -141,7 +141,7 @@ if ($action === 'api_status') {
// ── 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';
$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;
}
+1 -1
View File
@@ -23,7 +23,7 @@ $cmd = match($action) {
};
$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);
@file_put_contents(SCRIPTS_DIR . '/actions.log', $logLine, FILE_APPEND | LOCK_EX);
exec($cmd . ' > /dev/null 2>&1 &');
echo json_encode(['ok' => true]);
+16 -1
View File
@@ -3,11 +3,26 @@
#varaverk-wrap { padding: 10px; font-family: inherit; }
/* Tab bar */
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; }
#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;
+70 -4
View File
@@ -131,14 +131,14 @@ function vv_arr_cleanup_stats(string $type): array {
'orphans' => 0, 'orphans_sz' => '0B', 'junk' => 0];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['end'] = $meta['end'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (!file_exists($lf)) return $out;
if (file_exists($lf)) {
$log = file_get_contents($lf);
$parts = preg_split('/━{3,}[^\n]*SUMMARY[^\n]*/u', $log);
$blk = count($parts) > 1 ? end($parts) : $log;
@@ -154,6 +154,28 @@ function vv_arr_cleanup_stats(string $type): array {
if (preg_match('/Junk:\s*([\d,]+)\s*files/u', $blk, $m)) {
$out['junk'] = (int)str_replace(',', '', $m[1]);
}
}
}
// Fallback: daily aggregate db — date|arr|orphan_count|orphan_bytes|junk_count|junk_bytes|recent_count|tracked_count
if ($out['last_run'] === null) {
$dbFile = DATA_DIR . '/arr_cleanup_stats.db';
if (file_exists($dbFile)) {
$last = null;
foreach (file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$p = explode('|', $line);
if (count($p) >= 8 && $p[1] === $type) $last = $p;
}
if ($last) {
$out['last_run'] = strtotime($last[0] . ' 23:59:00') ?: null;
$out['status'] = 'ok';
$out['orphans'] = (int)$last[2];
$out['junk'] = (int)$last[4];
$out['tracked'] = (int)$last[7];
}
}
}
return $out;
}
@@ -165,7 +187,7 @@ function vv_arr_discovery_stats(string $type): array {
$out = ['last_run' => null, 'status' => null, 'added' => null];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
@@ -176,6 +198,28 @@ function vv_arr_discovery_stats(string $type): array {
if (preg_match('/Added[:\s]+(\d+)/i', $log, $m)) $out['added'] = (int)$m[1];
elseif (preg_match('/(\d+)\s+added/i', $log, $m)) $out['added'] = (int)$m[1];
}
}
// Fallback: per-title history db — status|id|date[|title]
if ($out['last_run'] === null) {
$dbFile = DATA_DIR . '/' . $type . '_discovery_history.db';
if (file_exists($dbFile)) {
$lastDate = null; $added = 0;
foreach (file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$p = explode('|', $line);
if (count($p) < 3) continue;
$date = $p[2];
if ($date !== $lastDate) { $lastDate = $date; $added = 0; }
if ($p[0] === 'ACCEPT') $added++;
}
if ($lastDate) {
$out['last_run'] = strtotime($lastDate . ' 23:59:00') ?: null;
$out['status'] = 'ok';
$out['added'] = $added;
}
}
}
return $out;
}
@@ -214,7 +258,7 @@ function vv_arr_recovery_stats(): array {
$out = ['last_run' => null, 'status' => null, 'fixed' => 0, 'searched' => 0];
$jf = $base . '.json';
if (!file_exists($jf)) return $out;
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
@@ -225,6 +269,28 @@ function vv_arr_recovery_stats(): array {
if (preg_match('/Removed[:\s]+(\d+)/i', $log, $m)) $out['fixed'] = (int)$m[1];
if (preg_match('/Re-searched[:\s]+(\d+)/i',$log, $m)) $out['searched'] = (int)$m[1];
}
}
// Fallback: daily aggregate db — date|time|count|bytes
if ($out['last_run'] === null) {
$dbFile = DATA_DIR . '/arr_recovery_stats.db';
if (file_exists($dbFile)) {
$lines = file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$last = $lines ? end($lines) : null;
if ($last) {
$p = explode('|', $last);
if (count($p) >= 3) {
$ts = strtotime(($p[0] ?? '') . ' ' . ($p[1] ?? '00:00')) ?: null;
if ($ts) {
$out['last_run'] = $ts;
$out['status'] = 'ok';
$out['fixed'] = (int)($p[2] ?? 0);
}
}
}
}
}
return $out;
}
+6 -6
View File
@@ -151,7 +151,7 @@ function vv_cpu_per_core(): array {
$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';
$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';
@@ -292,7 +292,7 @@ function vv_network_stats(): array {
break;
}
$stateFile = '/tmp/vv_net_stat.json';
$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';
@@ -536,7 +536,7 @@ function vv_array_disks(): array {
}
function vv_disk_io_rates(): array {
$snapFile = '/tmp/vv_diskio_snap.json';
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
@@ -631,7 +631,7 @@ function vv_remote_hosts_stats(): array {
continue;
}
$cacheFile = "/tmp/vv_remote_{$id}.json";
$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; }
@@ -727,7 +727,7 @@ function vv_parse_bash_array(string $raw, string $varName): array {
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? '/boot/config/plugins/varaverk/State_Files', '/');
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
@@ -773,7 +773,7 @@ function vv_transcode_sessions(): array {
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
+1 -1
View File
@@ -24,7 +24,7 @@ const VV_SCRIPT_CONF_SECTIONS = [
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
'Watchdogs/resource_watchdog.sh' => ['Pressure Levels'],
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
'Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
'Plugin/unraid/Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
// Media
'Media/media_cleaner.sh' => ['Media Cleaner'],
'Media/media_shares_permissions.sh' => ['Media Permissions'],
+36 -5
View File
@@ -13,6 +13,7 @@ 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 {
@@ -202,7 +203,7 @@ function vv_conf_vars(): array {
// 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]);
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
}
}
return $vars;
@@ -263,7 +264,7 @@ function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, s
}
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
@@ -278,7 +279,7 @@ function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, s
// If the API returned GraphQL errors, log them for diagnosis.
if (!empty($decoded['errors'])) {
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
'ts' => time(),
'host' => $hostId,
'url' => $url,
@@ -294,8 +295,6 @@ function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, s
// ── 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';
@@ -358,6 +357,38 @@ function vv_known_hosts(): array {
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 {
+55 -9
View File
@@ -149,7 +149,7 @@ function vv_script_suggested_cron(string $path): array {
// Parse user_script_plug-in.sh into an array of script blocks.
// Each block: title, schedule, desc (array of lines), scripts (array of {rel, cron})
function vv_parse_user_script_template(): array {
$file = SCRIPTS_DIR . '/user_script_plug-in.sh';
$file = SCRIPTS_DIR . '/Plugin/unraid/user_script_plug-in.sh';
if (!file_exists($file)) return [];
$lines = file($file, FILE_IGNORE_NEW_LINES);
$prefix = rtrim(SCRIPTS_DIR, '/') . '/';
@@ -231,11 +231,17 @@ function vv_script_description(string $path): string {
}
function vv_tools_scripts(): array {
$dir = SCRIPTS_DIR . '/Tools';
// Background writers managed automatically — not user-facing tools
static $EXCLUDE = ['api_cache_writer.sh', 'remote_arr_cache_writer.sh'];
$schedule = vv_schedule_load();
$scripts = [];
$collect = function(string $dir, string $relPrefix) use ($schedule, $EXCLUDE, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$rel = 'Tools/' . basename($path);
$base = basename($path);
if (in_array($base, $EXCLUDE, true)) continue;
$rel = $relPrefix . $base;
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
@@ -246,16 +252,28 @@ function vv_tools_scripts(): array {
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
};
// General tools
$collect(SCRIPTS_DIR . '/Tools', 'Tools/');
// Platform adapter tools (Plugin/<platform>/Tools/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Tools') ?: [] as $toolsDir) {
$platform = basename(dirname($toolsDir));
$collect($toolsDir, "Plugin/$platform/Tools/");
}
usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label']));
return $scripts;
}
function vv_custom_scripts(): array {
$dir = SCRIPTS_DIR . '/Custom';
$schedule = vv_schedule_load();
$scripts = [];
$collect = function(string $dir, string $relPrefix) use ($schedule, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$rel = 'Custom/' . basename($path);
$rel = $relPrefix . basename($path);
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
@@ -266,6 +284,16 @@ function vv_custom_scripts(): array {
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
};
$collect(SCRIPTS_DIR . '/Custom', 'Custom/');
// Platform adapter custom scripts (Plugin/<platform>/Custom/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Custom') ?: [] as $customDir) {
$platform = basename(dirname($customDir));
$collect($customDir, "Plugin/$platform/Custom/");
}
return $scripts;
}
@@ -287,7 +315,7 @@ function vv_orch_conf_arrays(string $orchPath): array {
}
// Return .sh scripts that exist in SCRIPTS_DIR but are not referenced in any
// master.conf *_SCRIPTS array and are not orchestrators or custom scripts.
// master.conf *_SCRIPTS array and are not shown in any other scheduler card.
function vv_script_library(): array {
$scriptsDir = SCRIPTS_DIR;
$confMap = vv_conf_script_map();
@@ -295,7 +323,17 @@ function vv_script_library(): array {
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $p) {
$orchIds[] = 'Orchestrators/' . basename($p);
}
$exclude = ['Plugin', '.git', 'Orchestrators', 'Custom', 'Configurations'];
// Scripts already shown in their own cards are not "unlisted"
$schedule = vv_schedule_load();
$cardIds = array_flip(array_merge(
array_column(vv_tools_scripts(), 'id'),
array_column(vv_custom_scripts(), 'id')
));
// UI-only subdirs under Plugin/<platform>/ — no runnable scripts
$pluginUiDirs = ['api', 'include', 'pages', 'css', 'js', 'icons', 'event'];
$exclude = ['.git', 'Orchestrators', 'Custom', 'Configurations'];
$library = [];
try {
$ri = new RecursiveIteratorIterator(
@@ -306,8 +344,16 @@ function vv_script_library(): array {
if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
$rel = ltrim(str_replace($base, '', $rf->getPathname()), '/');
$parts = explode('/', $rel);
if (count($parts) < 2 || in_array($parts[0], $exclude)) continue;
if (in_array($rel, $orchIds) || isset($confMap[$rel])) continue;
if (in_array($parts[0], $exclude)) continue;
if ($parts[0] === 'Plugin') {
// Require Plugin/<platform>/<category>/<script>.sh — skip root-level adapter files
if (count($parts) < 4) continue;
// Skip UI-only category dirs
if (in_array($parts[2], $pluginUiDirs)) continue;
} elseif (count($parts) < 2) {
continue;
}
if (in_array($rel, $orchIds) || isset($confMap[$rel]) || isset($cardIds[$rel]) || isset($schedule[$rel])) continue;
$library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
}
} catch (Exception $e) {}
+21
View File
@@ -7,3 +7,24 @@ function vvFlashStatus(el, msg, ok) {
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();
})();
+104
View File
@@ -111,6 +111,12 @@
.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; }
/* ── Certs panel ─────────────────────────────────────────────────────────── */
.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
</style>
<?php
@@ -123,6 +129,7 @@ $isOwner = vv_is_owner();
<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-tab" data-tab="certs">Certs</button>
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">&#8635; Refresh</button>
</div>
@@ -203,6 +210,21 @@ $isOwner = vv_is_owner();
</div>
</div>
<!-- ── Certs ───────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-certs">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
<button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>
</div>
<div class="vv-au-cert-grid" id="vv-au-cert-grid">
<div class="vv-au-loading">Loading…</div>
</div>
<div id="vv-au-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:140px;overflow-y:auto;white-space:pre-wrap;"></div>
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
</div>
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
<div class="vv-au-overlay" id="vv-au-overlay">
<div class="vv-au-modal" id="vv-au-modal"></div>
@@ -213,6 +235,7 @@ $isOwner = vv_is_owner();
'use strict';
const API = '/plugins/varaverk/api/auth.php';
const CERT_API = '/plugins/varaverk/api/cert.php';
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
// ── State ─────────────────────────────────────────────────────────────────────
@@ -287,6 +310,7 @@ function _loadTab(tab) {
if (tab === 'proxies') _loadProxies();
if (tab === 'users') { _loadUsers(); _loadGroups(); }
if (tab === 'acl') _loadAcl();
if (tab === 'certs') _loadCerts();
}
// ── Proxies ───────────────────────────────────────────────────────────────────
@@ -910,6 +934,86 @@ document.getElementById('vv-au-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
});
// ── Certs ─────────────────────────────────────────────────────────────────────
function _certBadgeCls(s) {
return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
}
function _certBadgeTxt(s) {
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
}
function _certDayColor(days, warn, crit) {
if (days == null) return '#3a3a3a';
return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
}
function _certRel(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';
return Math.floor(d/86400) + 'd ago';
}
function _renderCerts(data) {
const grid = document.getElementById('vv-au-cert-grid');
const ts = document.getElementById('vv-au-cert-ts');
const cfg = document.getElementById('vv-au-cert-cfg');
const warn = data.warn_days || 30, crit = data.crit_days || 7;
ts.textContent = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
const domains = data.domains || [];
if (!domains.length) {
grid.innerHTML = '<div class="vv-au-empty">No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf</div>';
return;
}
grid.innerHTML = domains.map(d => {
const s = d.status || 'UNKN';
const days = d.days;
const col = _certDayColor(days, warn, crit);
const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;
const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
return `<div class="vv-au-card" style="padding:12px 14px;">
<div class="vv-au-domain">${_esc(d.domain)}</div>
<div class="vv-au-cert-days" style="color:${col}">${days != null ? days : '—'}</div>
<div style="font-size:9px;color:#444;margin-bottom:6px;">${days != null ? 'days remaining' : ''}</div>
<span class="vv-au-badge ${_certBadgeCls(s)}">${_certBadgeTxt(s)}</span>
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${_esc(expStr)}</div>
${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
</div>`;
}).join('');
}
function _loadCerts() {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
fetch(CERT_API)
.then(r => r.json())
.then(d => { if (d.ok) _renderCerts(d); })
.catch(() => { grid.innerHTML = '<div class="vv-au-empty" style="color:#ef5350">Failed to load cert data</div>'; });
}
document.getElementById('vv-au-cert-run').addEventListener('click', function() {
const btn = this;
const log = document.getElementById('vv-au-cert-log');
btn.disabled = true; btn.textContent = 'Checking…';
log.style.display = 'none'; log.textContent = '';
const fd = new FormData(); fd.append('action', 'run');
fetch(CERT_API, { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Run now';
if (d.data) _renderCerts(d.data);
if (d.output && d.output.length) {
log.style.display = 'block';
log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
});
// ── Boot ──────────────────────────────────────────────────────────────────────
_loadProxies();
+1 -1
View File
@@ -218,7 +218,7 @@ $runningScripts = array_unique($runningScripts);
</div>
<div class="vv-children" id="vv-tools-children" style="display:none;">
<?php if (empty($tools)): ?>
<p class="vv-custom-empty">No scripts found in Tools/.</p>
<p class="vv-custom-empty">No scripts found in Tools/ or Plugin/*/tools/.</p>
<?php else: ?>
<?php foreach ($tools as $ts): $tsid = htmlspecialchars($ts['id']); ?>
<div class="vv-script" data-id="<?= $tsid ?>">
+329 -257
View File
@@ -1,312 +1,384 @@
<?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.
// 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 = 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;
$detectedHostname = vv_get_hostname();
?>
<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;
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 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; }
#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">
<?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>
<h1>⬡ Varaverk — First Run</h1>
<div class="vv-sub">Set up this server before the plugin can start.</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>
<!-- ── Step 1: Detection + identity ──────────────────────────────────────── -->
<div id="vv-step1">
<button id="vv-setup-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
<div id="vv-setup-status"></div>
<div id="vv-detect-banner"><div class="loading">Detecting environment…</div></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">
<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-setup-hint">Must match Settings → Identification exactly</div>
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</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>
<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>
<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 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-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>
<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-setup-conditional" id="vv-cond-partner">
<div class="vv-setup-field">
<label>Primary server's hostname <span style="color:#a44">*required</span></label>
<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-setup-field">
<div class="vv-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;">
<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 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.
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
<div id="vv-status"></div>
</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;">
<!-- ── 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 id="vv-skip-link" href="#" onclick="vvWizardContinue(event)"
style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
<a href="#" onclick="vvGoScheduler(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 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 _vvWizardNext = '';
function vvShowStep2(redirect) {
_vvWizardNext = redirect;
document.getElementById('vv-setup-step2').style.display = 'block';
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');
}
function vvWizardContinue(e) {
// ── 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 = _vvWizardNext || '?tab=scheduler';
window.location.href = _vvRedirect || '?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 => {
// ── 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) {
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';
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 {
status.textContent = '✗ ' + (d.error ?? 'Failed'); status.style.color = '#a44';
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(e => {
}).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';
});
}
</script>
</div>
// ── 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>
@@ -802,8 +802,8 @@
# Symptom of saturation: WebGUI slow, settings saves hang, container UI starts timeout.
# Resets on each reboot — reapplied at array start. Idempotent: silent when already correct.
#
# bash /boot/config/plugins/varaverk/System_Essentials/php_fpm_max_children.sh --status
# bash /boot/config/plugins/varaverk/System_Essentials/php_fpm_max_children.sh
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/php_fpm_max_children.sh --status
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/php_fpm_max_children.sh
# docker_syslog_filter.sh — suppress Docker veth/docker0 interface log noise
# Called by array_started.sh before containers start. Creates rsyslog drop rule.
@@ -817,8 +817,8 @@
# webgui_watchdog.sh — WebGUI availability watchdog (called by system_watchdog via SYSTEM_WATCHDOG_SCRIPTS)
# Not scheduled directly — runs as part of the every-15-minute watchdog chain.
# Escalation: nginx → php-fpm → emhttp, each with recheck before proceeding to next level.
# bash /boot/config/plugins/varaverk/Watchdogs/System/webgui_watchdog.sh --dry-run
# bash /boot/config/plugins/varaverk/Watchdogs/System/webgui_watchdog.sh --status
# bash /boot/config/plugins/varaverk/Plugin/unraid/Watchdogs/System/webgui_watchdog.sh --dry-run
# bash /boot/config/plugins/varaverk/Plugin/unraid/Watchdogs/System/webgui_watchdog.sh --status
# clear_logs.sh — size-threshold log cleanup (called by weekly_sync_maintenance via WEEKLY_MAINTENANCE_SCRIPTS)
# Not scheduled directly — runs as part of the Sunday 2:30am weekly window.
@@ -831,9 +831,9 @@
# SIGTERM (allows mover to finish current file — no partial files). Verify. SIGKILL last resort.
# Use before: planned reboots with mover running, disk replacement, array maintenance.
#
# bash /boot/config/plugins/varaverk/System_Essentials/mover_stop.sh --status
# bash /boot/config/plugins/varaverk/System_Essentials/mover_stop.sh --dry-run
# bash /boot/config/plugins/varaverk/System_Essentials/mover_stop.sh
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/mover_stop.sh --status
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/mover_stop.sh --dry-run
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/mover_stop.sh
# server_reboot.sh — graceful reboot with pre-flight warnings and clean shutdown sequence
# Pre-flight warnings (inform not block): rsync running, mover running, active Emby sessions.
@@ -851,9 +851,9 @@
# Use when plugin Abort button didn't work, or before a reboot to clean up running scripts.
# Called automatically by server_reboot.sh before reboot.
#
# bash /boot/config/plugins/varaverk/System_Essentials/user_scripts_stop.sh --status
# bash /boot/config/plugins/varaverk/System_Essentials/user_scripts_stop.sh --dry-run
# bash /boot/config/plugins/varaverk/System_Essentials/user_scripts_stop.sh
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/user_scripts_stop.sh --status
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/user_scripts_stop.sh --dry-run
# bash /boot/config/plugins/varaverk/Plugin/unraid/System_Essentials/user_scripts_stop.sh
# git_pull_execute.sh — pull latest scripts from Gitea and set execute permissions
# Deployment mechanism for the ecosystem. Push from VS Code → Gitea → run on both servers.
@@ -1401,9 +1401,9 @@
# chown nobody:users on creation. Run once — then initial rsync populates the content.
# Without this: rsync aborts "remote share missing" even though the share shows in the UI.
#
# bash /boot/config/plugins/varaverk/Tools/recreate_shares.sh --dry-run
# bash /boot/config/plugins/varaverk/Tools/recreate_shares.sh --status
# bash /boot/config/plugins/varaverk/Tools/recreate_shares.sh
# bash /boot/config/plugins/varaverk/Plugin/unraid/Tools/recreate_shares.sh --dry-run
# bash /boot/config/plugins/varaverk/Plugin/unraid/Tools/recreate_shares.sh --status
# bash /boot/config/plugins/varaverk/Plugin/unraid/Tools/recreate_shares.sh
# ==============================================================================================
+3 -2
View File
@@ -255,10 +255,11 @@ else
# ── Flash mode: sync Plugin/ to /boot/ so the webUI picks up updates ─────
# In flash mode SCRIPTS_DIR is in appdata — Plugin/ lives in the repo there
# but Unraid serves PHP from /boot/. Sync after every pull to keep them in step.
if [[ "$TARGET_DIR" != "/boot/config/plugins/varaverk" ]]; then
_BOOT_DIR="/boot/config/plugins/varaverk"
if [[ "$TARGET_DIR" != "$_BOOT_DIR" ]]; then
echo ""
echo "━━━ $ICON_SYNC Flash mode: sync Plugin/ → /boot/ ━━━"
if rsync -a --delete "$TARGET_DIR/Plugin/" "/boot/config/plugins/varaverk/Plugin/" 2>/dev/null; then
if rsync -a --delete "$TARGET_DIR/Plugin/" "$_BOOT_DIR/Plugin/" 2>/dev/null; then
echo " Plugin/ synced to /boot/ ✅"
else
warn "Plugin/ sync to /boot/ failed — webUI may be stale until next pull"
+8
View File
@@ -132,5 +132,13 @@
_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