diff --git a/Deployment/conf_templates/master.conf b/Deployment/conf_templates/master.conf index a58b5c5..ba51bf7 100644 --- a/Deployment/conf_templates/master.conf +++ b/Deployment/conf_templates/master.conf @@ -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 ) diff --git a/Monitors/cert_monitor.sh b/Monitors/cert_monitor.sh index ce5ca3d..bfdbdc3 100755 --- a/Monitors/cert_monitor.sh +++ b/Monitors/cert_monitor.sh @@ -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 \ No newline at end of file diff --git a/Partnership/README-Partnership.md b/Partnership/README-Partnership.md index 57d1354..3a5be3e 100644 --- a/Partnership/README-Partnership.md +++ b/Partnership/README-Partnership.md @@ -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 diff --git a/Partnership/partnership_manager.sh b/Partnership/partnership_manager.sh index 4e348b2..b12465b 100755 --- a/Partnership/partnership_manager.sh +++ b/Partnership/partnership_manager.sh @@ -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,25 +667,21 @@ 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 - warn "DRY RUN — would stop + rm: $container" - continue - fi - if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$container" >/dev/null 2>&1; then - timeout "${DOCKER_TIMEOUT:-30}" docker stop "$container" >/dev/null 2>&1 || true - _PM_TRAP_STOPPED+=("$container") - timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1 && \ - log "$container removed ✅" || warn "$container rm failed" - else - log "$container not found — skipping" - fi - done - fi + for container in "${containers[@]}"; do + [[ -z "$container" ]] && continue + if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would stop + rm: $container" + continue + fi + if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$container" >/dev/null 2>&1; then + timeout "${DOCKER_TIMEOUT:-30}" docker stop "$container" >/dev/null 2>&1 || true + _PM_TRAP_STOPPED+=("$container") + timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1 && \ + log "$container removed ✅" || warn "$container rm failed" + else + log "$container not found — skipping" + fi + done # 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" diff --git a/Partnership/partnership_offboard.sh b/Partnership/partnership_offboard.sh index 6cef246..87e2878 100755 --- a/Partnership/partnership_offboard.sh +++ b/Partnership/partnership_offboard.sh @@ -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>/,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>/,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 "" diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 05b67c8..055c891 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -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 stack — push XMLs, pull images, create + start on mirror # Mariadb/Redis health-checked before Authelia deploys -# Step 5: Stop mirror arr — stop mirror's existing arr containers before replacing -# Step 6: Deploy arr stack — push arr XMLs, pull images, create + start on mirror -# Step 7: Partnership onboard — configure WebUIs → owner IP, write state, FolderView3, Emby -# Step 8: Arr bootstrap — bidirectional library sync (arr_sync.sh) -# Step 9: Conf push — push master.conf + setup state to all listed hosts +# 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>/, a){print a[1];exit}' "$xml_file") - repo=$( awk 'match($0,/([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file") - network=$( awk 'match($0,/([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file") - extra=$( awk 'match($0,/([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file") - privileged=$( awk 'match($0,/([^<]+)<\/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>/,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 diff --git a/Partnership/ssh_setup.sh b/Partnership/ssh_setup.sh index 49b2c79..259415f 100755 --- a/Partnership/ssh_setup.sh +++ b/Partnership/ssh_setup.sh @@ -45,14 +45,16 @@ source "$SCRIPTS_ROOT/load_config.sh" # ── Parse --force before parse_args ─────────────────────────────────────────────────────────── MODE="setup" FORCE=false +LOCAL_ONLY=false FILTERED_ARGS=() for arg in "$@"; do case "$arg" in - --force) FORCE=true ;; - --validate) MODE="validate" ;; - --status) MODE="status" ;; - *) FILTERED_ARGS+=("$arg") ;; + --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@" + 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 diff --git a/Plugin/unraid/Partnership/containers.sh b/Plugin/unraid/Partnership/containers.sh new file mode 100755 index 0000000..8806057 --- /dev/null +++ b/Plugin/unraid/Partnership/containers.sh @@ -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>/, a){print a[1];exit}' "$xml_file") + repo=$( awk 'match($0,/([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file") + network=$( awk 'match($0,/([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file") + extra=$( awk 'match($0,/([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file") + privileged=$( awk 'match($0,/([^<]+)<\/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>/,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>/,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>/,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 +} + diff --git a/System_Essentials/mover_stop.sh b/Plugin/unraid/System_Essentials/mover_stop.sh similarity index 99% rename from System_Essentials/mover_stop.sh rename to Plugin/unraid/System_Essentials/mover_stop.sh index 2c1d308..1eb897f 100755 --- a/System_Essentials/mover_stop.sh +++ b/Plugin/unraid/System_Essentials/mover_stop.sh @@ -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 "$@" diff --git a/System_Essentials/php_fpm_max_children.sh b/Plugin/unraid/System_Essentials/php_fpm_max_children.sh similarity index 99% rename from System_Essentials/php_fpm_max_children.sh rename to Plugin/unraid/System_Essentials/php_fpm_max_children.sh index 861e70d..cd9e7d7 100755 --- a/System_Essentials/php_fpm_max_children.sh +++ b/Plugin/unraid/System_Essentials/php_fpm_max_children.sh @@ -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 "$@" diff --git a/System_Essentials/unraid_api_key_renew.sh b/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh similarity index 99% rename from System_Essentials/unraid_api_key_renew.sh rename to Plugin/unraid/System_Essentials/unraid_api_key_renew.sh index 43683c9..dc55c5a 100755 --- a/System_Essentials/unraid_api_key_renew.sh +++ b/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh @@ -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 diff --git a/System_Essentials/user_scripts_stop.sh b/Plugin/unraid/System_Essentials/user_scripts_stop.sh similarity index 99% rename from System_Essentials/user_scripts_stop.sh rename to Plugin/unraid/System_Essentials/user_scripts_stop.sh index 05f8037..174c41a 100755 --- a/System_Essentials/user_scripts_stop.sh +++ b/Plugin/unraid/System_Essentials/user_scripts_stop.sh @@ -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 "$@" diff --git a/Plugin/unraid/tools/api_cache_writer.php b/Plugin/unraid/Tools/api_cache_writer.php similarity index 98% rename from Plugin/unraid/tools/api_cache_writer.php rename to Plugin/unraid/Tools/api_cache_writer.php index fbc0145..29bf704 100644 --- a/Plugin/unraid/tools/api_cache_writer.php +++ b/Plugin/unraid/Tools/api_cache_writer.php @@ -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'; diff --git a/Plugin/unraid/tools/api_cache_writer.sh b/Plugin/unraid/Tools/api_cache_writer.sh similarity index 100% rename from Plugin/unraid/tools/api_cache_writer.sh rename to Plugin/unraid/Tools/api_cache_writer.sh diff --git a/Plugin/unraid/tools/conf_populate.sh b/Plugin/unraid/Tools/conf_populate.sh similarity index 61% rename from Plugin/unraid/tools/conf_populate.sh rename to Plugin/unraid/Tools/conf_populate.sh index 6a4e6bb..8d15e3a 100755 --- a/Plugin/unraid/tools/conf_populate.sh +++ b/Plugin/unraid/Tools/conf_populate.sh @@ -16,17 +16,29 @@ # 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_SYS_WATCHDOG_NIC from ip route default gateway interface +# 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 # # ============================================================================================== # RUNTIME MODES @@ -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/ 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,64 +176,106 @@ 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}_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) case "$arr" in radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;; - sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;; + sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;; lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;; esac fi done # ============================================================================================== -# ── SABnzbd API key ─────────────────────────────────────────────────────────────────────────── +# ── 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) - _set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key" + 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) + slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1) [[ -z "$slskd_key" ]] && \ slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1) - _set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key" + 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 ───────────────────────────────────────────────────────────────────────── # ============================================================================================== diff --git a/Tools/recreate_shares.sh b/Plugin/unraid/Tools/recreate_shares.sh similarity index 99% rename from Tools/recreate_shares.sh rename to Plugin/unraid/Tools/recreate_shares.sh index 5be2159..88dc801 100755 --- a/Tools/recreate_shares.sh +++ b/Plugin/unraid/Tools/recreate_shares.sh @@ -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 "$@" diff --git a/Plugin/unraid/tools/remote_arr_cache_writer.sh b/Plugin/unraid/Tools/remote_arr_cache_writer.sh similarity index 97% rename from Plugin/unraid/tools/remote_arr_cache_writer.sh rename to Plugin/unraid/Tools/remote_arr_cache_writer.sh index 7d04f6a..8997af5 100755 --- a/Plugin/unraid/tools/remote_arr_cache_writer.sh +++ b/Plugin/unraid/Tools/remote_arr_cache_writer.sh @@ -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'])); diff --git a/Plugin/unraid/tools/storage_migrate.sh b/Plugin/unraid/Tools/storage_migrate.sh similarity index 99% rename from Plugin/unraid/tools/storage_migrate.sh rename to Plugin/unraid/Tools/storage_migrate.sh index eec1259..91ff5a2 100755 --- a/Plugin/unraid/tools/storage_migrate.sh +++ b/Plugin/unraid/Tools/storage_migrate.sh @@ -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" diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index 3200b01..d39f114 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -40,12 +40,15 @@ $tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'D - - ⎋ GitHub - + diff --git a/Watchdogs/System/webgui_watchdog.sh b/Plugin/unraid/Watchdogs/System/webgui_watchdog.sh similarity index 99% rename from Watchdogs/System/webgui_watchdog.sh rename to Plugin/unraid/Watchdogs/System/webgui_watchdog.sh index 9ccf5f3..649a011 100755 --- a/Watchdogs/System/webgui_watchdog.sh +++ b/Plugin/unraid/Watchdogs/System/webgui_watchdog.sh @@ -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 "$@" diff --git a/Plugin/unraid/api/api_test.php b/Plugin/unraid/api/api_test.php index ed86a87..513b383 100644 --- a/Plugin/unraid/api/api_test.php +++ b/Plugin/unraid/api/api_test.php @@ -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); } diff --git a/Plugin/unraid/api/arrs.php b/Plugin/unraid/api/arrs.php index 3bede9c..c3f49cc 100644 --- a/Plugin/unraid/api/arrs.php +++ b/Plugin/unraid/api/arrs.php @@ -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; } diff --git a/Plugin/unraid/api/cert.php b/Plugin/unraid/api/cert.php new file mode 100644 index 0000000..0bcba59 --- /dev/null +++ b/Plugin/unraid/api/cert.php @@ -0,0 +1,91 @@ + 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)); diff --git a/Plugin/unraid/api/checklist.php b/Plugin/unraid/api/checklist.php new file mode 100644 index 0000000..fbb24c5 --- /dev/null +++ b/Plugin/unraid/api/checklist.php @@ -0,0 +1,107 @@ + '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]); diff --git a/Plugin/unraid/api/create_api_key.php b/Plugin/unraid/api/create_api_key.php index c738fef..d072338 100644 --- a/Plugin/unraid/api/create_api_key.php +++ b/Plugin/unraid/api/create_api_key.php @@ -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 &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')); diff --git a/Plugin/unraid/api/recent.php b/Plugin/unraid/api/recent.php index 47ab55f..674909b 100644 --- a/Plugin/unraid/api/recent.php +++ b/Plugin/unraid/api/recent.php @@ -1,7 +1,8 @@ /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') { - $host1Hostname = trim($_POST['host1_hostname'] ?? ''); - $mySlot = trim($_POST['my_slot'] ?? 'host2'); - $myHostname = trim($_POST['my_hostname'] ?? ''); - + $mySlot = trim($_POST['my_slot'] ?? '') ?: strtolower(vv_detect_host()); + $myHostname = trim($_POST['my_hostname'] ?? '') ?: vv_get_hostname(); + $host1Hostname = trim($_POST['host1_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', ]); diff --git a/Plugin/unraid/api/storage.php b/Plugin/unraid/api/storage.php index 32cef09..dd55e41 100644 --- a/Plugin/unraid/api/storage.php +++ b/Plugin/unraid/api/storage.php @@ -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; } diff --git a/Plugin/unraid/api/system.php b/Plugin/unraid/api/system.php index 76d4d94..c10550b 100644 --- a/Plugin/unraid/api/system.php +++ b/Plugin/unraid/api/system.php @@ -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]); diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 09ca18c..32ab2c1 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -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; diff --git a/Plugin/unraid/include/arrs.php b/Plugin/unraid/include/arrs.php index e6472b2..f71b884 100644 --- a/Plugin/unraid/include/arrs.php +++ b/Plugin/unraid/include/arrs.php @@ -131,29 +131,51 @@ function vv_arr_cleanup_stats(string $type): array { 'orphans' => 0, 'orphans_sz' => '0B', 'junk' => 0]; $jf = $base . '.json'; - if (!file_exists($jf)) return $out; - $meta = json_decode(file_get_contents($jf), true) ?: []; - $out['last_run'] = $meta['start'] ?? null; - $out['end'] = $meta['end'] ?? null; - $out['status'] = $meta['status'] ?? null; + 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; - $log = file_get_contents($lf); - $parts = preg_split('/━{3,}[^\n]*SUMMARY[^\n]*/u', $log); - $blk = count($parts) > 1 ? end($parts) : $log; + $lf = $base . '.log'; + 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; - if (preg_match('/Tracked:\s*([\d,]+)\s*files\s*\(([\d,]+)/u', $blk, $m)) { - $out['tracked'] = (int)str_replace(',', '', $m[1]); - $out['total'] = (int)str_replace(',', '', $m[2]); + if (preg_match('/Tracked:\s*([\d,]+)\s*files\s*\(([\d,]+)/u', $blk, $m)) { + $out['tracked'] = (int)str_replace(',', '', $m[1]); + $out['total'] = (int)str_replace(',', '', $m[2]); + } + if (preg_match('/Orphans:\s*([\d,]+)\s*files\s*\(([^)]+)\)/u', $blk, $m)) { + $out['orphans'] = (int)str_replace(',', '', $m[1]); + $out['orphans_sz'] = trim($m[2]); + } + if (preg_match('/Junk:\s*([\d,]+)\s*files/u', $blk, $m)) { + $out['junk'] = (int)str_replace(',', '', $m[1]); + } + } } - if (preg_match('/Orphans:\s*([\d,]+)\s*files\s*\(([^)]+)\)/u', $blk, $m)) { - $out['orphans'] = (int)str_replace(',', '', $m[1]); - $out['orphans_sz'] = trim($m[2]); - } - 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,17 +187,39 @@ 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; - $meta = json_decode(file_get_contents($jf), true) ?: []; - $out['last_run'] = $meta['start'] ?? null; - $out['status'] = $meta['status'] ?? null; + if (file_exists($jf)) { + $meta = json_decode(file_get_contents($jf), true) ?: []; + $out['last_run'] = $meta['start'] ?? null; + $out['status'] = $meta['status'] ?? null; - $lf = $base . '.log'; - if (file_exists($lf)) { - $log = file_get_contents($lf); - 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]; + $lf = $base . '.log'; + if (file_exists($lf)) { + $log = file_get_contents($lf); + 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,17 +258,39 @@ 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; - $meta = json_decode(file_get_contents($jf), true) ?: []; - $out['last_run'] = $meta['start'] ?? null; - $out['status'] = $meta['status'] ?? null; + if (file_exists($jf)) { + $meta = json_decode(file_get_contents($jf), true) ?: []; + $out['last_run'] = $meta['start'] ?? null; + $out['status'] = $meta['status'] ?? null; - $lf = $base . '.log'; - if (file_exists($lf)) { - $log = file_get_contents($lf); - 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]; + $lf = $base . '.log'; + if (file_exists($lf)) { + $log = file_get_contents($lf); + 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; } diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php index 50f1175..6c89fec 100644 --- a/Plugin/unraid/include/common.php +++ b/Plugin/unraid/include/common.php @@ -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) { diff --git a/Plugin/unraid/include/confform.php b/Plugin/unraid/include/confform.php index f1cc720..1970d1a 100644 --- a/Plugin/unraid/include/confform.php +++ b/Plugin/unraid/include/confform.php @@ -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'], diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index b91e9d0..4c0f57c 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -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 &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 { diff --git a/Plugin/unraid/include/scheduler.php b/Plugin/unraid/include/scheduler.php index 90103db..e0dde99 100644 --- a/Plugin/unraid/include/scheduler.php +++ b/Plugin/unraid/include/scheduler.php @@ -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,41 +231,69 @@ 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 = []; - foreach (glob("$dir/*.sh") ?: [] as $path) { - $rel = 'Tools/' . basename($path); - $entry = $schedule[$rel] ?? []; - $scripts[] = [ - 'id' => $rel, - 'label' => vv_pretty_label(basename($path, '.sh')), - 'desc' => vv_script_description($path), - 'enabled' => (bool)($entry['enabled'] ?? false), - 'cron' => $entry['cron'] ?? '', - 'log_enabled' => (bool)($entry['log_enabled'] ?? false), - ]; + + $collect = function(string $dir, string $relPrefix) use ($schedule, $EXCLUDE, &$scripts): void { + foreach (glob("$dir/*.sh") ?: [] as $path) { + $base = basename($path); + if (in_array($base, $EXCLUDE, true)) continue; + $rel = $relPrefix . $base; + $entry = $schedule[$rel] ?? []; + $scripts[] = [ + 'id' => $rel, + 'label' => vv_pretty_label(basename($path, '.sh')), + 'desc' => vv_script_description($path), + 'enabled' => (bool)($entry['enabled'] ?? false), + 'cron' => $entry['cron'] ?? '', + 'log_enabled' => (bool)($entry['log_enabled'] ?? false), + ]; + } + }; + + // General tools + $collect(SCRIPTS_DIR . '/Tools', 'Tools/'); + + // Platform adapter tools (Plugin//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 = []; - foreach (glob("$dir/*.sh") ?: [] as $path) { - $rel = 'Custom/' . basename($path); - $entry = $schedule[$rel] ?? []; - $scripts[] = [ - 'id' => $rel, - 'label' => vv_pretty_label(basename($path, '.sh')), - 'desc' => vv_script_description($path), - 'enabled' => (bool)($entry['enabled'] ?? false), - 'cron' => $entry['cron'] ?? '', - 'log_enabled' => (bool)($entry['log_enabled'] ?? false), - ]; + + $collect = function(string $dir, string $relPrefix) use ($schedule, &$scripts): void { + foreach (glob("$dir/*.sh") ?: [] as $path) { + $rel = $relPrefix . basename($path); + $entry = $schedule[$rel] ?? []; + $scripts[] = [ + 'id' => $rel, + 'label' => vv_pretty_label(basename($path, '.sh')), + 'desc' => vv_script_description($path), + 'enabled' => (bool)($entry['enabled'] ?? false), + 'cron' => $entry['cron'] ?? '', + 'log_enabled' => (bool)($entry['log_enabled'] ?? false), + ]; + } + }; + + $collect(SCRIPTS_DIR . '/Custom', 'Custom/'); + + // Platform adapter custom scripts (Plugin//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// — 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/// - - - -

