From 8f985e40a48e7aafac8b6050c67f7ab0fcc0a767 Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:21:43 +0200 Subject: [PATCH] add helpers --- tools/pve/lxc-prehook.sh | 780 ++++++++++++++++++++++++++++++ tools/pve/storage-share-helper.sh | 515 ++++++++++++++++++++ 2 files changed, 1295 insertions(+) create mode 100644 tools/pve/lxc-prehook.sh create mode 100644 tools/pve/storage-share-helper.sh diff --git a/tools/pve/lxc-prehook.sh b/tools/pve/lxc-prehook.sh new file mode 100644 index 00000000..759a8575 --- /dev/null +++ b/tools/pve/lxc-prehook.sh @@ -0,0 +1,780 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 community-scripts ORG +# Optional Proxmox VE LXC Guest Customization Hook Installer +# License: MIT +# +# Purpose: +# Installs a host-side hookscript system for optional LXC guest customization. +# The hookscript evaluates CT tags and optional config files, then installs +# additional packages or applies optional shell/profile tuning inside the CT. +# +# Scope: +# - LXC only +# - opt-in +# - tag/profile based +# - idempotent +# - separate from app install scripts +# +# Notes: +# - Runs on the PVE host +# - Executes customization inside containers via pct exec +# - Intended as a generic post-provisioning/add-on layer + +set -euo pipefail + +function header_info() { + clear + cat <<"EOF" + ____ _ _ _____ ____ _____ ____ _ _ ____ _____ ___ __ __ ___ ___________ + / ___| | | | ____/ ___|_ _| / ___| | | / ___|_ _/ _ \| \/ |_ _|__ / ____| + | | _| | | | _| \___ \ | | | | | | | \___ \ | || | | | |\/| || | / /| _| + | |_| | |_| | |___ ___) || | | |___| |_| |___) || || |_| | | | || | / /_| |___ + \____|\___/|_____|____/ |_| \____|\___/|____/ |_| \___/|_| |_|___/____|_____| + +EOF +} + +YW=$'\033[33m' +GN=$'\033[1;92m' +RD=$'\033[01;31m' +BL=$'\033[1;34m' +CL=$'\033[m' +BFR=$'\r\033[K' +CM="${GN}✓${CL}" +CROSS="${RD}✗${CL}" + +spinner() { + local pid=$! + local delay=0.1 + local spinstr='|/-\' + while kill -0 "$pid" >/dev/null 2>&1; do + local temp=${spinstr#?} + printf " [%c] " "$spinstr" + spinstr=$temp${spinstr%"$temp"} + sleep "$delay" + printf "\b\b\b\b\b\b" + done + printf " \b\b\b\b" +} + +msg_info() { + echo -ne " ${YW}›${CL} $1..." +} + +msg_ok() { + echo -e "${BFR} ${CM} $1" +} + +msg_error() { + echo -e "${BFR} ${CROSS} $1" +} + +require_root() { + if [[ "$EUID" -ne 0 ]]; then + msg_error "Please run this script as root" + exit 1 + fi +} + +require_pve() { + if ! command -v pveversion >/dev/null 2>&1; then + msg_error "This script must be run on a Proxmox VE host" + exit 1 + fi +} + +create_directories() { + msg_info "Creating required directories" + mkdir -p /var/lib/vz/snippets + mkdir -p /usr/local/bin + mkdir -p /etc/default + mkdir -p /etc/pve-auto-customize + mkdir -p /var/lib/pve-auto-customize + msg_ok "Created required directories" +} + +create_main_config() { + msg_info "Creating main configuration" + cat <<'EOF' >/etc/default/pve-auto-customize +# +# Configuration for the Proxmox Automatic Guest Customization Hook +# +# This file is sourced by the host-side hookscript. +# Global defaults live here. +# Per-CT overrides can be placed under: +# /etc/pve-auto-customize/.conf +# +# ----------------------------------------------------------------------------- +# GENERAL +# ----------------------------------------------------------------------------- + +# Space-separated CTIDs to skip entirely +IGNORE_IDS="" + +# If set to 1, customization will only run once per CT unless the marker is removed +RUN_ONCE=1 + +# If set to 1, customization will run on every start +# This overrides RUN_ONCE behavior conceptually, but use with caution +ALWAYS_RUN=0 + +# Marker directory used to prevent repeated execution +MARKER_DIR="/var/lib/pve-auto-customize" + +# Enable verbose logging to journal/syslog-style stdout +VERBOSE=1 + +# Hook phase to execute customization in +# Recommended: post-start +HOOK_PHASE="post-start" + +# Seconds to wait after post-start before attempting pct exec +POST_START_DELAY=8 + +# Retry behavior for pct exec / apt operations +RETRY_COUNT=20 +RETRY_SLEEP=3 + +# ----------------------------------------------------------------------------- +# APT / PACKAGE HANDLING +# ----------------------------------------------------------------------------- + +# Whether to run apt-get update before package installation +APT_UPDATE=1 + +# apt-get install options +APT_INSTALL_OPTS="-y --no-install-recommends" + +# Optional cleanup after installs +APT_AUTOREMOVE=0 +APT_CLEAN=1 + +# ----------------------------------------------------------------------------- +# PROFILE DEFINITIONS +# ----------------------------------------------------------------------------- +# Profiles are intentionally opinionated but optional. +# Assign them via CT tags, e.g.: +# tags: profile_shell;profile_ops +# +# Supported tag formats: +# profile_ +# pkg_ +# +# Example: +# profile_shell -> installs packages from PROFILE_shell_PACKAGES +# pkg_fastfetch -> installs package "fastfetch" + +PROFILE_shell_PACKAGES="bash-completion plocate" +PROFILE_ops_PACKAGES="curl wget unzip jq" +PROFILE_debug_PACKAGES="strace lsof procps" +PROFILE_files_PACKAGES="cifs-utils smbclient" +PROFILE_net_PACKAGES="dnsutils iputils-ping netcat-openbsd" + +# Example disabled profile: +# PROFILE_dev_PACKAGES="git vim tmux" + +# ----------------------------------------------------------------------------- +# SHELL TUNING +# ----------------------------------------------------------------------------- +# Enabled only when one of the following tags is present: +# feature_shellrc +# feature_fastfetch +# +# This is intentionally opt-in and not coupled to profiles automatically. + +ENABLE_BASHRC_TUNING=1 +ENABLE_BASH_COMPLETION_LINE=1 +ENABLE_FASTFETCH_LINE=1 + +# If set to 1 and nala exists, apt/apt-get aliases will be added +ENABLE_NALA_ALIASES=0 + +# ----------------------------------------------------------------------------- +# OPTIONAL FUTURE FEATURES (currently not implemented automatically) +# ----------------------------------------------------------------------------- +# These are placeholders for future expansion. +# +# ENABLE_SERVICE_USER=0 +# SERVICE_USER_NAME="" +# +# ENABLE_EXTERNAL_DB_BOOTSTRAP=0 +# EXTERNAL_DB_TYPE="" +# EXTERNAL_DB_HOST="" +# EXTERNAL_DB_PORT="" +# EXTERNAL_DB_NAME="" +# EXTERNAL_DB_USER="" +# EXTERNAL_DB_PASSWORD="" +# +# ENABLE_ENV_FILE_TEMPLATING=0 +# ENV_FILE_PATH="" +# +# ENABLE_FASTFETCH_CUSTOM_LOGO=0 +# FASTFETCH_LOGO_PATH="" +# +EOF + chmod 0644 /etc/default/pve-auto-customize + msg_ok "Created main configuration" +} + +create_example_override() { + msg_info "Creating example per-CT override" + cat <<'EOF' >/etc/pve-auto-customize/100.conf.example +# +# Example per-CT override for CT 100 +# Rename to: +# /etc/pve-auto-customize/100.conf +# +# Only variables you define here will override global defaults. + +# Example: +# RUN_ONCE=1 +# APT_UPDATE=1 +# POST_START_DELAY=10 +# +# Add extra packages regardless of tags: +# EXTRA_PACKAGES="mc htop" +# +# Disable shellrc tuning for this CT: +# ENABLE_BASHRC_TUNING=0 +# +# Force a local custom profile package definition: +# PROFILE_shell_PACKAGES="bash-completion plocate eza" +EOF + chmod 0644 /etc/pve-auto-customize/100.conf.example + msg_ok "Created example per-CT override" +} + +create_hookscript() { + msg_info "Creating guest customization hookscript" + cat <<'EOF' >/var/lib/vz/snippets/guest-customize.sh +#!/usr/bin/env bash +set -euo pipefail + +VMID="${1:-}" +PHASE="${2:-}" + +CONFIG_FILE="/etc/default/pve-auto-customize" + +log() { + echo "[hookscript-guest-customize] VMID ${VMID}: $1" +} + +fail() { + log "ERROR: $1" + exit 1 +} + +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + fi + + local override="/etc/pve-auto-customize/${VMID}.conf" + if [[ -f "$override" ]]; then + # shellcheck disable=SC1090 + source "$override" + log "Loaded per-CT override: $override" + fi + + : "${IGNORE_IDS:=}" + : "${RUN_ONCE:=1}" + : "${ALWAYS_RUN:=0}" + : "${MARKER_DIR:=/var/lib/pve-auto-customize}" + : "${VERBOSE:=1}" + : "${HOOK_PHASE:=post-start}" + : "${POST_START_DELAY:=8}" + : "${RETRY_COUNT:=20}" + : "${RETRY_SLEEP:=3}" + : "${APT_UPDATE:=1}" + : "${APT_INSTALL_OPTS:=-y --no-install-recommends}" + : "${APT_AUTOREMOVE:=0}" + : "${APT_CLEAN:=1}" + : "${ENABLE_BASHRC_TUNING:=1}" + : "${ENABLE_BASH_COMPLETION_LINE:=1}" + : "${ENABLE_FASTFETCH_LINE:=1}" + : "${ENABLE_NALA_ALIASES:=0}" + : "${PROFILE_shell_PACKAGES:=bash-completion plocate}" + : "${PROFILE_ops_PACKAGES:=curl wget unzip jq}" + : "${PROFILE_debug_PACKAGES:=strace lsof procps}" + : "${PROFILE_files_PACKAGES:=cifs-utils smbclient}" + : "${PROFILE_net_PACKAGES:=dnsutils iputils-ping netcat-openbsd}" + : "${EXTRA_PACKAGES:=}" +} + +is_ignored() { + for ignored in $IGNORE_IDS; do + [[ "$ignored" == "$VMID" ]] && return 0 + done + return 1 +} + +ensure_lxc() { + if ! pct config "$VMID" >/dev/null 2>&1; then + log "Not an LXC or config not found. Skipping." + exit 0 + fi +} + +get_tags() { + pct config "$VMID" | awk -F': ' '/^tags:/ {print $2}' +} + +container_is_running() { + pct status "$VMID" 2>/dev/null | grep -q '^status: running$' +} + +retry_pct_exec() { + local attempt=0 + while true; do + if pct exec "$VMID" -- "$@"; then + return 0 + fi + + attempt=$((attempt + 1)) + if (( attempt >= RETRY_COUNT )); then + return 1 + fi + + sleep "$RETRY_SLEEP" + done +} + +append_unique_line_in_ct() { + local target_file="$1" + local line="$2" + + retry_pct_exec bash -c " + touch '$target_file' + grep -Fqx -- \"$line\" '$target_file' || echo \"$line\" >> '$target_file' + " +} + +resolve_requested_packages() { + local tags="$1" + local pkgs=() + local seen="" + + if [[ -n "$EXTRA_PACKAGES" ]]; then + for pkg in $EXTRA_PACKAGES; do + if [[ ! " $seen " =~ [[:space:]]$pkg[[:space:]] ]]; then + pkgs+=("$pkg") + seen+=" $pkg" + fi + done + fi + + local tag + local tag_list="${tags//;/ }" + for tag in $tag_list; do + case "$tag" in + profile_*) + local profile="${tag#profile_}" + local var="PROFILE_${profile}_PACKAGES" + local profile_pkgs="${!var:-}" + if [[ -n "$profile_pkgs" ]]; then + for pkg in $profile_pkgs; do + if [[ ! " $seen " =~ [[:space:]]$pkg[[:space:]] ]]; then + pkgs+=("$pkg") + seen+=" $pkg" + fi + done + else + log "Profile '$profile' has no package definition" + fi + ;; + pkg_*) + local pkg="${tag#pkg_}" + if [[ -n "$pkg" ]] && [[ ! " $seen " =~ [[:space:]]$pkg[[:space:]] ]]; then + pkgs+=("$pkg") + seen+=" $pkg" + fi + ;; + esac + done + + printf '%s\n' "${pkgs[@]:-}" +} + +run_apt_update_if_needed() { + if [[ "$APT_UPDATE" == "1" ]]; then + log "Running apt-get update" + retry_pct_exec bash -c "export DEBIAN_FRONTEND=noninteractive; apt-get update" + fi +} + +install_packages() { + local packages=("$@") + if (( ${#packages[@]} == 0 )); then + log "No packages requested" + return 0 + fi + + log "Installing packages: ${packages[*]}" + retry_pct_exec bash -c " + export DEBIAN_FRONTEND=noninteractive + apt-get install $APT_INSTALL_OPTS ${packages[*]} + " +} + +run_optional_cleanup() { + if [[ "$APT_AUTOREMOVE" == "1" ]]; then + log "Running apt-get autoremove" + retry_pct_exec bash -c "export DEBIAN_FRONTEND=noninteractive; apt-get autoremove -y" + fi + + if [[ "$APT_CLEAN" == "1" ]]; then + log "Running apt-get clean" + retry_pct_exec bash -c "apt-get clean" + fi +} + +apply_shellrc_features() { + local tags="$1" + local tag_list="${tags//;/ }" + local enable_shellrc=0 + local enable_fastfetch=0 + + for tag in $tag_list; do + case "$tag" in + feature_shellrc) enable_shellrc=1 ;; + feature_fastfetch) enable_fastfetch=1 ;; + esac + done + + if [[ "$ENABLE_BASHRC_TUNING" != "1" ]]; then + log "Shellrc tuning globally disabled" + return 0 + fi + + if [[ "$enable_shellrc" != "1" && "$enable_fastfetch" != "1" ]]; then + log "No shell feature tags found" + return 0 + fi + + log "Applying optional shellrc tuning" + + append_unique_line_in_ct "/root/.bashrc" "" + append_unique_line_in_ct "/root/.bashrc" "# Added by pve-auto-customize" + + if [[ "$enable_shellrc" == "1" ]]; then + append_unique_line_in_ct "/root/.bashrc" "export LS_OPTIONS='--color=auto'" + append_unique_line_in_ct "/root/.bashrc" "eval \"\$(dircolors)\"" + append_unique_line_in_ct "/root/.bashrc" "alias ls='ls \$LS_OPTIONS -lA'" + + if [[ "$ENABLE_BASH_COMPLETION_LINE" == "1" ]]; then + append_unique_line_in_ct "/root/.bashrc" "[ -f /usr/share/bash-completion/bash_completion ] && source /usr/share/bash-completion/bash_completion" + fi + + if [[ "$ENABLE_NALA_ALIASES" == "1" ]]; then + retry_pct_exec bash -c "command -v nala >/dev/null 2>&1" && { + append_unique_line_in_ct "/root/.bashrc" "alias apt='nala'" + append_unique_line_in_ct "/root/.bashrc" "alias apt-get='nala'" + } + fi + fi + + if [[ "$enable_fastfetch" == "1" && "$ENABLE_FASTFETCH_LINE" == "1" ]]; then + retry_pct_exec bash -c "command -v fastfetch >/dev/null 2>&1" && { + append_unique_line_in_ct "/root/.bashrc" "fastfetch" + } + fi +} + +write_marker() { + mkdir -p "$MARKER_DIR" + touch "${MARKER_DIR}/${VMID}.done" +} + +marker_exists() { + [[ -f "${MARKER_DIR}/${VMID}.done" ]] +} + +main() { + [[ -n "$VMID" ]] || fail "Missing VMID" + [[ -n "$PHASE" ]] || fail "Missing phase" + + load_config + ensure_lxc + + if [[ "$PHASE" != "$HOOK_PHASE" ]]; then + log "Phase '$PHASE' does not match configured HOOK_PHASE '$HOOK_PHASE'. Skipping." + exit 0 + fi + + if is_ignored; then + log "CTID is ignored by config" + exit 0 + fi + + if ! container_is_running; then + log "Container not running yet. Skipping." + exit 0 + fi + + if [[ "$HOOK_PHASE" == "post-start" ]] && [[ "$POST_START_DELAY" -gt 0 ]]; then + sleep "$POST_START_DELAY" + fi + + if [[ "$ALWAYS_RUN" != "1" && "$RUN_ONCE" == "1" ]] && marker_exists; then + log "Marker exists. Customization already completed previously." + exit 0 + fi + + local tags + tags="$(get_tags)" + if [[ -z "$tags" && -z "${EXTRA_PACKAGES:-}" ]]; then + log "No tags and no EXTRA_PACKAGES configured. Nothing to do." + if [[ "$RUN_ONCE" == "1" ]]; then + write_marker + fi + exit 0 + fi + + log "--- Starting guest customization ---" + [[ "$VERBOSE" == "1" ]] && log "Tags: ${tags:-}" + + local package_lines + mapfile -t package_lines < <(resolve_requested_packages "$tags") + local packages=() + + for line in "${package_lines[@]}"; do + [[ -n "$line" ]] && packages+=("$line") + done + + if (( ${#packages[@]} > 0 )); then + run_apt_update_if_needed + install_packages "${packages[@]}" + run_optional_cleanup + else + log "Resolved package list is empty" + fi + + apply_shellrc_features "$tags" + + if [[ "$RUN_ONCE" == "1" && "$ALWAYS_RUN" != "1" ]]; then + write_marker + log "Marker written" + fi + + log "--- Guest customization complete ---" +} + +main "$@" +EOF + chmod +x /var/lib/vz/snippets/guest-customize.sh + msg_ok "Created guest customization hookscript" +} + +create_applicator_script() { + msg_info "Creating hook applicator script" + cat <<'EOF' >/usr/local/bin/pve-apply-guest-customize.sh +#!/usr/bin/env bash +set -euo pipefail + +HOOKSCRIPT_VOLUME_ID="local:snippets/guest-customize.sh" +CONFIG_FILE="/etc/default/pve-auto-customize" +LOG_TAG="pve-auto-customize" + +log() { + systemd-cat -t "$LOG_TAG" <<< "$1" +} + +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +fi + +: "${IGNORE_IDS:=}" + +is_ignored() { + local vmid="$1" + for ignored in $IGNORE_IDS; do + [[ "$ignored" == "$vmid" ]] && return 0 + done + return 1 +} + +pct list | awk 'NR>1 {print $1}' | while read -r CTID; do + is_ignored "$CTID" && continue + + if pct config "$CTID" | grep -q '^hookscript:'; then + current_hook="$(pct config "$CTID" | awk -F': ' '/^hookscript:/ {print $2}')" + if [[ "$current_hook" == "$HOOKSCRIPT_VOLUME_ID" ]]; then + continue + fi + fi + + log "Applying hookscript to CT $CTID" + pct set "$CTID" --hookscript "$HOOKSCRIPT_VOLUME_ID" >/dev/null 2>&1 || \ + log "Failed to apply hookscript to CT $CTID" +done +EOF + chmod +x /usr/local/bin/pve-apply-guest-customize.sh + msg_ok "Created hook applicator script" +} + +create_systemd_units() { + msg_info "Creating systemd units" + cat <<'EOF' >/etc/systemd/system/pve-auto-customize.path +[Unit] +Description=Watch for new Proxmox LXC configs and apply guest customization hook + +[Path] +PathModified=/etc/pve/lxc/ + +[Install] +WantedBy=multi-user.target +EOF + + cat <<'EOF' >/etc/systemd/system/pve-auto-customize.service +[Unit] +Description=Automatically add guest customization hookscript to LXCs + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/pve-apply-guest-customize.sh +EOF + msg_ok "Created systemd units" +} + +create_readme() { + msg_info "Creating documentation" + cat <<'EOF' >/etc/pve-auto-customize/README +Proxmox Auto Guest Customize +============================ + +Overview +-------- +This system applies a host-side hookscript to LXC containers. +On container start, the hookscript can optionally: + +- install extra packages based on CT tags +- apply optional shellrc tuning +- honor global config and per-CT overrides +- run only once via marker files + +Tag formats +----------- +profile_ + Uses PROFILE__PACKAGES from config + +pkg_ + Installs the specified package directly + +feature_shellrc + Enables optional root .bashrc tuning + +feature_fastfetch + Adds fastfetch to root .bashrc if installed + +Examples +-------- +tags: profile_shell;profile_ops +tags: pkg_fastfetch;pkg_jq +tags: profile_shell;pkg_fastfetch;feature_shellrc;feature_fastfetch + +Global config +------------- +/etc/default/pve-auto-customize + +Per-CT override +--------------- +/etc/pve-auto-customize/.conf + +Marker files +------------ +/var/lib/pve-auto-customize/.done + +Re-run customization for a CT +----------------------------- +Remove its marker file, e.g. + + rm -f /var/lib/pve-auto-customize/101.done + +Then restart the CT. + +Logs +---- +journalctl -fu pve-auto-customize.service +journalctl -t pve-auto-customize -n 100 +EOF + chmod 0644 /etc/pve-auto-customize/README + msg_ok "Created documentation" +} + +enable_systemd() { + msg_info "Reloading systemd and enabling watcher" + (systemctl daemon-reload && systemctl enable --now pve-auto-customize.path) >/dev/null 2>&1 & + spinner + msg_ok "Enabled watcher" +} + +initial_apply() { + msg_info "Performing initial apply for existing LXCs" + (/usr/local/bin/pve-apply-guest-customize.sh >/dev/null 2>&1) & + spinner + msg_ok "Initial apply complete" +} + +print_summary() { + echo + echo -e "${GN}Installation successful!${CL}" + echo + echo -e "${BL}Files created:${CL}" + echo " /var/lib/vz/snippets/guest-customize.sh" + echo " /usr/local/bin/pve-apply-guest-customize.sh" + echo " /etc/default/pve-auto-customize" + echo " /etc/pve-auto-customize/100.conf.example" + echo " /etc/pve-auto-customize/README" + echo " /etc/systemd/system/pve-auto-customize.path" + echo " /etc/systemd/system/pve-auto-customize.service" + echo + echo -e "${BL}Next steps:${CL}" + echo " 1. Edit /etc/default/pve-auto-customize if needed" + echo " 2. Add tags to a CT, for example:" + echo " pct set 101 --tags 'profile_shell;profile_ops;pkg_fastfetch;feature_shellrc;feature_fastfetch'" + echo " 3. Restart the CT" + echo + echo -e "${BL}Useful commands:${CL}" + echo " pct config 101" + echo " rm -f /var/lib/pve-auto-customize/101.done" + echo " journalctl -fu pve-auto-customize.service" + echo " journalctl -t pve-auto-customize -n 100" + echo +} + +main() { + header_info + require_root + require_pve + + echo -e "\nThis script will install an optional LXC guest customization framework on this Proxmox VE host." + echo -e "It will create files in:" + echo -e " - /var/lib/vz/snippets/" + echo -e " - /usr/local/bin/" + echo -e " - /etc/default/" + echo -e " - /etc/pve-auto-customize/" + echo -e " - /etc/systemd/system/\n" + + read -r -p "Do you want to proceed with the installation? (y/n): " reply + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + msg_error "Installation cancelled" + exit 1 + fi + + echo + create_directories + create_main_config + create_example_override + create_hookscript + create_applicator_script + create_systemd_units + create_readme + enable_systemd + initial_apply + print_summary +} + +main "$@" diff --git a/tools/pve/storage-share-helper.sh b/tools/pve/storage-share-helper.sh new file mode 100644 index 00000000..d9d77452 --- /dev/null +++ b/tools/pve/storage-share-helper.sh @@ -0,0 +1,515 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2026 community-scripts ORG +# Author: GitHub Copilot +# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE + +set -eEuo pipefail + +function header_info() { + clear + cat <<"EOF" + _____ __ ___ _________ + / ___// /_____ _________ _____ ____ / | / _/ __ \ + \__ \/ __/ __ \/ ___/ __ `/ __ `/ _ \ / /| | / // / / / + ___/ / /_/ /_/ / / / /_/ / /_/ / __/ / ___ |_/ // /_/ / + /____/\__/\____/_/ \__,_/\__, /\___/ /_/ |_/___/_____/ + /____/ + + Proxmox Storage Allrounder + SMB | NFS | iSCSI | LVM-on-iSCSI | LXC Mountpoints | Host Shares +EOF +} + +YW="\033[33m" +BL="\033[36m" +GN="\033[1;92m" +RD="\033[01;31m" +CL="\033[m" + +msg_info() { echo -e "${BL}[INFO]${CL} ${YW}$1${CL}"; } +msg_ok() { echo -e "${GN}[OK]${CL} $1"; } +msg_warn() { echo -e "${YW}[WARN]${CL} $1"; } +msg_error() { echo -e "${RD}[ERROR]${CL} $1"; } + +pause() { + read -r -p "Press Enter to continue..." _ +} + +require_root() { + if [[ $EUID -ne 0 ]]; then + msg_error "Run this script as root." + exit 1 + fi +} + +require_pve() { + if ! command -v pct >/dev/null 2>&1 || ! command -v pvesm >/dev/null 2>&1; then + msg_error "This script must run on a Proxmox VE host (pct/pvesm missing)." + exit 1 + fi +} + +ensure_packages() { + local packages=() + + command -v whiptail >/dev/null 2>&1 || packages+=("whiptail") + command -v mount.cifs >/dev/null 2>&1 || packages+=("cifs-utils") + command -v showmount >/dev/null 2>&1 || packages+=("nfs-common") + command -v iscsiadm >/dev/null 2>&1 || packages+=("open-iscsi") + + if [[ ${#packages[@]} -gt 0 ]]; then + msg_info "Installing required packages: ${packages[*]}" + apt update >/dev/null 2>&1 + apt install -y "${packages[@]}" >/dev/null 2>&1 + msg_ok "Dependencies installed" + fi +} + +confirm_start() { + whiptail --backtitle "Proxmox VE Helper Scripts" --title "Storage Share Allrounder" \ + --yesno "This AIO wizard can test and configure SMB/NFS/iSCSI, create/remove Proxmox storages, manage LXC mountpoints and optionally create host shares. Proceed?" 12 96 +} + +read_input() { + local title="$1" + local prompt="$2" + local default_value="${3:-}" + + whiptail --backtitle "Proxmox VE Helper Scripts" --title "$title" \ + --inputbox "$prompt" 11 84 "$default_value" 3>&1 1>&2 2>&3 +} + +read_password() { + local title="$1" + local prompt="$2" + + whiptail --backtitle "Proxmox VE Helper Scripts" --title "$title" \ + --passwordbox "$prompt" 11 84 3>&1 1>&2 2>&3 +} + +confirm_yes_no() { + local title="$1" + local prompt="$2" + + whiptail --backtitle "Proxmox VE Helper Scripts" --title "$title" \ + --yesno "$prompt" 11 84 +} + +manual_smb_test() { + header_info + local server share username password domain vers mount_dir mount_opts + + server=$(read_input "SMB Test" "SMB server/IP (e.g. 10.0.1.9)") || return + share=$(read_input "SMB Test" "Share name (without leading //)" "Proxmox") || return + username=$(read_input "SMB Test" "Username" "proxmox") || return + password=$(read_password "SMB Test" "Password for ${username}") || return + domain=$(read_input "SMB Test" "Domain/Workgroup (optional, leave empty if not needed)") || return + vers=$(read_input "SMB Test" "SMB version (e.g. 3.0, 3.1.1)" "3.1.1") || return + mount_dir="/mnt/test-smb" + + mkdir -p "$mount_dir" + + mount_opts="username=${username},password=${password},vers=${vers},sec=ntlmssp" + if [[ -n "$domain" ]]; then + mount_opts+=";domain=${domain}" + fi + + msg_info "Testing SMB mount on ${mount_dir}" + if mount -t cifs "//${server}/${share}" "$mount_dir" -o "${mount_opts//;/,}" >/dev/null 2>&1; then + touch "${mount_dir}/smb-test-$(date +%s)" >/dev/null 2>&1 || true + umount "$mount_dir" >/dev/null 2>&1 || true + msg_ok "SMB test successful" + else + msg_error "SMB test failed. Check network/firewall/credentials/share permissions." + fi + + pause +} + +manual_nfs_test() { + header_info + local server export_path mount_dir + + server=$(read_input "NFS Test" "NFS server/IP (e.g. 10.0.6.159)") || return + export_path=$(read_input "NFS Test" "NFS export path (e.g. /srv/proxmox-nfs)") || return + mount_dir="/mnt/test-nfs" + + mkdir -p "$mount_dir" + + msg_info "Testing NFS mount on ${mount_dir}" + if mount -t nfs "${server}:${export_path}" "$mount_dir" >/dev/null 2>&1; then + touch "${mount_dir}/nfs-test-$(date +%s)" >/dev/null 2>&1 || true + umount "$mount_dir" >/dev/null 2>&1 || true + msg_ok "NFS test successful" + else + msg_error "NFS test failed. Check export/firewall/network permissions." + fi + + pause +} + +manual_iscsi_discovery() { + header_info + local portal + + portal=$(read_input "iSCSI Discovery" "iSCSI portal IP/FQDN (e.g. 10.0.1.20)") || return + msg_info "Running iSCSI target discovery on ${portal}" + if iscsiadm -m discovery -t sendtargets -p "$portal"; then + msg_ok "iSCSI discovery completed" + else + msg_error "iSCSI discovery failed" + fi + + pause +} + +add_smb_storage() { + header_info + local storage_id server share username password content nodes options + + storage_id=$(read_input "Add SMB/CIFS Storage" "Storage ID (unique, e.g. smb-media)") || return + server=$(read_input "Add SMB/CIFS Storage" "SMB server/IP") || return + share=$(read_input "Add SMB/CIFS Storage" "Share name (without //)") || return + username=$(read_input "Add SMB/CIFS Storage" "Username") || return + password=$(read_password "Add SMB/CIFS Storage" "Password for ${username}") || return + content=$(read_input "Add SMB/CIFS Storage" "Content types (comma separated)" "backup,iso,vztmpl,snippets") || return + nodes=$(read_input "Add SMB/CIFS Storage" "Nodes (optional, comma separated)") || return + options=$(read_input "Add SMB/CIFS Storage" "Mount options (optional, e.g. vers=3.1.1,domain=WORKGROUP)") || return + + local cmd=(pvesm add cifs "$storage_id" --server "$server" --share "$share" --username "$username" --password "$password" --content "$content") + [[ -n "$nodes" ]] && cmd+=(--nodes "$nodes") + [[ -n "$options" ]] && cmd+=(--options "$options") + + if "${cmd[@]}" >/dev/null 2>&1; then + msg_ok "SMB storage '${storage_id}' added" + else + msg_error "Failed to add SMB storage '${storage_id}'" + fi + + pause +} + +add_nfs_storage() { + header_info + local storage_id server export_path content nodes options + + storage_id=$(read_input "Add NFS Storage" "Storage ID (unique, e.g. nfs-vmdata)") || return + server=$(read_input "Add NFS Storage" "NFS server/IP") || return + export_path=$(read_input "Add NFS Storage" "Export path") || return + content=$(read_input "Add NFS Storage" "Content types (comma separated)" "images,rootdir") || return + nodes=$(read_input "Add NFS Storage" "Nodes (optional, comma separated)") || return + options=$(read_input "Add NFS Storage" "Mount options (optional)") || return + + local cmd=(pvesm add nfs "$storage_id" --server "$server" --export "$export_path" --content "$content") + [[ -n "$nodes" ]] && cmd+=(--nodes "$nodes") + [[ -n "$options" ]] && cmd+=(--options "$options") + + if "${cmd[@]}" >/dev/null 2>&1; then + msg_ok "NFS storage '${storage_id}' added" + else + msg_error "Failed to add NFS storage '${storage_id}'" + fi + + pause +} + +add_iscsi_storage() { + header_info + local storage_id portal target nodes + + storage_id=$(read_input "Add iSCSI Storage" "Storage ID (unique, e.g. iscsi-synology)") || return + portal=$(read_input "Add iSCSI Storage" "Portal IP/FQDN") || return + target=$(read_input "Add iSCSI Storage" "Target IQN (e.g. iqn.2000-01.com.synology:...)") || return + nodes=$(read_input "Add iSCSI Storage" "Nodes (optional, comma separated)") || return + + local cmd=(pvesm add iscsi "$storage_id" --portal "$portal" --target "$target") + [[ -n "$nodes" ]] && cmd+=(--nodes "$nodes") + + if "${cmd[@]}" >/dev/null 2>&1; then + msg_ok "iSCSI storage '${storage_id}' added" + else + msg_error "Failed to add iSCSI storage '${storage_id}'" + fi + + pause +} + +add_lvm_on_base_storage() { + header_info + local storage_id base_storage vgname content shared + + storage_id=$(read_input "Add LVM Storage" "LVM Storage ID (unique, e.g. lvm-iscsi01)") || return + base_storage=$(read_input "Add LVM Storage" "Base storage ID (usually iSCSI storage ID)") || return + vgname=$(read_input "Add LVM Storage" "Volume Group name on target") || return + content=$(read_input "Add LVM Storage" "Content types (comma separated)" "images,rootdir") || return + shared=$(read_input "Add LVM Storage" "Shared across nodes? 1=yes, 0=no" "1") || return + + local cmd=(pvesm add lvm "$storage_id" --base "$base_storage" --vgname "$vgname" --content "$content" --shared "$shared") + if "${cmd[@]}" >/dev/null 2>&1; then + msg_ok "LVM storage '${storage_id}' added" + else + msg_error "Failed to add LVM storage '${storage_id}'" + fi + + pause +} + +remove_storage() { + header_info + local storage_id + + storage_id=$(read_input "Remove Storage" "Storage ID to remove") || return + confirm_yes_no "Remove Storage" "Really remove storage '${storage_id}' from Proxmox config?" || return + + if pvesm remove "$storage_id" >/dev/null 2>&1; then + msg_ok "Storage '${storage_id}' removed" + else + msg_error "Failed to remove storage '${storage_id}'" + fi + + pause +} + +find_next_mp_slot() { + local ctid="$1" + local used + + used=$(pct config "$ctid" | awk -F: '/^mp[0-9]+:/ {gsub("mp", "", $1); print $1}') + for i in $(seq 0 255); do + if ! grep -qx "$i" <<< "$used"; then + echo "$i" + return + fi + done + + echo "" +} + +add_lxc_mountpoint() { + header_info + local ctid host_path ct_path mp_slot + + ctid=$(read_input "LXC Mountpoint" "Container ID (CTID)") || return + host_path=$(read_input "LXC Mountpoint" "Host path (must exist, e.g. /mnt/pve/smb-media)") || return + ct_path=$(read_input "LXC Mountpoint" "Container path (e.g. /mnt/media)") || return + + if ! pct config "$ctid" >/dev/null 2>&1; then + msg_error "Container ${ctid} not found" + pause + return + fi + + if [[ ! -d "$host_path" ]]; then + msg_error "Host path does not exist: ${host_path}" + pause + return + fi + + mp_slot=$(find_next_mp_slot "$ctid") + if [[ -z "$mp_slot" ]]; then + msg_error "No free mp slot available for container ${ctid}" + pause + return + fi + + if pct set "$ctid" -mp"$mp_slot" "$host_path",mp="$ct_path" >/dev/null 2>&1; then + msg_ok "Added mp${mp_slot}: ${host_path} -> ${ct_path} on CT ${ctid}" + msg_warn "If CT is unprivileged, ensure UID/GID mapping and filesystem permissions fit your workload." + else + msg_error "Failed to add mountpoint to CT ${ctid}" + fi + + pause +} + +remove_lxc_mountpoint() { + header_info + local ctid mp_key + + ctid=$(read_input "Remove LXC Mountpoint" "Container ID (CTID)") || return + mp_key=$(read_input "Remove LXC Mountpoint" "Mountpoint key (e.g. mp0, mp1)") || return + + if ! pct config "$ctid" >/dev/null 2>&1; then + msg_error "Container ${ctid} not found" + pause + return + fi + + if pct set "$ctid" -delete "$mp_key" >/dev/null 2>&1; then + msg_ok "Removed ${mp_key} from CT ${ctid}" + else + msg_error "Failed to remove ${mp_key} from CT ${ctid}" + fi + + pause +} + +list_lxc_mountpoints() { + header_info + local ctid + + ctid=$(read_input "List LXC Mountpoints" "Container ID (CTID)") || return + if ! pct config "$ctid" >/dev/null 2>&1; then + msg_error "Container ${ctid} not found" + pause + return + fi + + echo -e "${BL}Mountpoints for CT ${ctid}${CL}\n" + pct config "$ctid" | awk '/^mp[0-9]+:/{print}' + echo + pause +} + +host_create_samba_share() { + header_info + local share_name share_path user_name user_pass + + share_name=$(read_input "Host Samba Share" "Share name (e.g. data)") || return + share_path=$(read_input "Host Samba Share" "Share path on host (e.g. /srv/samba/data)" "/srv/samba/${share_name}") || return + user_name=$(read_input "Host Samba Share" "Linux/Samba username") || return + user_pass=$(read_password "Host Samba Share" "Password for ${user_name}") || return + + msg_info "Installing Samba on host if needed" + apt update >/dev/null 2>&1 + apt install -y samba >/dev/null 2>&1 + + mkdir -p "$share_path" + getent group sambashare >/dev/null 2>&1 || groupadd sambashare + id "$user_name" >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin -G sambashare "$user_name" + usermod -aG sambashare "$user_name" + chown -R root:sambashare "$share_path" + chmod 2775 "$share_path" + + if ! (echo "$user_pass"; echo "$user_pass") | smbpasswd -s -a "$user_name" >/dev/null 2>&1; then + msg_error "Failed to set Samba password for ${user_name}" + pause + return + fi + + if ! grep -q "^\[${share_name}\]" /etc/samba/smb.conf; then + cat <>/etc/samba/smb.conf + +[${share_name}] + comment = Proxmox Host Share (${share_name}) + path = ${share_path} + browseable = yes + read only = no + guest ok = no + create mask = 0664 + directory mask = 2775 + valid users = @sambashare +EOF + fi + + testparm -s >/dev/null 2>&1 || { + msg_error "Samba config validation failed (testparm). Check /etc/samba/smb.conf" + pause + return + } + + systemctl enable --now smbd nmbd >/dev/null 2>&1 + systemctl restart smbd nmbd >/dev/null 2>&1 + + msg_ok "Host SMB share created: //$(hostname -I | awk '{print $1}')/${share_name}" + msg_warn "Best practice: prefer running Samba in a dedicated LXC/VM for cleaner host separation." + + pause +} + +host_create_nfs_export() { + header_info + local export_path subnet options + + export_path=$(read_input "Host NFS Export" "Export path on host (e.g. /srv/proxmox-nfs)" "/srv/proxmox-nfs") || return + subnet=$(read_input "Host NFS Export" "Allowed subnet/CIDR (e.g. 10.0.0.0/16)") || return + options=$(read_input "Host NFS Export" "Export options" "rw,sync,no_subtree_check,no_root_squash") || return + + msg_info "Installing NFS server on host if needed" + apt update >/dev/null 2>&1 + apt install -y nfs-kernel-server >/dev/null 2>&1 + + mkdir -p "$export_path" + chmod 0770 "$export_path" + + if ! grep -qE "^${export_path//\//\/}[[:space:]]+${subnet//\//\/}\(" /etc/exports; then + echo "${export_path} ${subnet}(${options})" >>/etc/exports + fi + + exportfs -ra >/dev/null 2>&1 + systemctl enable --now nfs-kernel-server >/dev/null 2>&1 + + msg_ok "Host NFS export created: ${export_path} ${subnet}(${options})" + msg_warn "Best practice: use host exports carefully; dedicated storage VM/LXC is often cleaner." + pause +} + +show_status() { + header_info + echo -e "${BL}pvesm status${CL}" + pvesm status || true + echo + echo -e "${BL}Mounted /mnt/pve paths${CL}" + mount | grep /mnt/pve || true + echo + echo -e "${BL}Mounted CIFS/NFS/iSCSI related paths${CL}" + mount | grep -E ' type (cifs|nfs|nfs4)' || true + echo + echo -e "${BL}iSCSI sessions${CL}" + iscsiadm -m session 2>/dev/null || true + echo + pause +} + +main_menu() { + while true; do + local choice + choice=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "Storage Share Allrounder" \ + --menu "Select action:" 30 110 20 \ + "1" "SMB: manual mount test" \ + "2" "NFS: manual mount test" \ + "3" "iSCSI: discovery test" \ + "4" "Proxmox: add SMB/CIFS storage" \ + "5" "Proxmox: add NFS storage" \ + "6" "Proxmox: add iSCSI storage" \ + "7" "Proxmox: add LVM on base storage (e.g. iSCSI)" \ + "8" "Proxmox: remove storage definition" \ + "9" "LXC: add bind mountpoint (pct set -mpX)" \ + "10" "LXC: remove mountpoint (pct set -delete mpX)" \ + "11" "LXC: list mountpoints" \ + "12" "Host: install Samba + create SMB share" \ + "13" "Host: install NFS server + create export" \ + "14" "Show storage/mount/iSCSI status" \ + "0" "Exit" 3>&1 1>&2 2>&3) || break + + case "$choice" in + 1) manual_smb_test ;; + 2) manual_nfs_test ;; + 3) manual_iscsi_discovery ;; + 4) add_smb_storage ;; + 5) add_nfs_storage ;; + 6) add_iscsi_storage ;; + 7) add_lvm_on_base_storage ;; + 8) remove_storage ;; + 9) add_lxc_mountpoint ;; + 10) remove_lxc_mountpoint ;; + 11) list_lxc_mountpoints ;; + 12) host_create_samba_share ;; + 13) host_create_nfs_export ;; + 14) show_status ;; + 0) break ;; + *) ;; + esac + done +} + +header_info +require_root +require_pve +ensure_packages +confirm_start || exit 0 +main_menu + +header_info +msg_ok "Finished."