diff --git a/misc/images/community-scripts.png b/.github/assets/community-scripts.png similarity index 100% rename from misc/images/community-scripts.png rename to .github/assets/community-scripts.png diff --git a/misc/images/community-scripts.svg b/.github/assets/community-scripts.svg similarity index 100% rename from misc/images/community-scripts.svg rename to .github/assets/community-scripts.svg diff --git a/misc/images/logo-81x112.png b/.github/assets/logo-81x112.png similarity index 100% rename from misc/images/logo-81x112.png rename to .github/assets/logo-81x112.png diff --git a/misc/images/logo-proxmoxve-name.svg b/.github/assets/logo-proxmoxve-name.svg similarity index 100% rename from misc/images/logo-proxmoxve-name.svg rename to .github/assets/logo-proxmoxve-name.svg diff --git a/misc/images/logo-proxmoxve.svg b/.github/assets/logo-proxmoxve.svg similarity index 100% rename from misc/images/logo-proxmoxve.svg rename to .github/assets/logo-proxmoxve.svg diff --git a/misc/images/logo.png b/.github/assets/logo.png similarity index 100% rename from misc/images/logo.png rename to .github/assets/logo.png diff --git a/misc/images/old_logo-81x112.png b/.github/assets/old_logo-81x112.png similarity index 100% rename from misc/images/old_logo-81x112.png rename to .github/assets/old_logo-81x112.png diff --git a/misc/images/old_logo.png b/.github/assets/old_logo.png similarity index 100% rename from misc/images/old_logo.png rename to .github/assets/old_logo.png diff --git a/.github/autolabeler-config.json b/.github/autolabeler-config.json index d668595e6..39c7b919c 100644 --- a/.github/autolabeler-config.json +++ b/.github/autolabeler-config.json @@ -77,17 +77,6 @@ "excludeGlobs": [] } ], - "core": [ - { - "fileStatus": null, - "includeGlobs": [ - "misc/*.func" - ], - "excludeGlobs": [ - "misc/api.func" - ] - } - ], "documentation": [ { "fileStatus": null, diff --git a/.github/workflows/delete-pocketbase-entry-on-removal.yml b/.github/workflows/delete-pocketbase-entry-on-removal.yml index f3d42d104..07036fe14 100644 --- a/.github/workflows/delete-pocketbase-entry-on-removal.yml +++ b/.github/workflows/delete-pocketbase-entry-on-removal.yml @@ -267,15 +267,14 @@ jobs: const stubPath = path.join('ct', slug + '.sh'); const content = [ "#!/usr/bin/env bash", - "source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)", + "source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/core/main/core/core.func)", "# Copyright (c) 2021-2026 community-scripts ORG", "# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE", "", `APP="${appName.replace(/"/g, '\\"')}"`, "", - 'header_info "$APP"', - 'variables', 'color', + 'header_info', "", 'msg_error "This script is no longer available in community-scripts."', `msg_error "${deletedMessage.replace(/"/g, '\\"')}"`, diff --git a/.github/workflows/pr-test-command.yml b/.github/workflows/pr-test-command.yml index 5cbeeb4c5..570ffecca 100644 --- a/.github/workflows/pr-test-command.yml +++ b/.github/workflows/pr-test-command.yml @@ -184,8 +184,8 @@ jobs: ); } lines.push( - 'Only the script itself comes from this branch. Whatever it loads, the engine', - 'or the `misc/` helpers, still comes from `main`.', + 'Only the script itself comes from this branch. The engine it loads still', + 'comes from community-scripts/core on `main`.', ); } diff --git a/.github/workflows/sync-to-incus.yml b/.github/workflows/sync-to-incus.yml index 3565b34fb..d6c363bcf 100644 --- a/.github/workflows/sync-to-incus.yml +++ b/.github/workflows/sync-to-incus.yml @@ -5,8 +5,8 @@ name: Sync ct/install to Incus # # The actual mirroring + transformation lives in community-scripts/Incus # (.github/workflows/sync-scripts.yml): it rewrites each script's bootstrap line -# to load the engine from community-scripts/core (ProxmoxVE's misc/build.func is -# a monolith with no Incus backend), so a plain 1:1 copy would break every Incus +# to load the engine from community-scripts/core (ct scripts here load its PVE +# backend; Incus needs the Incus one), so a plain 1:1 copy would break every Incus # script. We therefore only fire a repository_dispatch and let that single # source of truth do the work (and auto-approve + merge its own PR). # diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b77c825..810a127cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ 
- +

Changelog

diff --git a/README.md b/README.md index e14d48e8d..c629f2c17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
- Community Scripts — Proxmox VE logo - + Community Scripts — Proxmox VE logo +

Community Scripts — Proxmox VE