⬡ Varaverk — Partner Setup

-
HOST1 has been configured. Pull their settings to continue.
- -
- HOST1 detected:
- This server will pull master.conf from HOST1 via Tailscale + SSH.
- Requires SSH keys to be exchanged first (Partnership/ssh_setup.sh). -
- -
- - -
Must match Settings → Identification exactly
-
- -
- - -
- - -
- - - - -

⬡ Varaverk — First Run

-
Set up your server identity before the plugin can start.
+
Set up this server before the plugin can start.
-
- - -
Must match Settings → Identification exactly (case-sensitive)
+ +
+ +
Detecting environment…
+ +
+ + +
Must match Unraid Settings → Identification exactly (case-sensitive)
+
+ +
+ +
+
+ Primary
HOST1 · first server +
+
+ Partner
HOST2+ · joining primary +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ SSH key and master.conf pull are handled automatically after save. +
+
+ + +
-
+ +
+
+
Step 2 of 2
- -
-
- Primary
HOST1 · first to be set up +
⟳ Running auto-populate…
+ +
+ Quick start +
    +
  1. Create your Unraid API key below — needed for live monitor stats
  2. +
  3. Open Scheduler → Edit host.conf — only three things need manual entry:
    + + EMBY_API_KEY — Emby Dashboard → API Keys → + New Key
    + DISCORD_WEBHOOK — for notifications (optional)
    + DAILY_SYNC_SHARES — media paths to rsync nightly
    + Everything else was auto-populated or has working defaults +
  4. +
  5. If partnering: the checklist below will guide you through pulling HOST1's config and running onboard
  6. +
-
- Partner
HOST2+ · joining an existing primary + +
+ + Skip → +
+
+ +
+
Setup checklist
+
Loading…
+ +
-
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
- - - - - - -
+// ── 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'); }); +} + diff --git a/user_script_plug-in.sh b/Plugin/unraid/user_script_plug-in.sh similarity index 98% rename from user_script_plug-in.sh rename to Plugin/unraid/user_script_plug-in.sh index 665b208..26db33c 100755 --- a/user_script_plug-in.sh +++ b/Plugin/unraid/user_script_plug-in.sh @@ -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 # ============================================================================================== diff --git a/git_pull_execute.sh b/git_pull_execute.sh index b8464e7..32d1d26 100755 --- a/git_pull_execute.sh +++ b/git_pull_execute.sh @@ -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" diff --git a/load_config.sh b/load_config.sh index 0280453..36f78be 100755 --- a/load_config.sh +++ b/load_config.sh @@ -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 \ No newline at end of file