#!/usr/bin/env bash # Copyright (c) 2021-2026 community-scripts ORG # Author: community-scripts # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE source <(curl -fsSL ${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/core.func) source <(curl -fsSL ${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/lib/tools.func) set -eEo pipefail color formatting icons set_std_mode SILENT_LOGFILE="/tmp/arr-stack-$$.log" silent() { "$@" >>"$SILENT_LOGFILE" 2>&1; } msg_info() { echo -e "${INFO:-[i]} ${YW}${1}${CL}"; } msg_ok() { echo -e "${CM:-[ok]} ${GN}${1}${CL}"; } msg_warn() { echo -e "${YW}[WARN]${CL} ${1}"; } msg_error() { echo -e "${CROSS:-[x]} ${RD}${1}${CL}"; } msg_step() { echo -e "${BL}==>${CL} ${1}"; } cancelled() { msg_warn "Cancelled at $1."; exit 0; } var_container_storage="${var_container_storage:-}" var_template_storage="${var_template_storage:-}" var_bridge="${var_bridge:-}" var_gateway="${var_gateway:-}" var_cidr="${var_cidr:-24}" var_start_ctid="${var_start_ctid:-}" var_repo="${var_repo:-ProxmoxVE}" var_qbt_password="${var_qbt_password:-}" SUMMARY_FILE="${SUMMARY_FILE:-/root/arr-stack-summary.txt}" QBT_PERMANENT=0 BACKTITLE="Proxmox VE Helper Scripts — arr Stack" TEMP_DIR=$(mktemp -d) _on_exit() { local rc=$? if (( rc != 0 )); then if (( ${#INSTALLED_SLUGS[@]} > 0 )); then orphan_report; fi if [[ -s "$SILENT_LOGFILE" ]]; then echo msg_error "Last 20 lines of ${SILENT_LOGFILE}:" tail -n 20 "$SILENT_LOGFILE" fi fi rm -rf "$TEMP_DIR" } trap _on_exit EXIT declare -A APP SELECTED_ARRS="" SELECTED_CLIENTS="" ORDERED_SLUGS=() INSTALLED_SLUGS=() WIRING_RESULTS=() WIRING_FAILURES=() SYNC_CATEGORIES_SONARR='[5000,5010,5020,5030,5040,5045,5050]' SYNC_CATEGORIES_RADARR='[2000,2010,2020,2030,2040,2045,2050,2060]' SYNC_CATEGORIES_LIDARR='[3000,3010,3020,3030,3040]' header_info() { clear cat <<"EOF" _ _ __ _ _ __ _ __ ___| |_ __ _ ___| | __ / _` | '__| '__| ____ / __| __/ _` |/ __| |/ / | (_| | | | | |____| \__ \ || (_| | (__| < \__,_|_| |_| |___/\__\__,_|\___|_|\_\ EOF } check_root() { if [[ $EUID -ne 0 ]]; then msg_error "Run this script as root." exit 1 fi } check_pve_tools() { local missing=() for cmd in pct pvesh pvesm; do command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") done if (( ${#missing[@]} > 0 )); then msg_error "Missing Proxmox VE tools: ${missing[*]}. Run this on a PVE node." exit 1 fi } wait_for_port() { local ip=$1 port=$2 timeout=${3:-60} elapsed=0 while ! (echo > "/dev/tcp/${ip}/${port}") >/dev/null 2>&1; do sleep 2 elapsed=$((elapsed + 2)) if (( elapsed >= timeout )); then return 1; fi done return 0 } is_valid_ipv4() { local ip=$1 [[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 local a=${BASH_REMATCH[1]} b=${BASH_REMATCH[2]} c=${BASH_REMATCH[3]} d=${BASH_REMATCH[4]} (( a <= 255 && b <= 255 && c <= 255 && d <= 255 )) || return 1 return 0 } form_input_program() { if command -v dialog >/dev/null 2>&1; then echo "dialog" elif whiptail --help 2>&1 | grep -q -- '--form'; then echo "whiptail" else echo "none" fi } seed_catalog() { while IFS='|' read -r slug script port impl apiver kind name contract; do [[ -z "$slug" ]] && continue APP[$slug.script]="$script" APP[$slug.port]="$port" APP[$slug.impl]="$impl" APP[$slug.apiver]="$apiver" APP[$slug.kind]="$kind" APP[$slug.name]="$name" APP[$slug.contract]="$contract" done <<'EOF' prowlarr|prowlarr.sh|9696||v1|indexer|Prowlarr| sonarr|sonarr.sh|8989|Sonarr|v3|arr|Sonarr|SonarrSettings radarr|radarr.sh|7878|Radarr|v3|arr|Radarr|RadarrSettings lidarr|lidarr.sh|8686|Lidarr|v1|arr|Lidarr|LidarrSettings seerr|seerr.sh|5055||-|requests|Seerr| qbittorrent|qbittorrent.sh|8090|QBittorrent|-|client|qBittorrent|QBittorrentSettings sabnzbd|sabnzbd.sh|7777|Sabnzbd|-|client|SABnzbd|SabnzbdSettings EOF } pick_storage() { if [[ -n "$var_container_storage" ]]; then msg_info "Container storage (from env): ${var_container_storage}" else local options=() row name type while IFS= read -r row; do name=$(awk '{print $1}' <<<"$row") type=$(awk '{print $2}' <<<"$row") [[ -z "$name" ]] && continue options+=("$name" "$type") done < <(pvesm status -content rootdir 2>/dev/null | awk 'NR>1') if (( ${#options[@]} == 0 )); then msg_error "No PVE storage with content 'rootdir' available." exit 1 fi if (( ${#options[@]} == 2 )); then var_container_storage="${options[0]}" msg_info "Container storage (only option): ${var_container_storage}" else var_container_storage=$(whiptail --backtitle "$BACKTITLE" \ --title "Container Storage" \ --menu "Pick a PVE storage for the container rootfs:" 20 70 10 \ "${options[@]}" 3>&1 1>&2 2>&3) || cancelled "storage pick" fi fi if [[ -z "$var_template_storage" ]]; then var_template_storage=$(pvesm status -content vztmpl 2>/dev/null \ | awk 'NR>1 && $1=="local" {print $1; exit}') [[ -z "$var_template_storage" ]] && var_template_storage=$(pvesm status -content vztmpl 2>/dev/null \ | awk 'NR>1 {print $1; exit}') fi [[ -n "$var_template_storage" ]] && msg_info "Template storage: ${var_template_storage}" } pick_network_defaults() { if [[ -z "$var_bridge" ]]; then local options=() b while IFS= read -r b; do [[ -n "$b" ]] && options+=("$b" "") done < <(awk '/^iface vmbr/ {print $2}' /etc/network/interfaces 2>/dev/null) if (( ${#options[@]} == 0 )); then options=("vmbr0" "") fi var_bridge=$(whiptail --backtitle "$BACKTITLE" \ --title "Network Bridge" \ --menu "Pick the Linux bridge for all containers:" 15 60 6 \ "${options[@]}" 3>&1 1>&2 2>&3) || cancelled "bridge pick" fi local default_gw default_gw=$(ip -4 route show default | awk '{print $3}' | head -n1) while [[ -z "$var_gateway" ]] || ! is_valid_ipv4 "$var_gateway"; do var_gateway=$(whiptail --backtitle "$BACKTITLE" \ --title "Gateway" \ --inputbox "IPv4 gateway for the container subnet (leave blank for default: $default_gw):" 10 70 \ "${var_gateway:-$default_gw}" 3>&1 1>&2 2>&3) || cancelled "gateway prompt" if [[ -z "$var_gateway" && -n "$default_gw" ]]; then var_gateway="$default_gw" fi if ! is_valid_ipv4 "$var_gateway"; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Not a valid IPv4 address: ${var_gateway}" 8 60 var_gateway="" fi done while true; do var_cidr=$(whiptail --backtitle "$BACKTITLE" \ --title "CIDR Mask" \ --inputbox "Network mask (1-32, e.g. 24):" 10 60 \ "${var_cidr:-24}" 3>&1 1>&2 2>&3) || cancelled "CIDR prompt" if [[ "$var_cidr" =~ ^[0-9]+$ ]] && (( var_cidr >= 1 && var_cidr <= 32 )); then break fi whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "CIDR must be an integer between 1 and 32." 8 60 done msg_info "Bridge ${var_bridge} | gateway ${var_gateway} | mask /${var_cidr}" } pick_apps() { while true; do local choice choice=$(whiptail --backtitle "$BACKTITLE" \ --title "Pick *arr Apps" \ --checklist "Prowlarr is always installed. Pick additional apps:" 16 70 6 \ "sonarr" "Sonarr (TV)" ON \ "radarr" "Radarr (Movies)" ON \ "lidarr" "Lidarr (Music)" OFF \ "seerr" "Seerr (Requests)" OFF \ 3>&1 1>&2 2>&3) || cancelled "*arr app pick" SELECTED_ARRS=$(echo "$choice" | tr -d '"') if [[ -z "$SELECTED_ARRS" ]]; then if whiptail --backtitle "$BACKTITLE" --title "Confirm" \ --yesno "You picked no *arr apps. Only Prowlarr will be installed and there will be nothing to wire. Continue anyway?" 10 70; then return fi continue fi return done } pick_clients() { local choice choice=$(whiptail --backtitle "$BACKTITLE" \ --title "Pick Download Clients" \ --checklist "Optional download clients to install + wire:" 14 70 4 \ "qbittorrent" "qBittorrent (Torrents)" ON \ "sabnzbd" "SABnzbd (Usenet)" OFF \ 3>&1 1>&2 2>&3) || cancelled "download client pick" SELECTED_CLIENTS=$(echo "$choice" | tr -d '"') } _gen_password() { local p p=$(openssl rand -base64 18 2>/dev/null | tr -dc 'A-Za-z0-9' | cut -c1-16) [[ -z "$p" ]] && p="ChangeMe${RANDOM}${RANDOM}" printf '%s' "$p" } pick_qbittorrent_password() { [[ " $SELECTED_CLIENTS " == *" qbittorrent "* ]] || return [[ -n "$var_qbt_password" ]] && { msg_info "qBittorrent password (from env) will be used."; return; } local choice choice=$(whiptail --backtitle "$BACKTITLE" \ --title "qBittorrent WebUI Password" \ --menu "Set the qBittorrent admin password:" 15 70 2 \ "generate" "Auto-generate a strong random password (recommended)" \ "manual" "Enter my own password" \ 3>&1 1>&2 2>&3) || cancelled "qBittorrent password pick" if [[ "$choice" == "generate" ]]; then var_qbt_password=$(_gen_password) msg_info "A random qBittorrent password was generated (shown in the final summary)." return fi local pw1 pw2 while true; do pw1=$(whiptail --backtitle "$BACKTITLE" --title "qBittorrent Password" \ --passwordbox "Enter a WebUI password (min 6 chars):" 10 60 \ 3>&1 1>&2 2>&3) || cancelled "qBittorrent password entry" pw2=$(whiptail --backtitle "$BACKTITLE" --title "Confirm Password" \ --passwordbox "Re-enter the password:" 10 60 \ 3>&1 1>&2 2>&3) || cancelled "qBittorrent password confirm" if [[ "$pw1" != "$pw2" ]]; then whiptail --backtitle "$BACKTITLE" --title "Mismatch" \ --msgbox "Passwords do not match. Try again." 8 50 continue fi if (( ${#pw1} < 6 )); then whiptail --backtitle "$BACKTITLE" --title "Too short" \ --msgbox "Password must be at least 6 characters." 8 50 continue fi var_qbt_password="$pw1" return done } compute_ordered_slugs() { ORDERED_SLUGS=("prowlarr") local s for s in $SELECTED_ARRS; do [[ "$s" == "seerr" ]] && continue ORDERED_SLUGS+=("$s") done for s in $SELECTED_CLIENTS; do ORDERED_SLUGS+=("$s") done for s in $SELECTED_ARRS; do [[ "$s" == "seerr" ]] && ORDERED_SLUGS+=("seerr") done } pick_ip_mode_and_ips() { while true; do local mode mode=$(whiptail --backtitle "$BACKTITLE" \ --title "IP Entry Mode" \ --menu "How would you like to enter IP addresses?" 15 75 2 \ "list" "Enter all IPs at once (space- or comma-separated)" \ "form" "Enter each IP in a form" \ 3>&1 1>&2 2>&3) || cancelled "IP entry mode pick" case "$mode" in list) _collect_ips_list_mode; return ;; form) if _collect_ips_one_by_one; then return fi ;; esac done } _collect_ips_list_mode() { local expected_n=${#ORDERED_SLUGS[@]} local hint="" s for s in "${ORDERED_SLUGS[@]}"; do hint+=" ${s}"$'\n'; done while true; do local raw raw=$(whiptail --backtitle "$BACKTITLE" \ --title "Enter ${expected_n} IPv4 addresses" \ --inputbox "Enter ${expected_n} IPs separated by spaces or commas, in this order:"$'\n\n'"${hint}" \ 22 78 "" 3>&1 1>&2 2>&3) || cancelled "IP list entry" local normalized="${raw//,/ }" local -a ips=() # shellcheck disable=SC2206 ips=( $normalized ) if (( ${#ips[@]} != expected_n )); then whiptail --backtitle "$BACKTITLE" --title "Wrong count" \ --msgbox "Expected ${expected_n} IPs, got ${#ips[@]}. Please re-enter." 8 60 continue fi local ok=1 i for i in "${!ips[@]}"; do if ! is_valid_ipv4 "${ips[$i]}"; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) is not a valid IPv4: ${ips[$i]}" 8 60 ok=0; break fi if [[ "${ips[$i]}" == "$var_gateway" ]]; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) collides with the gateway: ${ips[$i]}" 8 60 ok=0; break fi done (( ok == 0 )) && continue local dup dup=$(printf '%s\n' "${ips[@]}" | sort | uniq -d | head -n1) if [[ -n "$dup" ]]; then whiptail --backtitle "$BACKTITLE" --title "Duplicate IP" \ --msgbox "IP appears more than once: ${dup}" 8 60 continue fi for i in "${!ORDERED_SLUGS[@]}"; do APP[${ORDERED_SLUGS[$i]}.ip]=${ips[$i]} done return done } _collect_ips_one_by_one() { local ui ui=$(form_input_program) if [[ "$ui" == "dialog" ]]; then local expected_n=${#ORDERED_SLUGS[@]} local -a form_fields=() local slug for slug in "${ORDERED_SLUGS[@]}"; do form_fields+=("$slug" "") done while true; do local raw_values if ! raw_values=$(dialog --backtitle "$BACKTITLE" \ --title "Container IP Addresses" \ --form "Enter an IPv4 address for each container:" 22 78 0 \ "${form_fields[@]}" 2>&1 >/dev/tty); then msg_warn "IP form entry cancelled." return 1 fi local -a ips=() mapfile -t ips <<< "$raw_values" if (( ${#ips[@]} != expected_n )); then whiptail --backtitle "$BACKTITLE" --title "Wrong count" \ --msgbox "Expected ${expected_n} IPs, got ${#ips[@]}. Please re-enter." 8 60 continue fi local ok=1 i for i in "${!ips[@]}"; do local ip="${ips[$i]}" if ! is_valid_ipv4 "$ip"; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) is not a valid IPv4: ${ip}" 8 60 ok=0 break fi if [[ "$ip" == "$var_gateway" ]]; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) collides with the gateway: ${ip}" 8 60 ok=0 break fi if ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then whiptail --backtitle "$BACKTITLE" --title "IP In Use" \ --msgbox "Entry $((i+1)) is already in use by another device: ${ip}" 8 60 ok=0 break fi done (( ok == 0 )) && continue local dup dup=$(printf '%s\n' "${ips[@]}" | sort | uniq -d | head -n1) if [[ -n "$dup" ]]; then whiptail --backtitle "$BACKTITLE" --title "Duplicate IP" \ --msgbox "IP appears more than once: ${dup}" 8 60 continue fi for i in "${!ORDERED_SLUGS[@]}"; do APP[${ORDERED_SLUGS[$i]}.ip]=${ips[$i]} done return 0 done fi if [[ "$ui" == "whiptail" ]]; then local expected_n=${#ORDERED_SLUGS[@]} local -a form_fields=() local slug for slug in "${ORDERED_SLUGS[@]}"; do form_fields+=("$slug" "") done while true; do local raw_values if ! raw_values=$(whiptail --backtitle "$BACKTITLE" \ --title "Container IP Addresses" \ --separate-output \ --form "Enter an IPv4 address for each container:" 22 78 "$((expected_n + 4))" \ "${form_fields[@]}" 3>&1 1>&2 2>&3); then msg_warn "IP form entry cancelled." return 1 fi local -a ips=() mapfile -t ips <<< "$raw_values" if (( ${#ips[@]} != expected_n )); then whiptail --backtitle "$BACKTITLE" --title "Wrong count" \ --msgbox "Expected ${expected_n} IPs, got ${#ips[@]}. Please re-enter." 8 60 continue fi local ok=1 i for i in "${!ips[@]}"; do local ip="${ips[$i]}" if ! is_valid_ipv4 "$ip"; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) is not a valid IPv4: ${ip}" 8 60 ok=0 break fi if [[ "$ip" == "$var_gateway" ]]; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Entry $((i+1)) collides with the gateway: ${ip}" 8 60 ok=0 break fi if ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then whiptail --backtitle "$BACKTITLE" --title "IP In Use" \ --msgbox "Entry $((i+1)) is already in use by another device: ${ip}" 8 60 ok=0 break fi done (( ok == 0 )) && continue local dup dup=$(printf '%s\n' "${ips[@]}" | sort | uniq -d | head -n1) if [[ -n "$dup" ]]; then whiptail --backtitle "$BACKTITLE" --title "Duplicate IP" \ --msgbox "IP appears more than once: ${dup}" 8 60 continue fi for i in "${!ORDERED_SLUGS[@]}"; do APP[${ORDERED_SLUGS[$i]}.ip]=${ips[$i]} done return 0 done fi local slug ip running="" last_ip="" default_ip="" for slug in "${ORDERED_SLUGS[@]}"; do if [[ -n "$last_ip" ]]; then local prefix="${last_ip%.*}" local host="${last_ip##*.}" if [[ "$host" =~ ^[0-9]+$ ]] && (( host < 254 )); then default_ip="${prefix}.$((host + 1))" fi fi while true; do local prompt="Enter IPv4 for ${slug}:" [[ -n "$running" ]] && prompt+=$'\n\nAlready assigned:'"$running" ip=$(whiptail --backtitle "$BACKTITLE" \ --title "Container IP Addresses" \ --inputbox "$prompt" 16 60 "$default_ip" 3>&1 1>&2 2>&3) || { msg_warn "IP entry cancelled."; return 1; } if ! is_valid_ipv4 "$ip"; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Not a valid IPv4: ${ip}" 8 60 continue fi if [[ "$ip" == "$var_gateway" ]]; then whiptail --backtitle "$BACKTITLE" --title "Invalid" \ --msgbox "Collides with the gateway: ${ip}" 8 60 continue fi local dup=0 other_slug other_ip for other_slug in "${ORDERED_SLUGS[@]}"; do [[ "$other_slug" == "$slug" ]] && continue other_ip="${APP[${other_slug}.ip]:-}" [[ -n "$other_ip" && "$other_ip" == "$ip" ]] && { dup=1; break; } done if (( dup )); then whiptail --backtitle "$BACKTITLE" --title "Duplicate" \ --msgbox "Already used by another container: ${ip}" 8 60 continue fi APP[$slug.ip]=$ip running+=$'\n '"${slug} -> ${ip}" last_ip=$ip break done done return 0 } pick_start_ctid() { local default_start if [[ -n "$var_start_ctid" ]]; then default_start="$var_start_ctid" else default_start=$(pvesh get /cluster/nextid 2>/dev/null || echo "100") fi local start start=$(whiptail --backtitle "$BACKTITLE" \ --title "Starting CTID" \ --inputbox "Starting Container ID (in-use IDs are skipped):" 10 60 \ "$default_start" 3>&1 1>&2 2>&3) || cancelled "starting CTID prompt" if ! [[ "$start" =~ ^[0-9]+$ ]]; then msg_error "Invalid CTID: $start" exit 1 fi local id=$start s for s in "${ORDERED_SLUGS[@]}"; do while pct status "$id" >/dev/null 2>&1; do id=$((id + 1)) (( id > 999999 )) && { msg_error "Ran out of CTID space."; exit 1; } done APP[$s.ctid]=$id id=$((id + 1)) done } confirm_summary() { local lines="" s for s in "${ORDERED_SLUGS[@]}"; do lines+=" $(printf '%-12s ctid=%-5s ip=%-16s port=%s' \ "$s" "${APP[$s.ctid]}" "${APP[$s.ip]}" "${APP[$s.port]}")"$'\n' done local body="About to create these containers and wire them together:"$'\n\n'"${lines}"$'\n'"Storage: ${var_container_storage} | Bridge: ${var_bridge} | Gateway: ${var_gateway} | Mask: /${var_cidr}" whiptail --backtitle "$BACKTITLE" --title "Confirm" \ --yesno "$body" 22 78 || { msg_warn "User cancelled."; exit 0; } } orphan_report() { if (( ${#INSTALLED_SLUGS[@]} == 0 )); then return; fi msg_error "Containers already created (to clean up, run):" local s for s in "${INSTALLED_SLUGS[@]}"; do echo " pct stop ${APP[$s.ctid]} && pct destroy ${APP[$s.ctid]} # ${s}" done } install_loop() { local total=${#ORDERED_SLUGS[@]} idx=0 local s script_file ip ctid port exec 4> >(whiptail --backtitle "$BACKTITLE" --title "Installing Containers" --gauge "Starting installation..." 10 70 0) for s in "${ORDERED_SLUGS[@]}"; do idx=$((idx + 1)) ip="${APP[$s.ip]}" ctid="${APP[$s.ctid]}" port="${APP[$s.port]}" script_file="$TEMP_DIR/${s}.sh" local base_pct=$(( (idx - 1) * 100 / total )) local half_pct=$(( base_pct + (50 / total) )) local full_pct=$(( idx * 100 / total )) echo -e "XXX\n${base_pct}\n[${idx}/${total}] Downloading ct/${s}.sh...\nXXX" >&4 $STD curl -fsSL \ "https://raw.githubusercontent.com/community-scripts/${var_repo}/main/ct/${s}.sh" \ -o "$script_file" if [[ ! -s "$script_file" ]]; then exec 4>&- msg_error "Empty/failed download for ${s}" exit 1 fi echo -e "XXX\n${half_pct}\n[${idx}/${total}] Installing ${s} -> ctid=${ctid} ip=${ip}/${var_cidr}\nXXX" >&4 $STD env \ MODE=generated mode=generated PHS_SILENT=1 \ var_ctid="$ctid" \ var_hostname="$s" \ var_brg="$var_bridge" \ var_net="${ip}/${var_cidr}" \ var_gateway="$var_gateway" \ var_container_storage="$var_container_storage" \ var_template_storage="$var_template_storage" \ bash "$script_file" INSTALLED_SLUGS+=("$s") if [[ "${APP[$s.kind]}" == "arr" || "${APP[$s.kind]}" == "indexer" ]]; then echo -e "XXX\n${half_pct}\n[${idx}/${total}] Waiting for ${s} to listen on ${port}...\nXXX" >&4 if ! wait_for_port "$ip" "$port" 90; then # Handled silently; warning is re-issued during extraction : fi fi echo -e "XXX\n${full_pct}\n[${idx}/${total}] Installed ${s}\nXXX" >&4 done exec 4>&- sleep 0.1 for s in "${ORDERED_SLUGS[@]}"; do msg_ok "Installed ${s}" done } extract_arr_key() { local slug=$1 ctid=$2 ip=$3 port=$4 local config_dir="/var/lib/${slug}/config.xml" msg_info "Waiting for ${slug} on ${ip}:${port}..." wait_for_port "$ip" "$port" 240 || { msg_error "${slug} never opened ${port}"; return 1; } local i for ((i=0; i<60; i++)); do if pct exec "$ctid" -- test -f "$config_dir" 2>/dev/null; then break; fi sleep 2 done local key key=$(pct exec "$ctid" -- sed -n 's:.*\([^<]*\).*:\1:p' "$config_dir" 2>/dev/null | head -n1 || true) if [[ -z "$key" ]]; then msg_error "Failed to extract API key for ${slug} (config: ${config_dir})" return 1 fi APP[$slug.apikey]="$key" msg_ok "${slug} apikey extracted (${key:0:6}…)" } extract_qbittorrent_password() { local ctid=$1 ip=$2 APP[qbittorrent.user]="admin" msg_info "Waiting for qbittorrent on ${ip}:8090..." if ! wait_for_port "$ip" 8090 240; then msg_warn "qbittorrent never opened 8090; assuming legacy default admin/adminadmin." APP[qbittorrent.pass]="adminadmin" return 1 fi # The community-script installs qBittorrent with a hardcoded adminadmin password. # We skip searching for a temporary password to prevent a 60s hang, # since we will overwrite the password with the user's custom one anyway. APP[qbittorrent.pass]="adminadmin" } qbt_password_hash() { # qBittorrent WebUI password format: @ByteArray(:) # PBKDF2-HMAC-SHA512, 100000 iterations, 16-byte salt, 64-byte derived key. # Pure Perl (Digest::SHA + MIME::Base64 are core modules, always present on PVE). local plain=$1 PW="$plain" perl -e ' use strict; use warnings; use Digest::SHA qw(hmac_sha512); use MIME::Base64 qw(encode_base64); my $pw = $ENV{PW}; open(my $r, "<", "/dev/urandom") or die "urandom: $!"; my $salt; read($r, $salt, 16) == 16 or die "salt read"; close($r); my $iter = 100000; # dkLen (64) == hLen (64), so only block T_1 is needed. my $u = hmac_sha512($salt . pack("N", 1), $pw); my $t = $u; for (2 .. $iter) { $u = hmac_sha512($u, $pw); $t ^= $u; } print "\@ByteArray(" . encode_base64($salt, "") . ":" . encode_base64($t, "") . ")"; ' 2>/dev/null } set_qbittorrent_permanent_password() { local ctid=$1 plain=$2 local conf hashval localconf edited meta uid gid perms pushed=1 conf=$(pct exec "$ctid" -- bash -c 'find /root /home /opt /var/lib -name qBittorrent.conf 2>/dev/null | head -n1' 2>/dev/null || true) if [[ -z "$conf" ]]; then msg_warn "qBittorrent.conf not found in ctid ${ctid}; keeping temporary password." return 1 fi hashval=$(qbt_password_hash "$plain") if [[ -z "$hashval" ]]; then msg_warn "Could not compute qBittorrent password hash; keeping temporary password." return 1 fi localconf="$TEMP_DIR/qBittorrent.conf" if ! pct pull "$ctid" "$conf" "$localconf" >/dev/null 2>&1; then msg_warn "Could not read ${conf} from ctid ${ctid}; keeping temporary password." return 1 fi meta=$(pct exec "$ctid" -- stat -c '%u %g %a' "$conf" 2>/dev/null || true) read -r uid gid perms <<<"$meta" # qbittorrent-nox rewrites its conf on shutdown, so stop it before editing. pct exec "$ctid" -- systemctl stop qbittorrent-nox >/dev/null 2>&1 || true # Insert/replace WebUI\Password_PBKDF2 under [Preferences], preserving the rest. # Done in Perl so the literal backslash in the key is unambiguous. edited="$TEMP_DIR/qBittorrent.conf.new" if ! QBT_HASH="$hashval" QBT_SRC="$localconf" QBT_DST="$edited" perl -e ' use strict; use warnings; my $key = q{WebUI\Password_PBKDF2}; my $hash = $ENV{QBT_HASH}; open(my $in, "<", $ENV{QBT_SRC}) or die "read: $!"; my @lines = <$in>; close($in); my @out; my $inprefs = 0; my $done = 0; for my $ln (@lines) { if ($ln =~ /^\[/) { if ($inprefs && !$done) { push @out, qq{$key="$hash"\n}; $done = 1; } $inprefs = ($ln =~ /^\[Preferences\]\s*$/) ? 1 : 0; } if ($inprefs && index($ln, "$key=") == 0) { if (!$done) { push @out, qq{$key="$hash"\n}; $done = 1; } next; } push @out, $ln; } unless ($done) { push @out, "[Preferences]\n" unless $inprefs; push @out, qq{$key="$hash"\n}; } open(my $o, ">", $ENV{QBT_DST}) or die "write: $!"; print $o @out; close($o); '; then msg_warn "Failed to edit qBittorrent.conf; restarting service with temporary password." pct exec "$ctid" -- systemctl start qbittorrent-nox >/dev/null 2>&1 || true return 1 fi if [[ -n "$uid" && -n "$gid" && -n "$perms" ]]; then pct push "$ctid" "$edited" "$conf" --user "$uid" --group "$gid" --perms "$perms" >/dev/null 2>&1 || pushed=0 else pct push "$ctid" "$edited" "$conf" >/dev/null 2>&1 || pushed=0 fi pct exec "$ctid" -- systemctl start qbittorrent-nox >/dev/null 2>&1 || true if (( pushed == 0 )); then msg_warn "Could not write updated qBittorrent.conf to ctid ${ctid}; keeping temporary password." return 1 fi PASS_BY_SLUG[qbittorrent]="$plain" APP[qbittorrent.pass]="$plain" QBT_PERMANENT=1 msg_ok "qBittorrent permanent WebUI password set." } extract_sabnzbd_key() { local ctid=$1 ip=$2 msg_info "Waiting for sabnzbd on ${ip}:7777..." wait_for_port "$ip" 7777 240 || { msg_warn "sabnzbd never opened 7777"; return 1; } local ini="" candidate for candidate in /opt/sabnzbd/sabnzbd.ini /root/.sabnzbd/sabnzbd.ini /etc/sabnzbd/sabnzbd.ini; do if pct exec "$ctid" -- test -f "$candidate" 2>/dev/null; then ini="$candidate"; break fi done if [[ -z "$ini" ]]; then msg_warn "Could not locate sabnzbd.ini inside ctid ${ctid}; SABnzbd will need manual setup." return 1 fi local key="" i for ((i=0; i<60; i++)); do key=$(pct exec "$ctid" -- awk -F' *= *' '/^api_key/ {print $2; exit}' "$ini" 2>/dev/null || true) [[ -n "$key" ]] && break sleep 2 done if [[ -z "$key" ]]; then msg_warn "sabnzbd api_key not yet written. Open the web wizard once at http://${ip}:7777 and rerun wiring." return 1 fi APP[sabnzbd.apikey]="$key" msg_ok "sabnzbd apikey extracted (${key:0:6}…)" } wait_and_extract_keys() { msg_step "Extracting credentials & API keys" local s ctid ip port for s in "${ORDERED_SLUGS[@]}"; do ctid="${APP[$s.ctid]}" ip="${APP[$s.ip]}" port="${APP[$s.port]}" case "${APP[$s.kind]}" in indexer|arr) extract_arr_key "$s" "$ctid" "$ip" "$port" || true ;; client) if [[ "$s" == "qbittorrent" ]]; then extract_qbittorrent_password "$ctid" "$ip" || true [[ -z "$var_qbt_password" ]] && var_qbt_password=$(_gen_password) set_qbittorrent_permanent_password "$ctid" "$var_qbt_password" || true elif [[ "$s" == "sabnzbd" ]]; then extract_sabnzbd_key "$ctid" "$ip" || true fi ;; requests) msg_warn "Seerr requires the web first-run wizard. URL + keys will be in the summary." ;; esac done } record_wiring() { WIRING_RESULTS+=("$1"); } record_failure() { WIRING_FAILURES+=("$1"); } api_post() { local url=$1 apikey=$2 payload=$3 label=$4 local resp status="" resp=$(curl -fsS --max-time 30 --retry 2 \ -H "X-Api-Key: $apikey" \ -H "Content-Type: application/json" \ -X POST "$url" -d "$payload" \ -w '\n__HTTP__%{http_code}' 2>&1) || status="curl_fail" local code="" if [[ "$resp" =~ __HTTP__([0-9]+)$ ]]; then code="${BASH_REMATCH[1]}" fi if [[ "$status" == "curl_fail" || -z "$code" || "$code" -ge 400 ]]; then record_failure "${label} FAIL (http ${code:-?})" msg_warn "${label} failed (http ${code:-?})" return 1 fi record_wiring "${label} OK" msg_ok "${label}" } probe_lidarr_api_version() { if [[ -z "${APP[lidarr.apikey]:-}" ]]; then return; fi local ip="${APP[lidarr.ip]}" key="${APP[lidarr.apikey]}" if curl -fsS --max-time 10 -H "X-Api-Key: $key" \ "http://${ip}:8686/api/v3/system/status" >/dev/null 2>&1; then APP[lidarr.apiver]="v3" msg_info "Lidarr supports /api/v3 — using v3 for wiring." fi } wire_arrs_into_prowlarr() { local prowlarr_ip="${APP[prowlarr.ip]}" local prowlarr_key="${APP[prowlarr.apikey]:-}" if [[ -z "$prowlarr_key" ]]; then msg_warn "Skipping Prowlarr wiring — no Prowlarr API key." return fi local s sync_cats payload for s in $SELECTED_ARRS; do [[ "$s" == "seerr" ]] && continue local key="${APP[$s.apikey]:-}" if [[ -z "$key" ]]; then record_failure "Prowlarr -> ${APP[$s.name]} FAIL (no apikey)" continue fi case "$s" in sonarr) sync_cats="$SYNC_CATEGORIES_SONARR" ;; radarr) sync_cats="$SYNC_CATEGORIES_RADARR" ;; lidarr) sync_cats="$SYNC_CATEGORIES_LIDARR" ;; *) sync_cats='[]' ;; esac payload=$(jq -n \ --arg name "${APP[$s.name]}" \ --arg impl "${APP[$s.impl]}" \ --arg contract "${APP[$s.contract]}" \ --arg prowlarr_url "http://${prowlarr_ip}:9696" \ --arg base_url "http://${APP[$s.ip]}:${APP[$s.port]}" \ --arg apikey "$key" \ --argjson sync_cats "$sync_cats" \ '{ name: $name, syncLevel: "fullSync", implementation: $impl, implementationName: $impl, configContract: $contract, tags: [], fields: [ { name: "prowlarrUrl", value: $prowlarr_url }, { name: "baseUrl", value: $base_url }, { name: "apiKey", value: $apikey }, { name: "syncCategories", value: $sync_cats } ] }') api_post "http://${prowlarr_ip}:9696/api/v1/applications" \ "$prowlarr_key" "$payload" \ "Prowlarr -> ${APP[$s.name]}" || true done } wire_clients_into_arrs() { local arr client arr_key arr_ip arr_port api_ver category_field category_name payload url sab_key for arr in $SELECTED_ARRS; do [[ "$arr" == "seerr" ]] && continue arr_key="${APP[$arr.apikey]:-}" if [[ -z "$arr_key" ]]; then msg_warn "Skipping download-client wiring for ${arr} — no API key." continue fi arr_ip="${APP[$arr.ip]}" arr_port="${APP[$arr.port]}" api_ver="${APP[$arr.apiver]}" case "$arr" in sonarr) category_field="tvCategory"; category_name="tv-sonarr" ;; radarr) category_field="movieCategory"; category_name="radarr" ;; lidarr) category_field="musicCategory"; category_name="lidarr" ;; esac for client in $SELECTED_CLIENTS; do url="http://${arr_ip}:${arr_port}/api/${api_ver}/downloadclient?forceSave=true" if [[ "$client" == "qbittorrent" ]]; then payload=$(jq -n \ --arg host "${APP[qbittorrent.ip]}" \ --argjson port 8090 \ --arg user "${APP[qbittorrent.user]}" \ --arg pass "${APP[qbittorrent.pass]}" \ --arg category_field "$category_field" \ --arg category_name "$category_name" \ '{ enable: true, protocol: "torrent", priority: 1, name: "qBittorrent", implementation: "QBittorrent", implementationName: "qBittorrent", configContract: "QBittorrentSettings", tags: [], fields: [ { name: "host", value: $host }, { name: "port", value: $port }, { name: "useSsl", value: false }, { name: "username", value: $user }, { name: "password", value: $pass }, { name: $category_field, value: $category_name } ] }') api_post "$url" "$arr_key" "$payload" \ "${APP[$arr.name]} -> qBittorrent" || true elif [[ "$client" == "sabnzbd" ]]; then sab_key="${APP[sabnzbd.apikey]:-}" if [[ -z "$sab_key" ]]; then record_failure "${APP[$arr.name]} -> SABnzbd FAIL (no sab apikey)" continue fi payload=$(jq -n \ --arg host "${APP[sabnzbd.ip]}" \ --argjson port 7777 \ --arg apikey "$sab_key" \ --arg category_field "$category_field" \ --arg category_name "$category_name" \ '{ enable: true, protocol: "usenet", priority: 1, name: "SABnzbd", implementation: "Sabnzbd", implementationName: "SABnzbd", configContract: "SabnzbdSettings", tags: [], fields: [ { name: "host", value: $host }, { name: "port", value: $port }, { name: "apiKey", value: $apikey }, { name: "useSsl", value: false }, { name: $category_field, value: $category_name } ] }') api_post "$url" "$arr_key" "$payload" \ "${APP[$arr.name]} -> SABnzbd" || true fi done done } wire_apis() { msg_step "Wiring apps together via HTTP APIs" probe_lidarr_api_version wire_arrs_into_prowlarr wire_clients_into_arrs if [[ " $SELECTED_ARRS " == *" seerr "* ]]; then record_wiring "Seerr -> (manual via web wizard)" msg_warn "Seerr can't be wired headlessly. URLs and keys are in the summary." fi } write_summary() { msg_step "Writing summary" local now host now=$(date '+%Y-%m-%d %H:%M:%S %Z') host=$(hostname) local lines=() lines+=( "\e[1;36m============================================================\e[0m" ) lines+=( "" ) lines+=( "\e[1;33m[Shared settings]\e[0m" ) lines+=( " Bridge: ${var_bridge}" ) lines+=( " Gateway: ${var_gateway}" ) lines+=( " CIDR: /${var_cidr}" ) lines+=( " CT storage: ${var_container_storage}" ) lines+=( " Template: ${var_template_storage}" ) lines+=( "" ) lines+=( "\e[1;33m[Containers]\e[0m" ) local s for s in "${ORDERED_SLUGS[@]}"; do lines+=( "$(printf ' \e[1m%-12s\e[0m ctid=%-5s ip=%-16s \e[4murl=http://%s:%s\e[0m' \ "$s" "${APP[$s.ctid]}" "${APP[$s.ip]}" "${APP[$s.ip]}" "${APP[$s.port]}")" ) done lines+=( "" ) lines+=( "\e[1;33m[Credentials & API keys]\e[0m" ) for s in "${ORDERED_SLUGS[@]}"; do case "${APP[$s.kind]}" in indexer|arr) if [[ -n "${APP[$s.apikey]:-}" ]]; then lines+=( "$(printf ' %-12s apikey: \e[32m%s\e[0m' "$s" "${APP[$s.apikey]}")" ) else lines+=( "$(printf ' %-12s apikey: \e[31m(not extracted)\e[0m' "$s")" ) fi ;; client) if [[ "$s" == "qbittorrent" ]]; then lines+=( "$(printf ' %-12s user: \e[32m%s\e[0m' "$s" "${APP[qbittorrent.user]:-admin}")" ) elif [[ "$s" == "sabnzbd" ]]; then if [[ -n "${APP[sabnzbd.apikey]:-}" ]]; then lines+=( "$(printf ' %-12s apikey: \e[32m%s\e[0m' "$s" "${APP[sabnzbd.apikey]}")" ) else lines+=( "$(printf ' %-12s apikey: \e[33m(open web wizard at http://%s:7777 once)\e[0m' "$s" "${APP[sabnzbd.ip]}")" ) fi fi ;; requests) lines+=( "$(printf ' %-12s \e[33m(set during first-run web wizard)\e[0m' "$s")" ) ;; esac done lines+=( "" ) lines+=( "\e[1;33m[Wired automatically]\e[0m" ) if (( ${#WIRING_RESULTS[@]} == 0 )); then lines+=( " (nothing)" ) else local w for w in "${WIRING_RESULTS[@]}"; do lines+=( " \e[32m✔ ${w}\e[0m" ); done fi lines+=( "" ) if (( ${#WIRING_FAILURES[@]} > 0 )); then lines+=( "\e[1;31m[Wiring failures]\e[0m" ) local f for f in "${WIRING_FAILURES[@]}"; do lines+=( " \e[31m✖ ${f}\e[0m" ); done lines+=( "" ) fi lines+=( "\e[1;41;37m !!! MANUAL STEPS STILL REQUIRED !!! \e[0m" ) lines+=( "\e[1;31m------------------------------------------------------------\e[0m" ) lines+=( " - \e[1mProwlarr:\e[0m Add indexers (none ship by default)." ) lines+=( " - \e[1mSonarr/Radarr/Lidarr:\e[0m Set root folders and at least one quality profile." ) if [[ " $SELECTED_CLIENTS " == *" sabnzbd "* ]]; then lines+=( " - \e[1mSABnzbd:\e[0m Open \e[4mhttp://${APP[sabnzbd.ip]}:7777\e[0m and complete the web wizard." ) fi if [[ " $SELECTED_ARRS " == *" seerr "* ]]; then lines+=( " - \e[1mSeerr:\e[0m Open \e[4mhttp://${APP[seerr.ip]}:5055\e[0m, complete the wizard, then add:" ) for s in $SELECTED_ARRS; do [[ "$s" == "seerr" ]] || [[ "$s" == "lidarr" ]] && continue lines+=( " -> \e[1m${APP[$s.name]}\e[0m at \e[4mhttp://${APP[$s.ip]}:${APP[$s.port]}\e[0m (API Key: \e[32m${APP[$s.apikey]:-}\e[0m)" ) done fi lines+=( "\e[1;31m------------------------------------------------------------\e[0m" ) lines+=( "" ) lines+=( "Summary written to \e[36m${SUMMARY_FILE}\e[0m (chmod 600)." ) lines+=( "\e[1;36m============================================================\e[0m" ) local body body=$(printf '%s\n' "${lines[@]}") echo echo -e "$body" # Write raw summary without colors ( umask 077; echo -e "$body" | sed 's/\x1b\[[0-9;]*m//g' > "$SUMMARY_FILE" ) chmod 600 "$SUMMARY_FILE" 2>/dev/null || true msg_ok "Wrote ${SUMMARY_FILE}" } main() { header_info check_root check_pve_tools ensure_dependencies curl whiptail jq iputils-ping seed_catalog pick_storage pick_network_defaults pick_apps pick_clients pick_qbittorrent_password compute_ordered_slugs pick_ip_mode_and_ips pick_start_ctid confirm_summary install_loop wait_and_extract_keys wire_apis write_summary msg_ok "arr-stack provisioning finished." } main "$@"