One-command installations for services, containers and VMs on Proxmox VE
@@ -104,7 +104,7 @@ Virtual machine scripts need the same versions as containers. | `vm/` | Virtual machine scripts | | `tools/pve/` | Host tooling: post-install, hardware passthrough, maintenance | | `turnkey/` | TurnKey Linux appliance helper | -| `misc/` | Legacy engine copies — current scripts load [core](https://github.com/community-scripts/core) at runtime, so changes here never reach a running install | +| `misc/` | One legacy engine file left, loaded only by `vm/owncloud-vm.sh` — everything else loads [core](https://github.com/community-scripts/core) at runtime | Application metadata — name, description, logo, tags — is managed on the website, not in this repository. Use the **Report Issue** link on any script page to diff --git a/install/argus-install.sh b/install/argus-install.sh index 06ab10a78..3d8ee4780 100644 --- a/install/argus-install.sh +++ b/install/argus-install.sh @@ -57,7 +57,7 @@ service: url: community-scripts/ProxmoxVE use_prerelease: false dashboard: - icon: https://raw.githubusercontent.com/community-scripts/ProxmoxVE/refs/heads/main/misc/images/logo.png + icon: https://raw.githubusercontent.com/community-scripts/ProxmoxVE/refs/heads/main/.github/assets/logo.png icon_link_to: https://community-scripts.org/ web_url: https://github.com/community-scripts/ProxmoxVE/releases EOF diff --git a/misc/alpine-install.func b/misc/alpine-install.func deleted file mode 100644 index 36de2315b..000000000 --- a/misc/alpine-install.func +++ /dev/null @@ -1,275 +0,0 @@ -# Copyright (c) 2021-2026 community-scripts ORG -# Author: tteck (tteckster) -# Co-Author: MickLesk -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE - -if ! command -v curl >/dev/null 2>&1; then - apk update && apk add curl >/dev/null 2>&1 -fi -# Mark container context BEFORE error handling starts: error_handler/on_exit -# must write local failure artifacts instead of talking to the telemetry API -# (the host is the single telemetry reporter). -export TELEMETRY_CONTEXT="container" -# A freshly booted container may not have DNS up yet. `source <(curl ...)` hides -# curl's exit code, so a failed download would silently source an empty stream. -_bootstrap_die() { - if declare -f msg_error >/dev/null 2>&1; then - msg_error "$1" - else - echo "FATAL: $1" >&2 - fi - exit 115 -} - -_bootstrap_source() { - local url="$1" probe="$2" content - content=$(curl -fsSL --connect-timeout 10 --retry 5 --retry-connrefused --retry-delay 2 "$url") || - _bootstrap_die "Failed to download ${url##*/}" - source /dev/stdin <<<"$content" - declare -f "$probe" >/dev/null 2>&1 || - _bootstrap_die "${url##*/} loaded but incomplete (missing ${probe})" -} - -_FUNC_BASE="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc" -_bootstrap_source "$_FUNC_BASE/core.func" load_functions -_bootstrap_source "$_FUNC_BASE/error_handler.func" catch_errors -load_functions -catch_errors - -# Persist diagnostics setting inside container (exported from build.func) -# so addon scripts running later can find the user's choice -if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then - mkdir -p /usr/local/community-scripts - echo "DIAGNOSTICS=${DIAGNOSTICS:-no}" >/usr/local/community-scripts/diagnostics -fi - -# Get LXC IP address (must be called INSIDE container, after network is up) -get_lxc_ip - -# ------------------------------------------------------------------------------ -# post_progress_to_api() -# -# - Lightweight progress ping from inside the container -# - Updates the existing telemetry record status -# - Arguments: -# * $1: status (optional, default: "configuring") -# - Signals that the installation is actively progressing (not stuck) -# - Fire-and-forget: never blocks or fails the script -# - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set -# ------------------------------------------------------------------------------ -post_progress_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 - - local progress_status="${1:-configuring}" - - curl -fsS -m 5 -X POST "https://telemetry.community-scripts.org/telemetry" \ - -H "Content-Type: application/json" \ - -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"lxc\",\"nsapp\":\"${app:-unknown}\",\"status\":\"${progress_status}\",\"platform\":\"${TELEMETRY_PLATFORM:-}\",\"repo_source\":\"${REPO_SOURCE:-}\",\"repo_slug\":\"${REPO_SLUG:-}\"}" &>/dev/null || true -} - -# This function enables IPv6 if it's not disabled and sets verbose mode -verb_ip6() { - set_std_mode # Set STD mode based on VERBOSE - - if [ "${IPV6_METHOD:-}" = "disable" ]; then - msg_info "Disabling IPv6 (this may affect some services)" - $STD sysctl -w net.ipv6.conf.all.disable_ipv6=1 - $STD sysctl -w net.ipv6.conf.default.disable_ipv6=1 - $STD sysctl -w net.ipv6.conf.lo.disable_ipv6=1 - mkdir -p /etc/sysctl.d - $STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <&2 -en "${CROSS}${RD} No Network! " - sleep $RETRY_EVERY - i=$((i - 1)) - done - - if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" = "" ]; then - echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}" - echo -e "${NETWORK}Check Network Settings" - exit 121 - fi - msg_ok "Set up Container OS" - msg_ok "Network Connected: ${BL}$(ip addr show | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1 | tail -n1)${CL}" - post_progress_to_api -} - -# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected -network_check() { - set +e - trap - ERR - ipv4_connected=false - - # Check IPv4 connectivity to Cloudflare, Google & Quad9 DNS servers - if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then - msg_ok "IPv4 Internet Connected" - ipv4_connected=true - else - msg_error "IPv4 Internet Not Connected" - fi - - if [[ $ipv4_connected == false ]]; then - read -r -p "No Internet detected, would you like to continue anyway? " prompt /dev/tcp/$m/80" 2>/dev/null; then - msg_custom "${INFO}" "${YW}" "Attempting mirror: ${m}" - cat </etc/apk/repositories -http://$m/alpine/latest-stable/main -http://$m/alpine/latest-stable/community -EOF - if $STD apk -U upgrade; then - msg_ok "CDN set to ${m}: tests passed" - apk_ok=true - break - else - msg_warn "Mirror ${m} failed" - fi - fi - done - if [[ "$apk_ok" != true ]]; then - msg_error "All Alpine mirrors failed. Check network or try again later." - exit 1 - fi - fi - local tools_content - tools_content=$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) || { - msg_error "Failed to download tools.func" - exit 115 - } - source /dev/stdin <<<"$tools_content" - if ! declare -f fetch_and_deploy_gh_release >/dev/null 2>&1; then - msg_error "tools.func loaded but incomplete — missing expected functions" - exit 115 - fi - msg_ok "Updated Container OS" - post_progress_to_api -} - -# This function modifies the message of the day (motd) and SSH settings -motd_ssh() { - echo "export TERM='xterm-256color'" >>/root/.bashrc - - PROFILE_FILE="/etc/profile.d/00_lxc-details.sh" - - echo "[ -t 1 ] || return 0" >"$PROFILE_FILE" - echo "echo -e \"\"" >>"$PROFILE_FILE" - echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE" - echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE" - echo "echo \"\"" >>"$PROFILE_FILE" - cat <>"$PROFILE_FILE" -os_display="Unknown OS" -if [ -r /etc/os-release ]; then - . /etc/os-release - os_display="\${PRETTY_NAME:-\${NAME:-Unknown OS}}" -fi -echo -e "${TAB}${OS}${YW} OS: ${GN}\${os_display}${CL}" -EOF - echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE" - echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(ip -4 addr show eth0 | awk '/inet / {print \$2}' | cut -d/ -f1 | head -n 1)${CL}\"" >>"$PROFILE_FILE" - - # Configure SSH if enabled - if [[ "${SSH_ROOT}" == "yes" ]]; then - # Enable sshd service - $STD rc-update add sshd - # Allow root login via SSH - sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config - # Start the sshd service - $STD /etc/init.d/sshd start - fi - post_progress_to_api -} - -# Validate Timezone for some LXC's -validate_tz() { - [[ -f "/usr/share/zoneinfo/$1" ]] -} - -# This function customizes the container and enables passwordless login for the root user -customize() { - if [[ "$PASSWORD" == "" ]]; then - msg_info "Customizing Container" - passwd -d root >/dev/null 2>&1 - - # Ensure agetty is available - apk add --no-cache --force-broken-world util-linux >/dev/null 2>&1 - - # Create persistent autologin boot script - mkdir -p /etc/local.d - cat <<'EOF' >/etc/local.d/autologin.start -#!/bin/sh -sed -i 's|^tty1::respawn:.*|tty1::respawn:/sbin/agetty --autologin root --noclear tty1 38400 linux|' /etc/inittab -kill -HUP 1 -EOF - touch /root/.hushlogin - - chmod +x /etc/local.d/autologin.start - rc-update add local >/dev/null 2>&1 - - # Apply autologin immediately for current session - /etc/local.d/autologin.start - - msg_ok "Customized Container" - fi - - echo 'set -a; [ -f /etc/profile.d/90-http-proxy.sh ] && . /etc/profile.d/90-http-proxy.sh; set +a; bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/'"${app}"'.sh)"' >/usr/bin/update - chmod +x /usr/bin/update - post_progress_to_api -} diff --git a/misc/alpine-tools.func b/misc/alpine-tools.func deleted file mode 100644 index f5ba60368..000000000 --- a/misc/alpine-tools.func +++ /dev/null @@ -1,515 +0,0 @@ -#!/bin/ash -# shellcheck shell=ash - -# Expects existing msg_* functions and optional $STD from the framework. - -# ------------------------------ -# helpers -# ------------------------------ -lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } -has() { command -v "$1" >/dev/null 2>&1; } - -need_tool() { - # usage: need_tool curl jq unzip ... - # setup missing tools via apk - local missing=0 t - for t in "$@"; do - if ! has "$t"; then missing=1; fi - done - if [ "$missing" -eq 1 ]; then - msg_info "Installing tools: $*" - apk add --no-cache "$@" >/dev/null 2>&1 || { - msg_error "apk add failed for: $*" - return 100 - } - msg_ok "Tools ready: $*" - fi -} - -net_resolves() { - # better handling for missing getent on Alpine - # usage: net_resolves api.github.com - local host="$1" - ping -c1 -W1 "$host" >/dev/null 2>&1 || nslookup "$host" >/dev/null 2>&1 -} - -ensure_usr_local_bin_persist() { - # Login shells: /etc/profile.d/ - local PROFILE_FILE="/etc/profile.d/10-localbin.sh" - if [ ! -f "$PROFILE_FILE" ]; then - echo 'case ":$PATH:" in *:/usr/local/bin:*) ;; *) export PATH="/usr/local/bin:$PATH";; esac' >"$PROFILE_FILE" - chmod +x "$PROFILE_FILE" - fi - - # Non-login shells (pct enter): /root/.profile and /root/.bashrc - for rc_file in /root/.profile /root/.bashrc; do - if [ -f "$rc_file" ] && ! grep -q '/usr/local/bin' "$rc_file"; then - echo 'export PATH="/usr/local/bin:$PATH"' >>"$rc_file" - fi - done -} - -download_with_progress() { - # $1 url, $2 dest - local url="$1" out="$2" cl - need_tool curl pv || return 127 - cl=$(curl -fsSLI "$url" 2>/dev/null | awk 'tolower($0) ~ /^content-length:/ {print $2}' | tr -d '\r') - if [ -n "$cl" ]; then - curl -fsSL "$url" | pv -s "$cl" >"$out" || { - msg_error "Download failed: $url" - return 250 - } - else - curl -fL# -o "$out" "$url" || { - msg_error "Download failed: $url" - return 250 - } - fi -} - -# ------------------------------ -# GitHub: check Release -# ------------------------------ -check_for_gh_release() { - # app, repo, [pinned] - local app="$1" source="$2" pinned="${3:-}" - local app_lc - app_lc="$(lower "$app" | tr -d ' ')" - local current_file="$HOME/.${app_lc}" - local current="" release tag - - msg_info "Check for update: $app" - - net_resolves api.github.com || { - msg_error "DNS/network error: api.github.com" - return 6 - } - need_tool curl jq || return 127 - - tag=$(curl -fsSL "https://api.github.com/repos/${source}/releases/latest" | jq -r '.tag_name // empty') - [ -z "$tag" ] && { - msg_error "Unable to fetch latest tag for $app" - return 22 - } - release="${tag#v}" - - [ -f "$current_file" ] && current="$(cat "$current_file")" - - if [ -n "$pinned" ]; then - if [ "$pinned" = "$release" ]; then - msg_ok "$app pinned to v$pinned (no update)" - return 1 - fi - if [ "$current" = "$pinned" ]; then - msg_ok "$app pinned v$pinned installed (upstream v$release)" - return 1 - fi - msg_info "$app pinned v$pinned (upstream v$release) → update/downgrade" - CHECK_UPDATE_RELEASE="$pinned" - return 0 - fi - - if [ "$release" != "$current" ] || [ ! -f "$current_file" ]; then - CHECK_UPDATE_RELEASE="$release" - msg_info "New release available: v$release (current: v${current:-none})" - return 0 - fi - - msg_ok "$app is up to date (v$release)" - return 1 -} - -# ------------------------------ -# GitHub: get Release & deploy (Alpine) -# modes: tarball | prebuild | singlefile -# ------------------------------ -fetch_and_deploy_gh() { - # $1 app, $2 repo, [$3 mode], [$4 version], [$5 target], [$6 asset_pattern - local app="$1" repo="$2" mode="${3:-tarball}" version="${4:-latest}" target="${5:-/opt/$1}" pattern="${6:-}" - local app_lc - app_lc="$(lower "$app" | tr -d ' ')" - local vfile="$HOME/.${app_lc}" - local json url filename tmpd unpack - - net_resolves api.github.com || { - msg_error "DNS/network error" - return 6 - } - need_tool curl jq tar || return 127 - [ "$mode" = "prebuild" ] || [ "$mode" = "singlefile" ] && need_tool unzip >/dev/null 2>&1 || true - - tmpd="$(mktemp -d)" || return 252 - mkdir -p "$target" - - # Release JSON - if [ "$version" = "latest" ]; then - json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest")" || { - msg_error "GitHub API failed" - rm -rf "$tmpd" - return 22 - } - else - json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/tags/$version")" || { - msg_error "GitHub API failed" - rm -rf "$tmpd" - return 22 - } - fi - - # correct Version - version="$(printf '%s' "$json" | jq -r '.tag_name // empty')" - version="${version#v}" - - [ -z "$version" ] && { - msg_error "No tag in release json" - rm -rf "$tmpd" - return 65 - } - - case "$mode" in - tarball | source) - url="$(printf '%s' "$json" | jq -r '.tarball_url // empty')" - [ -z "$url" ] && url="https://github.com/$repo/archive/refs/tags/v$version.tar.gz" - filename="${app_lc}-${version}.tar.gz" - download_with_progress "$url" "$tmpd/$filename" || { - rm -rf "$tmpd" - return 250 - } - tar -xzf "$tmpd/$filename" -C "$tmpd" || { - msg_error "tar extract failed" - rm -rf "$tmpd" - return 251 - } - unpack="$(find "$tmpd" -mindepth 1 -maxdepth 1 -type d | head -n1)" - # copy content of unpack to target - (cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || { - msg_error "copy failed" - rm -rf "$tmpd" - return 252 - } - ;; - prebuild) - [ -n "$pattern" ] || { - msg_error "prebuild requires asset pattern" - rm -rf "$tmpd" - return 65 - } - url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" ' - BEGIN{IGNORECASE=1} - $0 ~ p {print; exit} - ')" - [ -z "$url" ] && { - msg_error "asset not found for pattern: $pattern" - rm -rf "$tmpd" - return 250 - } - filename="${url##*/}" - download_with_progress "$url" "$tmpd/$filename" || { - rm -rf "$tmpd" - return 250 - } - # unpack archive (Zip or tarball) - case "$filename" in - *.zip) - need_tool unzip || { - rm -rf "$tmpd" - return 127 - } - mkdir -p "$tmpd/unp" - unzip -q "$tmpd/$filename" -d "$tmpd/unp" - ;; - *.tar.gz | *.tgz | *.tar.xz | *.tar.zst | *.tar.bz2) - mkdir -p "$tmpd/unp" - tar -xf "$tmpd/$filename" -C "$tmpd/unp" - ;; - *) - msg_error "unsupported archive: $filename" - rm -rf "$tmpd" - return 251 - ;; - esac - # top-level folder strippen - if [ "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1 ] && [ -z "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type f | head -n1)" ]; then - unpack="$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d)" - (cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || { - msg_error "copy failed" - rm -rf "$tmpd" - return 252 - } - else - (cd "$tmpd/unp" && tar -cf - .) | (cd "$target" && tar -xf -) || { - msg_error "copy failed" - rm -rf "$tmpd" - return 252 - } - fi - ;; - singlefile) - [ -n "$pattern" ] || { - msg_error "singlefile requires asset pattern" - rm -rf "$tmpd" - return 65 - } - url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" ' - BEGIN{IGNORECASE=1} - $0 ~ p {print; exit} - ')" - [ -z "$url" ] && { - msg_error "asset not found for pattern: $pattern" - rm -rf "$tmpd" - return 250 - } - filename="${url##*/}" - download_with_progress "$url" "$target/$app" || { - rm -rf "$tmpd" - return 250 - } - chmod +x "$target/$app" - ;; - *) - msg_error "Unknown mode: $mode" - rm -rf "$tmpd" - return 65 - ;; - esac - - echo "$version" >"$vfile" - ensure_usr_local_bin_persist - rm -rf "$tmpd" - msg_ok "Deployed $app ($version) → $target" -} - -# ------------------------------ -# yq (mikefarah) – Alpine -# ------------------------------ -setup_yq() { - # prefer apk, unless FORCE_GH=1 - if [ "${FORCE_GH:-0}" != "1" ] && apk info -e yq >/dev/null 2>&1; then - msg_info "Updating yq via apk" - apk add --no-cache --upgrade yq >/dev/null 2>&1 || true - msg_ok "yq ready ($(yq --version 2>/dev/null))" - return 0 - fi - - need_tool curl || return 127 - local arch bin url tmp - case "$(uname -m)" in - x86_64) arch="amd64" ;; - aarch64) arch="arm64" ;; - *) - msg_error "Unsupported arch for yq: $(uname -m)" - return 238 - ;; - esac - url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}" - tmp="$(mktemp)" - download_with_progress "$url" "$tmp" || return 250 - /usr/bin/install -m 0755 "$tmp" /usr/local/bin/yq - rm -f "$tmp" - msg_ok "Setup yq ($(yq --version 2>/dev/null))" -} - -# ------------------------------ -# Adminer – Alpine -# ------------------------------ -setup_adminer() { - need_tool curl || return 127 - msg_info "Setup Adminer (Alpine)" - mkdir -p /var/www/localhost/htdocs/adminer - curl -fsSL https://github.com/vrana/adminer/releases/latest/download/adminer.php \ - -o /var/www/localhost/htdocs/adminer/index.php || { - msg_error "Adminer download failed" - return 250 - } - msg_ok "Adminer at /adminer (served by your webserver)" -} - -# ------------------------------ -# uv – Alpine (musl tarball) -# optional: PYTHON_VERSION="3.12" -# ------------------------------ -setup_uv() { - need_tool curl tar || return 127 - local UV_BIN="/usr/local/bin/uv" - local arch tarball url tmpd ver installed - - case "$(uname -m)" in - x86_64) arch="x86_64-unknown-linux-musl" ;; - aarch64) arch="aarch64-unknown-linux-musl" ;; - *) - msg_error "Unsupported arch for uv: $(uname -m)" - return 238 - ;; - esac - - ver="$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest | jq -r '.tag_name' 2>/dev/null)" - ver="${ver#v}" - [ -z "$ver" ] && { - msg_error "uv: cannot determine latest version" - return 250 - } - - if has "$UV_BIN"; then - installed="$($UV_BIN -V 2>/dev/null | awk '{print $2}')" - [ "$installed" = "$ver" ] && { - msg_ok "uv $ver already installed" - return 0 - } - msg_info "Updating uv $installed → $ver" - else - msg_info "Setup uv $ver" - fi - - tmpd="$(mktemp -d)" || return 252 - tarball="uv-${arch}.tar.gz" - url="https://github.com/astral-sh/uv/releases/download/v${ver}/${tarball}" - - download_with_progress "$url" "$tmpd/uv.tar.gz" || { - rm -rf "$tmpd" - return 250 - } - tar -xzf "$tmpd/uv.tar.gz" -C "$tmpd" || { - msg_error "uv: extract failed" - rm -rf "$tmpd" - return 251 - } - - # tar contains ./uv - if [ -x "$tmpd/uv" ]; then - /usr/bin/install -m 0755 "$tmpd/uv" "$UV_BIN" - else - # fallback: in subfolder - /usr/bin/install -m 0755 "$tmpd"/*/uv "$UV_BIN" 2>/dev/null || { - msg_error "uv binary not found in tar" - rm -rf "$tmpd" - return 252 - } - fi - rm -rf "$tmpd" - ensure_usr_local_bin_persist - msg_ok "Setup uv $ver" - - if [ -n "${PYTHON_VERSION:-}" ]; then - local match - match="$(uv python list --only-downloads 2>/dev/null | awk -v maj="$PYTHON_VERSION" ' - $0 ~ "^cpython-"maj"\\." { print $0 }' | awk -F- '{print $2}' | sort -V | tail -n1)" - [ -z "$match" ] && { - msg_error "No matching Python for $PYTHON_VERSION" - return 250 - } - if ! uv python list | grep -q "cpython-${match}-linux"; then - msg_info "Installing Python $match via uv" - uv python install "$match" || { - msg_error "uv python install failed" - return 150 - } - msg_ok "Python $match installed (uv)" - fi - fi -} - -# ------------------------------ -# Java – Alpine (OpenJDK) -# JAVA_VERSION: 17|21 (Default 21) -# ------------------------------ -setup_java() { - local JAVA_VERSION="${JAVA_VERSION:-21}" pkg - case "$JAVA_VERSION" in - 17) pkg="openjdk17-jdk" ;; - 21 | *) pkg="openjdk21-jdk" ;; - esac - msg_info "Setup Java (OpenJDK $JAVA_VERSION)" - apk add --no-cache "$pkg" >/dev/null 2>&1 || { - msg_error "apk add $pkg failed" - return 100 - } - # set JAVA_HOME - local prof="/etc/profile.d/20-java.sh" - if [ ! -f "$prof" ]; then - echo 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(command -v java))))' >"$prof" - echo 'case ":$PATH:" in *:$JAVA_HOME/bin:*) ;; *) export PATH="$JAVA_HOME/bin:$PATH";; esac' >>"$prof" - chmod +x "$prof" - fi - msg_ok "Java ready: $(java -version 2>&1 | head -n1)" -} - -# ------------------------------ -# Go – Alpine (apk prefers, else tarball) -# ------------------------------ -setup_go() { - if [ -z "${GO_VERSION:-}" ]; then - msg_info "Setup Go (apk)" - apk add --no-cache go >/dev/null 2>&1 || { - msg_error "apk add go failed" - return 100 - } - msg_ok "Go ready: $(go version 2>/dev/null)" - return 0 - fi - - need_tool curl tar || return 127 - local ARCH TARBALL URL TMP - case "$(uname -m)" in - x86_64) ARCH="amd64" ;; - aarch64) ARCH="arm64" ;; - *) - msg_error "Unsupported arch for Go: $(uname -m)" - return 238 - ;; - esac - TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz" - URL="https://go.dev/dl/${TARBALL}" - msg_info "Setup Go $GO_VERSION (tarball)" - TMP="$(mktemp)" - download_with_progress "$URL" "$TMP" || return 250 - rm -rf /usr/local/go - tar -C /usr/local -xzf "$TMP" || { - msg_error "extract go failed" - rm -f "$TMP" - return 251 - } - rm -f "$TMP" - ln -sf /usr/local/go/bin/go /usr/local/bin/go - ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt - ensure_usr_local_bin_persist - msg_ok "Go ready: $(go version 2>/dev/null)" -} - -# ------------------------------ -# Composer – Alpine -# uses php83-cli + openssl + phar -# ------------------------------ -setup_composer() { - local COMPOSER_BIN="/usr/local/bin/composer" - if ! has php; then - # prefers php83 - msg_info "Installing PHP CLI for Composer" - apk add --no-cache php83-cli php83-openssl php83-phar php83-iconv >/dev/null 2>&1 || { - # Fallback to generic php if 83 not available - apk add --no-cache php-cli php-openssl php-phar php-iconv >/dev/null 2>&1 || { - msg_error "Failed to install php-cli for composer" - return 100 - } - } - msg_ok "PHP CLI ready: $(php -v | head -n1)" - fi - - if [ -x "$COMPOSER_BIN" ]; then - msg_info "Updating Composer" - else - msg_info "Setup Composer" - fi - - need_tool curl || return 127 - curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php || { - msg_error "composer installer download failed" - return 250 - } - php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer >/dev/null 2>&1 || { - msg_error "composer install failed" - return 150 - } - rm -f /tmp/composer-setup.php - ensure_usr_local_bin_persist - msg_ok "Composer ready: $(composer --version 2>/dev/null)" -} diff --git a/misc/build.func b/misc/build.func deleted file mode 100644 index 8aeed099c..000000000 --- a/misc/build.func +++ /dev/null @@ -1,7331 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2021-2026 community-scripts ORG -# Author: tteck (tteckster) | MickLesk | michelroegl-brunner -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/branch/main/LICENSE - -# ============================================================================== -# BUILD.FUNC - LXC CONTAINER BUILD & CONFIGURATION -# ============================================================================== -# -# This file provides the main build functions for creating and configuring -# LXC containers in Proxmox VE. It handles: -# -# - Variable initialization and defaults -# - Container creation and resource allocation -# - Storage selection and management -# - Advanced configuration and customization -# - User interaction menus and prompts -# -# Usage: -# - Sourced automatically by CT creation scripts -# - Requires core.func and error_handler.func to be loaded first -# -# ============================================================================== - -# ============================================================================== -# SECTION 1: INITIALIZATION & CORE VARIABLES -# ============================================================================== - -# ------------------------------------------------------------------------------ -# variables() -# -# - Initializes core variables for container creation -# - Normalizes application name (NSAPP = lowercase, no spaces) -# - Builds installer filename (var_install) -# - Defines regex patterns for validation -# - Fetches Proxmox hostname and version -# - Generates unique session ID for tracking and logging -# - Captures app-declared resource defaults (CPU, RAM, Disk) -# ------------------------------------------------------------------------------ -variables() { - NSAPP=$(echo "${APP,,}" | tr -d ' ') # This function sets the NSAPP variable by converting the value of the APP variable to lowercase and removing any spaces. - var_install="${NSAPP}-install" # sets the var_install variable by appending "-install" to the value of NSAPP. - INTEGER='^[0-9]+([.][0-9]+)?$' # it defines the INTEGER regular expression pattern. - PVEHOST_NAME=$(hostname) # gets the Proxmox Hostname and sets it to Uppercase - DIAGNOSTICS="no" # Safe default: no telemetry until user consents via diagnostics_check() - METHOD="default" # sets the METHOD variable to "default", used for the API call. - RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" # generates a random UUID and sets it to the RANDOM_UUID variable. - EXECUTION_ID="${RANDOM_UUID}" # Unique execution ID for telemetry record identification (unique-indexed in PocketBase) - SESSION_ID="${RANDOM_UUID:0:8}" # Short session ID (first 8 chars of UUID) for log files - BUILD_LOG="/tmp/create-lxc-${SESSION_ID}.log" # Host-side container creation log - # NOTE: combined_log is constructed locally in build_container() and ensure_log_on_host() - # as "/tmp/${NSAPP}-${CTID}-${SESSION_ID}.log" (requires CTID, not available here) - CTTYPE="${CTTYPE:-${CT_TYPE:-1}}" - - # ARM64 Template default variables - DEBIAN_DEFAULT_CODENAME="trixie" - UBUNTU_DEFAULT_CODENAME="noble" - ALPINE_DEFAULT_VERSION="3.23" - - # Parse dev_mode early - parse_dev_mode - - # Setup persistent log directory if logs mode active - if [[ "${DEV_MODE_LOGS:-false}" == "true" ]]; then - mkdir -p /var/log/community-scripts - BUILD_LOG="/var/log/community-scripts/create-lxc-${SESSION_ID}-$(date +%Y%m%d_%H%M%S).log" - fi - - # Get Proxmox VE version and kernel version - if command -v pveversion >/dev/null 2>&1; then - PVEVERSION="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')" - else - PVEVERSION="N/A" - fi - KERNEL_VERSION=$(uname -r) - - # Capture app-declared defaults (for precedence logic) - # These values are set by the app script BEFORE default.vars is loaded - # If app declares higher values than default.vars, app values take precedence - if [[ -n "${var_cpu:-}" && "${var_cpu}" =~ ^[0-9]+$ ]]; then - export APP_DEFAULT_CPU="${var_cpu}" - fi - if [[ -n "${var_ram:-}" && "${var_ram}" =~ ^[0-9]+$ ]]; then - export APP_DEFAULT_RAM="${var_ram}" - fi - if [[ -n "${var_disk:-}" && "${var_disk}" =~ ^[0-9]+$ ]]; then - export APP_DEFAULT_DISK="${var_disk}" - fi -} - -# `source <(curl ...)` hides the download exit code, so a failed fetch would -# silently source an empty stream and leave the helpers undefined. -_bootstrap_die() { - if declare -f msg_error >/dev/null 2>&1; then - msg_error "$1" - else - echo "FATAL: $1" >&2 - fi - exit 115 -} - -_bootstrap_source() { - local url="$1" probe="$2" content="" - if command -v curl >/dev/null 2>&1; then - content=$(curl -fsSL --connect-timeout 10 --retry 5 --retry-connrefused --retry-delay 2 "$url") || content="" - elif command -v wget >/dev/null 2>&1; then - content=$(wget -qO- --tries=5 --timeout=10 "$url") || content="" - else - _bootstrap_die "Neither curl nor wget available" - fi - [[ -n "$content" ]] || _bootstrap_die "Failed to download ${url##*/}" - source /dev/stdin <<<"$content" - declare -f "$probe" >/dev/null 2>&1 || - _bootstrap_die "${url##*/} loaded but incomplete (missing ${probe})" -} - -_FUNC_BASE="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc" -_bootstrap_source "$_FUNC_BASE/api.func" post_to_api -_bootstrap_source "$_FUNC_BASE/core.func" load_functions -_bootstrap_source "$_FUNC_BASE/error_handler.func" catch_errors -load_functions -catch_errors - -# ============================================================================== -# SECTION 2: PRE-FLIGHT CHECKS & SYSTEM VALIDATION -# ============================================================================== - -# ------------------------------------------------------------------------------ -# maxkeys_check() -# -# - Reads kernel keyring limits (maxkeys, maxbytes) -# - Checks current usage for LXC user (UID 100000) -# - Warns if usage is close to limits and suggests sysctl tuning -# - Exits if thresholds are exceeded -# - https://cleveruptime.com/docs/files/proc-key-users | https://docs.kernel.org/security/keys/core.html -# ------------------------------------------------------------------------------ - -maxkeys_check() { - # Read kernel parameters - per_user_maxkeys=$(cat /proc/sys/kernel/keys/maxkeys 2>/dev/null || echo 0) - per_user_maxbytes=$(cat /proc/sys/kernel/keys/maxbytes 2>/dev/null || echo 0) - - # Exit if kernel parameters are unavailable - if [[ "$per_user_maxkeys" -eq 0 || "$per_user_maxbytes" -eq 0 ]]; then - msg_error "Unable to read kernel key parameters. Ensure proper permissions." - exit 107 - fi - - # Fetch key usage for user ID 100000 (typical for containers) - used_lxc_keys=$(awk '/100000:/ {print $2}' /proc/key-users 2>/dev/null || echo 0) - used_lxc_bytes=$(awk '/100000:/ {split($5, a, "/"); print a[1]}' /proc/key-users 2>/dev/null || echo 0) - - # Calculate thresholds and suggested new limits - threshold_keys=$((per_user_maxkeys - 100)) - threshold_bytes=$((per_user_maxbytes - 1000)) - new_limit_keys=$((per_user_maxkeys * 2)) - new_limit_bytes=$((per_user_maxbytes * 2)) - - # Check if key or byte usage is near limits - failure=0 - if [[ "$used_lxc_keys" -gt "$threshold_keys" ]]; then - msg_warn "Key usage is near the limit (${used_lxc_keys}/${per_user_maxkeys})" - echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxkeys=${new_limit_keys}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." - failure=1 - fi - if [[ "$used_lxc_bytes" -gt "$threshold_bytes" ]]; then - msg_warn "Key byte usage is near the limit (${used_lxc_bytes}/${per_user_maxbytes})" - echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxbytes=${new_limit_bytes}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." - failure=1 - fi - - # Provide next steps if issues are detected - if [[ "$failure" -eq 1 ]]; then - msg_error "Kernel key limits exceeded - see suggestions above" - exit 108 - fi - - # Silent success - only show errors if they exist -} - -# ============================================================================== -# SECTION 3: CONTAINER SETUP UTILITIES -# ============================================================================== - -# ------------------------------------------------------------------------------ -# get_current_ip() -# -# - Returns current container IP depending on OS type -# - Debian/Ubuntu: uses `hostname -I` -# - Alpine: parses eth0 via `ip -4 addr` or `ip -6 addr` -# - Supports IPv6-only environments as fallback -# - Returns "Unknown" if OS type cannot be determined -# ------------------------------------------------------------------------------ -get_current_ip() { - CURRENT_IP="" - if [ -f /etc/os-release ]; then - # Check for Debian/Ubuntu (uses hostname -I) - if grep -qE 'ID=debian|ID=ubuntu' /etc/os-release; then - # Try IPv4 first - CURRENT_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true) - # Fallback to IPv6 if no IPv4 - if [[ -z "$CURRENT_IP" ]]; then - CURRENT_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E ':' | head -n1 || true) - fi - # Check for Alpine (uses ip command) - elif grep -q 'ID=alpine' /etc/os-release; then - # Try IPv4 first - CURRENT_IP=$(ip -4 addr show eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -n 1) - # Fallback to IPv6 if no IPv4 - if [[ -z "$CURRENT_IP" ]]; then - CURRENT_IP=$(ip -6 addr show eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n 1) - fi - else - CURRENT_IP="Unknown" - fi - fi - echo "$CURRENT_IP" -} - -# ------------------------------------------------------------------------------ -# update_motd_ip() -# -# - Updates /etc/motd with current container IP -# - Removes old IP entries to avoid duplicates -# - Regenerates /etc/profile.d/00_lxc-details.sh with dynamic OS/IP info -# ------------------------------------------------------------------------------ -update_motd_ip() { - MOTD_FILE="/etc/motd" - PROFILE_FILE="/etc/profile.d/00_lxc-details.sh" - - if [ -f "$MOTD_FILE" ]; then - # Remove existing IP Address lines to prevent duplication - sed -i '/IP Address:/d' "$MOTD_FILE" - - IP=$(get_current_ip) - # Add the new IP address - echo -e "${TAB}${NETWORK}${YW} IP Address: ${GN}${IP}${CL}" >>"$MOTD_FILE" - fi - - # Update dynamic LXC details profile if values changed (e.g., after OS upgrade) - # Only update if file exists and is from community-scripts - if [ -f "$PROFILE_FILE" ] && grep -q "community-scripts" "$PROFILE_FILE" 2>/dev/null; then - # Get current values - local current_hostname="$(hostname)" - local current_ip="$(hostname -I | awk '{print $1}')" - - # Escape sed special chars in replacement strings (& \ |) - current_hostname="${current_hostname//\\/\\\\}" - current_hostname="${current_hostname//&/\\&}" - current_ip="${current_ip//\\/\\\\}" - current_ip="${current_ip//&/\\&}" - - # Update only if values actually changed - if ! grep -q "Hostname:.*$current_hostname" "$PROFILE_FILE" 2>/dev/null; then - sed -i "s|Hostname:.*|Hostname: \${GN}$current_hostname\${CL}\\\"|" "$PROFILE_FILE" - fi - if ! grep -q "IP Address:.*$current_ip" "$PROFILE_FILE" 2>/dev/null; then - sed -i "s|IP Address:.*|IP Address: \${GN}$current_ip\${CL}\\\"|" "$PROFILE_FILE" - fi - fi -} - -# ------------------------------------------------------------------------------ -# install_ssh_keys_into_ct() -# -# - Installs SSH keys into container root account if SSH is enabled -# - Uses pct push or direct input to authorized_keys -# - Supports both SSH_KEYS_FILE (from advanced settings) and SSH_AUTHORIZED_KEY (from user defaults) -# - Falls back to warning if no keys provided -# ------------------------------------------------------------------------------ -install_ssh_keys_into_ct() { - [[ "${SSH:-no}" != "yes" ]] && return 0 - - # Ensure SSH_KEYS_FILE is defined (may not be set if advanced_settings was skipped) - : "${SSH_KEYS_FILE:=}" - - # If SSH_KEYS_FILE doesn't exist but SSH_AUTHORIZED_KEY is set (from user defaults), - # create a temporary SSH_KEYS_FILE with the key - if [[ -z "$SSH_KEYS_FILE" || ! -s "$SSH_KEYS_FILE" ]] && [[ -n "${SSH_AUTHORIZED_KEY:-}" ]]; then - SSH_KEYS_FILE="$(mktemp)" - printf '%s\n' "$SSH_AUTHORIZED_KEY" >"$SSH_KEYS_FILE" - fi - - if [[ -n "$SSH_KEYS_FILE" && -s "$SSH_KEYS_FILE" ]]; then - msg_info "Installing selected SSH keys into CT ${CTID}" - pct exec "$CTID" -- sh -c 'mkdir -p /root/.ssh && chmod 700 /root/.ssh' || { - msg_error "prepare /root/.ssh failed" - return 252 - } - pct push "$CTID" "$SSH_KEYS_FILE" /root/.ssh/authorized_keys >/dev/null 2>&1 || - pct exec "$CTID" -- sh -c "cat > /root/.ssh/authorized_keys" <"$SSH_KEYS_FILE" || { - msg_error "write authorized_keys failed" - return 252 - } - pct exec "$CTID" -- sh -c 'chmod 600 /root/.ssh/authorized_keys' || true - msg_ok "Installed SSH keys into CT ${CTID}" - return 0 - fi - - # Fallback - msg_warn "No SSH keys to install (skipping)." - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_container_id() -# -# - Validates if a container ID is available for use (CLUSTER-WIDE) -# - Checks cluster resources via pvesh for VMs/CTs on ALL nodes -# - Falls back to local config file check if pvesh unavailable -# - Checks if ID is used in LVM logical volumes -# - Returns 0 if ID is available, 1 if already in use -# ------------------------------------------------------------------------------ -validate_container_id() { - local ctid="$1" - - # Check if ID is numeric - if ! [[ "$ctid" =~ ^[0-9]+$ ]]; then - return 1 - fi - - # CLUSTER-WIDE CHECK: Query all VMs/CTs across all nodes - # This catches IDs used on other nodes in the cluster - # NOTE: Works on single-node too - Proxmox always has internal cluster structure - # Falls back gracefully if pvesh unavailable or returns empty - if command -v pvesh &>/dev/null; then - local cluster_ids - cluster_ids=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | - grep -oP '"vmid":\s*\K[0-9]+' 2>/dev/null || true) - if [[ -n "$cluster_ids" ]] && echo "$cluster_ids" | grep -qw "$ctid"; then - return 1 - fi - fi - - # LOCAL FALLBACK: Check if config file exists for VM or LXC - # This handles edge cases where pvesh might not return all info - if [[ -f "/etc/pve/qemu-server/${ctid}.conf" ]] || [[ -f "/etc/pve/lxc/${ctid}.conf" ]]; then - return 1 - fi - - # Check ALL nodes in cluster for config files (handles pmxcfs sync delays) - # NOTE: On single-node, /etc/pve/nodes/ contains just the one node - still works - if [[ -d "/etc/pve/nodes" ]]; then - for node_dir in /etc/pve/nodes/*/; do - if [[ -f "${node_dir}qemu-server/${ctid}.conf" ]] || [[ -f "${node_dir}lxc/${ctid}.conf" ]]; then - return 1 - fi - done - fi - - # Check if ID is used in LVM logical volumes - if lvs --noheadings -o lv_name 2>/dev/null | grep -qE "(^|[-_])${ctid}($|[-_])"; then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# get_valid_container_id() -# -# - Returns a valid, unused container ID (CLUSTER-AWARE) -# - Uses pvesh /cluster/nextid as starting point (already cluster-aware) -# - If provided ID is valid, returns it -# - Otherwise increments until a free one is found across entire cluster -# - Calls validate_container_id() to check availability -# ------------------------------------------------------------------------------ -get_valid_container_id() { - local suggested_id="${1:-$(pvesh get /cluster/nextid 2>/dev/null || echo 100)}" - - # Ensure we have a valid starting ID - if ! [[ "$suggested_id" =~ ^[0-9]+$ ]]; then - suggested_id=$(pvesh get /cluster/nextid 2>/dev/null || echo 100) - fi - - local max_attempts=1000 - local attempts=0 - - while ! validate_container_id "$suggested_id"; do - suggested_id=$((suggested_id + 1)) - attempts=$((attempts + 1)) - if [[ $attempts -ge $max_attempts ]]; then - msg_error "Could not find available container ID after $max_attempts attempts" - exit 109 - fi - done - - echo "$suggested_id" -} - -# ------------------------------------------------------------------------------ -# validate_hostname() -# -# - Validates hostname/FQDN according to RFC 1123/952 -# - Checks total length (max 253 characters for FQDN) -# - Validates each label (max 63 chars, alphanumeric + hyphens) -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_hostname() { - local hostname="$1" - - # Check total length (max 253 for FQDN) - if [[ ${#hostname} -gt 253 ]] || [[ -z "$hostname" ]]; then - return 1 - fi - - # Split by dots and validate each label - local IFS='.' - read -ra labels <<<"$hostname" - for label in "${labels[@]}"; do - # Each label: 1-63 chars, alphanumeric, hyphens allowed (not at start/end) - if [[ -z "$label" ]] || [[ ${#label} -gt 63 ]]; then - return 1 - fi - if [[ ! "$label" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] && [[ ! "$label" =~ ^[a-z0-9]$ ]]; then - return 1 - fi - done - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_mac_address() -# -# - Validates MAC address format (XX:XX:XX:XX:XX:XX) -# - Empty value is allowed (auto-generated) -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_mac_address() { - local mac="$1" - [[ -z "$mac" ]] && return 0 - if [[ ! "$mac" =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]]; then - return 1 - fi - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_vlan_tag() -# -# - Validates VLAN tag (1-4094) -# - Empty value is allowed (no VLAN) -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_vlan_tag() { - local vlan="$1" - [[ -z "$vlan" ]] && return 0 - if ! [[ "$vlan" =~ ^[0-9]+$ ]] || ((vlan < 1 || vlan > 4094)); then - return 1 - fi - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_mtu() -# -# - Validates MTU size (576-65535, common values: 1500, 9000) -# - Empty value is allowed (default 1500) -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_mtu() { - local mtu="$1" - [[ -z "$mtu" ]] && return 0 - if ! [[ "$mtu" =~ ^[0-9]+$ ]] || ((mtu < 576 || mtu > 65535)); then - return 1 - fi - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_ipv6_address() -# -# - Validates IPv6 address with optional CIDR notation -# - Supports compressed (::) and full notation -# - Empty value is allowed -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_ipv6_address() { - local ipv6="$1" - [[ -z "$ipv6" ]] && return 0 - - # Extract address and CIDR - local addr="${ipv6%%/*}" - local cidr="${ipv6##*/}" - - # Validate CIDR if present (1-128) - if [[ "$ipv6" == */* ]]; then - if ! [[ "$cidr" =~ ^[0-9]+$ ]] || ((cidr < 1 || cidr > 128)); then - return 1 - fi - fi - - # Basic IPv6 validation - check for valid characters and structure - # Must contain only hex digits and colons - if [[ ! "$addr" =~ ^[0-9a-fA-F:]+$ ]]; then - return 1 - fi - - # Must contain at least one colon - if [[ ! "$addr" == *:* ]]; then - return 1 - fi - - # Check for valid double-colon usage (only one :: allowed) - if [[ "$addr" == *::*::* ]]; then - return 1 - fi - - # Check that no segment exceeds 4 hex chars - local IFS=':' - local -a segments - read -ra segments <<<"$addr" - for seg in "${segments[@]}"; do - if [[ ${#seg} -gt 4 ]]; then - return 1 - fi - done - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_bridge() -# -# - Validates that network bridge exists and is active -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_bridge() { - local bridge="$1" - [[ -z "$bridge" ]] && return 1 - - # Check if bridge interface exists - if ! ip link show dev "$bridge" &>/dev/null; then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_sdn_vnet() -# -# - Validates that an SDN vnet exists in the cluster config -# ------------------------------------------------------------------------------ -validate_sdn_vnet() { - local vnet="$1" - [[ -z "$vnet" ]] && return 1 - [[ -f /etc/pve/sdn/vnets.cfg ]] && grep -qE "^vnet:[[:space:]]*${vnet}([[:space:]]|$)" /etc/pve/sdn/vnets.cfg && return 0 - command -v pvesh &>/dev/null && pvesh get "/cluster/sdn/vnets/${vnet}" &>/dev/null && return 0 - return 1 -} - -# ------------------------------------------------------------------------------ -# validate_gateway_in_subnet() -# -# - Validates that gateway IP is in the same subnet as static IP -# - Arguments: static_ip (with CIDR), gateway_ip -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_gateway_in_subnet() { - local static_ip="$1" - local gateway="$2" - - [[ -z "$static_ip" || -z "$gateway" ]] && return 0 - - # Extract IP and CIDR - local ip="${static_ip%%/*}" - local cidr="${static_ip##*/}" - - # /31 and /32 are valid point-to-point / zero-trust DMZ configurations - # where the gateway is technically outside the subnet — skip validation - ((cidr >= 31)) && return 0 - - # Convert CIDR to netmask bits - local mask=$((0xFFFFFFFF << (32 - cidr) & 0xFFFFFFFF)) - - # Convert IPs to integers - local IFS='.' - read -r i1 i2 i3 i4 <<<"$ip" - read -r g1 g2 g3 g4 <<<"$gateway" - - local ip_int=$(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4)) - local gw_int=$(((g1 << 24) + (g2 << 16) + (g3 << 8) + g4)) - - # Check if both are in same network - if (((ip_int & mask) != (gw_int & mask))); then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_ip_address() -# -# - Validates IPv4 address with CIDR notation -# - Checks each octet is 0-255 -# - Checks CIDR is 1-32 -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_ip_address() { - local ip="$1" - [[ -z "$ip" ]] && return 1 - - # Check format with CIDR - if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$ ]]; then - return 1 - fi - - local o1="${BASH_REMATCH[1]}" - local o2="${BASH_REMATCH[2]}" - local o3="${BASH_REMATCH[3]}" - local o4="${BASH_REMATCH[4]}" - local cidr="${BASH_REMATCH[5]}" - - # Validate octets (0-255) - for octet in "$o1" "$o2" "$o3" "$o4"; do - if ((octet > 255)); then - return 1 - fi - done - - # Validate CIDR (1-32) - if ((cidr < 1 || cidr > 32)); then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_gateway_ip() -# -# - Validates gateway IPv4 address (without CIDR) -# - Checks each octet is 0-255 -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_gateway_ip() { - local ip="$1" - [[ -z "$ip" ]] && return 0 - - # Check format without CIDR - if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then - return 1 - fi - - local o1="${BASH_REMATCH[1]}" - local o2="${BASH_REMATCH[2]}" - local o3="${BASH_REMATCH[3]}" - local o4="${BASH_REMATCH[4]}" - - # Validate octets (0-255) - for octet in "$o1" "$o2" "$o3" "$o4"; do - if ((octet > 255)); then - return 1 - fi - done - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_timezone() -# -# - Validates timezone string against system zoneinfo -# - Empty value or "host" is allowed -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_timezone() { - local tz="$1" - [[ -z "$tz" || "$tz" == "host" ]] && return 0 - - # Check if timezone file exists - if [[ ! -f "/usr/share/zoneinfo/$tz" ]]; then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# validate_tags() -# -# - Validates Proxmox tags format -# - Only alphanumeric, hyphens, underscores, and semicolons allowed -# - Empty value is allowed -# - Returns 0 if valid, 1 if invalid -# ------------------------------------------------------------------------------ -validate_tags() { - local tags="$1" - [[ -z "$tags" ]] && return 0 - - # Tags can only contain alphanumeric, -, _, and ; (separator) - if [[ ! "$tags" =~ ^[a-zA-Z0-9_\;-]+$ ]]; then - return 1 - fi - - return 0 -} - -# ------------------------------------------------------------------------------ -# find_host_ssh_keys() -# -# - Scans system for available SSH keys -# - Supports defaults (~/.ssh, /etc/ssh/authorized_keys) -# - Returns list of files containing valid SSH public keys -# - Sets FOUND_HOST_KEY_COUNT to number of keys found -# ------------------------------------------------------------------------------ -find_host_ssh_keys() { - local re='(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-(ssh-ed25519|ecdsa-sha2-nistp256))' - local -a files=() cand=() - local g="${var_ssh_import_glob:-}" - local total=0 f base c - - shopt -s nullglob - if [[ -n "$g" ]]; then - for pat in $g; do cand+=($pat); done - else - cand+=(/root/.ssh/authorized_keys /root/.ssh/authorized_keys2) - cand+=(/root/.ssh/*.pub) - cand+=(/etc/ssh/authorized_keys /etc/ssh/authorized_keys.d/*) - fi - shopt -u nullglob - - for f in "${cand[@]}"; do - [[ -f "$f" && -r "$f" ]] || continue - base="$(basename -- "$f")" - case "$base" in - known_hosts | known_hosts.* | config) continue ;; - id_*) [[ "$f" != *.pub ]] && continue ;; - esac - - # CRLF safe check for host keys - c=$(tr -d '\r' <"$f" | awk ' - /^[[:space:]]*#/ {next} - /^[[:space:]]*$/ {next} - {print} - ' | grep -E -c "$re" || true) - - if ((c > 0)); then - files+=("$f") - total=$((total + c)) - fi - done - - # Fallback to /root/.ssh/authorized_keys - if ((${#files[@]} == 0)) && [[ -r /root/.ssh/authorized_keys ]]; then - if grep -E -q "$re" /root/.ssh/authorized_keys; then - files+=(/root/.ssh/authorized_keys) - total=$((total + $(grep -E -c "$re" /root/.ssh/authorized_keys || echo 0))) - fi - fi - - FOUND_HOST_KEY_COUNT="$total" - ( - IFS=: - echo "${files[*]}" - ) -} - -# ============================================================================== -# SECTION 3B: IP RANGE SCANNING -# ============================================================================== - -# ------------------------------------------------------------------------------ -# ip_to_int() / int_to_ip() -# -# - Converts IP address to integer and vice versa for range iteration -# ------------------------------------------------------------------------------ -ip_to_int() { - local IFS=. - read -r i1 i2 i3 i4 <<<"$1" - echo $(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4)) -} - -int_to_ip() { - local ip=$1 - echo "$(((ip >> 24) & 0xFF)).$(((ip >> 16) & 0xFF)).$(((ip >> 8) & 0xFF)).$((ip & 0xFF))" -} - -# ------------------------------------------------------------------------------ -# resolve_ip_from_range() -# -# - Takes an IP range in format "10.0.0.1/24-10.0.0.10/24" -# - Pings each IP in the range to find the first available one -# - Returns the first free IP with CIDR notation -# - Sets NET_RESOLVED to the resolved IP or empty on failure -# ------------------------------------------------------------------------------ -resolve_ip_from_range() { - local range="$1" - local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$' - local ip_start ip_end - - # Parse range: "10.0.0.1/24-10.0.0.10/24" - ip_start="${range%%-*}" - ip_end="${range##*-}" - - if [[ ! "$ip_start" =~ $ip_cidr_regex ]] || [[ ! "$ip_end" =~ $ip_cidr_regex ]]; then - NET_RESOLVED="" - return 1 - fi - - local ip1="${ip_start%%/*}" - local ip2="${ip_end%%/*}" - local cidr="${ip_start##*/}" - - local start_int=$(ip_to_int "$ip1") - local end_int=$(ip_to_int "$ip2") - - for ((ip_int = start_int; ip_int <= end_int; ip_int++)); do - local ip=$(int_to_ip $ip_int) - msg_info "Checking IP: $ip" - if ! ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then - NET_RESOLVED="$ip/$cidr" - msg_ok "Found free IP: ${BGN}$NET_RESOLVED${CL}" - return 0 - fi - done - - NET_RESOLVED="" - msg_error "No free IP found in range $range" - return 1 -} - -# ------------------------------------------------------------------------------ -# is_ip_range() -# -# - Checks if a string is an IP range (contains - and looks like IP/CIDR) -# - Returns 0 if it's a range, 1 otherwise -# ------------------------------------------------------------------------------ -is_ip_range() { - local value="$1" - local ip_start ip_end - if [[ "$value" == *-* ]] && [[ "$value" != "dhcp" ]]; then - local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$' - ip_start="${value%%-*}" - ip_end="${value##*-}" - if [[ "$ip_start" =~ $ip_cidr_regex ]] && [[ "$ip_end" =~ $ip_cidr_regex ]]; then - return 0 - fi - fi - return 1 -} - -# ============================================================================== -# SECTION 4: STORAGE & RESOURCE MANAGEMENT -# ============================================================================== - -# ------------------------------------------------------------------------------ -# _write_storage_to_vars() -# -# - Writes storage selection to vars file -# - Removes old entries (commented and uncommented) to avoid duplicates -# - Arguments: vars_file, key (var_container_storage/var_template_storage), value -# ------------------------------------------------------------------------------ -_write_storage_to_vars() { - # $1 = vars_file, $2 = key (var_container_storage / var_template_storage), $3 = value - local vf="$1" key="$2" val="$3" - # remove uncommented and commented versions to avoid duplicates - sed -i "/^[#[:space:]]*${key}=/d" "$vf" - echo "${key}=${val}" >>"$vf" -} - -choose_and_set_storage_for_file() { - # $1 = vars_file, $2 = class ('container'|'template') - local vf="$1" class="$2" key="" current="" - case "$class" in - container) key="var_container_storage" ;; - template) key="var_template_storage" ;; - *) - msg_error "Unknown storage class: $class" - return 65 - ;; - esac - - current=$(awk -F= -v k="^${key}=" '$0 ~ k {print $2; exit}' "$vf") - - # If only one storage exists for the content type, auto-pick. Else always ask (your wish #4). - local content="rootdir" - [[ "$class" == "template" ]] && content="vztmpl" - local count - count=$(pvesm status -content "$content" | awk 'NR>1{print $1}' | wc -l) - - if [[ "$count" -eq 1 ]]; then - STORAGE_RESULT=$(pvesm status -content "$content" | awk 'NR>1{print $1; exit}') - STORAGE_INFO="" - - # Validate storage space for auto-picked container storage - if [[ "$class" == "container" && -n "${DISK_SIZE:-}" ]]; then - validate_storage_space "$STORAGE_RESULT" "$DISK_SIZE" "yes" - # Continue even if validation fails - user was warned - fi - else - # If the current value is preselectable, we could show it, but per your requirement we always offer selection - select_storage "$class" || return 150 - fi - - _write_storage_to_vars "$vf" "$key" "$STORAGE_RESULT" - - # Keep environment in sync for later steps (e.g. app-default save) - if [[ "$class" == "container" ]]; then - export var_container_storage="$STORAGE_RESULT" - export CONTAINER_STORAGE="$STORAGE_RESULT" - else - export var_template_storage="$STORAGE_RESULT" - export TEMPLATE_STORAGE="$STORAGE_RESULT" - fi - - # Silent operation - no output message -} - -# ============================================================================== -# SECTION 5: CONFIGURATION & DEFAULTS MANAGEMENT -# ============================================================================== - -# ------------------------------------------------------------------------------ -# Distinct versions of OS type $1 in the cached pveam catalog (no network). -# `pveam available` prints "

\t