#!/usr/bin/env bash # lib/build.func — shared host-side helpers for ct/*.sh # # Sourced via: # source <(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib/build.func) # # Caller (ct/.sh) is expected to set: # APP short app name (used for filenames, banner) # APP_DESCRIPTION one-line description (shown in banner) # INSTALL_SCRIPT_URL URL of the matching install/-install.sh # and may override the DEFAULT_* values below. # # Caller may define print_app_summary() to customize the trailing summary. # # NB: We use CT_HOSTNAME (not HOSTNAME) because HOSTNAME is a bash built-in # that always holds the current host's name, which would defeat any prompt. # ── colors / logging ───────────────────────────────────────────────────────── if [[ -z "${_COLORS_LOADED:-}" ]]; then RED="\033[0;31m"; GREEN="\033[0;32m"; YELLOW="\033[1;33m"; BLUE="\033[0;34m"; NC="\033[0m" _COLORS_LOADED=1 fi msg_info() { echo -e "${BLUE}[i]${NC} $*"; } msg_ok() { echo -e "${GREEN}[✓]${NC} $*"; } msg_warn() { echo -e "${YELLOW}[!]${NC} $*"; } msg_err() { echo -e "${RED}[✗]${NC} $*" >&2; } # ── defaults (per-script overridable before sourcing or via env) ───────────── DEFAULT_HOSTNAME="${DEFAULT_HOSTNAME:-lxc}" DEFAULT_DISK="${DEFAULT_DISK:-8}" DEFAULT_CORES="${DEFAULT_CORES:-2}" DEFAULT_RAM="${DEFAULT_RAM:-1024}" DEFAULT_BRIDGE="${DEFAULT_BRIDGE:-vmbr0}" DEFAULT_TEMPLATE_STORAGE="${DEFAULT_TEMPLATE_STORAGE:-local}" DEFAULT_ROOTFS_STORAGE="${DEFAULT_ROOTFS_STORAGE:-local-lvm}" DEFAULT_TEMPLATE_PATTERN="${DEFAULT_TEMPLATE_PATTERN:-debian-12-standard}" DEFAULT_NAMESERVER="${DEFAULT_NAMESERVER:-}" # Network profiles: map a VLAN tag → DNS servers (+ subnet), maintained in # lib/networks.conf so adding a network is a one-line change. Applied even for # DHCP, so every container gets the right resolvers for its VLAN. NET_PROFILES_URL="${NET_PROFILES_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib/networks.conf}" # Set NET_PROFILES_FILE to a local path to use that instead of the remote file. # ── error trap ─────────────────────────────────────────────────────────────── _on_error() { local exit_code=$? msg_err "Installation failed (exit $exit_code)" if [[ -n "${CTID:-}" ]] && pct status "$CTID" >/dev/null 2>&1; then msg_warn "LXC $CTID was created. To clean up: pct stop $CTID && pct destroy $CTID" fi exit "$exit_code" } # ── checks ─────────────────────────────────────────────────────────────────── preflight_pve() { [[ "$EUID" -eq 0 ]] || { msg_err "Must run as root"; exit 1; } command -v pveversion >/dev/null 2>&1 || { msg_err "Not a Proxmox VE host (pveversion not found)"; exit 1; } } show_header() { local app="$1" desc="${2:-}" clear echo "═══════════════════════════════════════════════════════════════" echo " ${app} · Proxmox LXC installer" [[ -n "$desc" ]] && echo " ${desc}" echo " luki-net/proxmox-scripts" echo "═══════════════════════════════════════════════════════════════" echo } # ── network profiles ───────────────────────────────────────────────────────── # Look up DNS servers for the chosen VLAN tag from lib/networks.conf. # Sets PROFILE_DNS (space-separated, ready for pct --nameserver) and # PROFILE_SUBNET. Both stay empty if there's no matching profile. apply_network_profile() { PROFILE_DNS=""; PROFILE_SUBNET="" local key="${VLAN_TAG:-}"; [[ -z "$key" ]] && key="none" local data="" if [[ -n "${NET_PROFILES_FILE:-}" && -r "${NET_PROFILES_FILE:-}" ]]; then data=$(cat "$NET_PROFILES_FILE") else data=$(curl -fsSL "$NET_PROFILES_URL" 2>/dev/null) || data="" fi [[ -n "$data" ]] || { msg_warn "Could not load network profiles ($NET_PROFILES_URL)"; return 0; } local t s d _rest while read -r t s d _rest; do [[ -z "$t" || "$t" == \#* ]] && continue # skip blanks/comments if [[ "$t" == "$key" ]]; then PROFILE_SUBNET="$s" PROFILE_DNS="${d//,/ }" # pct wants space-separated return 0 fi done <<< "$data" return 0 } # ── input validation (K-114) ───────────────────────────────────────────────── # Real incident 2026-06-11: a pasted VLAN tag carried an invisible non-UTF-8 # byte and broke `pct create` deep in the run. Policy: trim CR/edge whitespace, # but NEVER silently strip junk inside a value — embedded control/non-ASCII # bytes fail validation and trigger a visible re-prompt. # Trim \r and leading/trailing whitespace (edges only). sanitize_input() { local s="${1-}" s="${s//$'\r'/}" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } # True if the value contains only printable ASCII (no control/non-ASCII # bytes). Byte-exact via tr: delete all printable ASCII — anything left # over is junk. is_clean_ascii() { # Newline/Tab zuerst explizit ablehnen — $(…) strippt trailing newlines, # die der tr-Pfad sonst übersehen würde (Review-Finding K-114). [[ "$1" == *$'\n'* || "$1" == *$'\t'* ]] && return 1 local leftover leftover="$(printf '%s' "$1" | LC_ALL=C tr -d '\40-\176')" [[ -z "$leftover" ]] } is_uint() { is_clean_ascii "$1" && [[ "$1" =~ ^[0-9]+$ ]]; } # 10#: führende Nullen nicht als Oktal werten ("08" wäre sonst ein # Arithmetik-Fehler statt einer sauberen Ablehnung/Annahme). is_vlan_tag() { is_uint "$1" && (( 10#$1 >= 1 && 10#$1 <= 4094 )); } is_token() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; } is_hostname() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; } is_ipv4() { is_clean_ascii "$1" && [[ "$1" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 local o for o in "${BASH_REMATCH[@]:1:4}"; do (( 10#$o <= 255 )) || return 1; done return 0 } is_cidr() { [[ "$1" =~ ^([0-9.]+)/([0-9]{1,2})$ ]] || return 1 # BASH_REMATCH retten — is_ipv4 nutzt selbst =~ und überschreibt es. local _ip="${BASH_REMATCH[1]}" _prefix="${BASH_REMATCH[2]}" is_ipv4 "$_ip" && (( 10#$_prefix >= 1 && 10#$_prefix <= 32 )) } is_ipcfg() { [[ "$1" == "dhcp" ]] || is_cidr "$1"; } # Ja/Nein-Antworten (Prompts wie "… erlauben? [Y/n]"). Akzeptiert # deutsch/englisch, normalize_yesno macht daraus kanonisch yes|no. is_yesno() { is_clean_ascii "$1" && [[ "${1,,}" =~ ^(y|yes|j|ja|n|no|nein)$ ]]; } normalize_yesno() { case "${1,,}" in y|yes|j|ja) printf 'yes' ;; *) printf 'no' ;; esac } # Space/comma-separated list of IPv4s (DNS prompt). Gesamtstring zuerst # prüfen — die Wort-Splittung würde eingebettete Newlines sonst verstecken. is_ipv4_list() { is_clean_ascii "$1" || return 1 local item for item in ${1//,/ }; do is_ipv4 "$item" || return 1; done [[ -n "$1" ]] } # prompt_validated VARNAME PROMPT VALIDATOR [DEFAULT] [allow_empty] # Reads into VARNAME (nameref, no subshell), sanitizes, applies the default on # empty input and re-prompts until the validator passes. allow_empty=yes lets # an empty value through (e.g. "no VLAN"). prompt_validated() { # Schutz vor zirkulärem nameref (Review-Finding): interne Namen tabu. [[ "$1" == _pv_* || "$1" == _rv_* ]] && { msg_err "prompt_validated: reserved variable name '$1'"; return 2; } local -n _pv_ref="$1" local _pv_prompt="$2" _pv_validator="$3" _pv_default="${4-}" _pv_allow_empty="${5:-no}" local _pv_value while true; do if ! read -rp "$_pv_prompt" _pv_value; then # EOF (kein TTY / stdin erschöpft): kein Endlos-Loop, sauber raus — # unter set -e bricht der Caller damit kontrolliert ab. msg_err "No input available for prompt: ${_pv_prompt%% *}" return 1 fi _pv_value="$(sanitize_input "$_pv_value")" if [[ -z "$_pv_value" && -n "$_pv_default" ]]; then _pv_value="$_pv_default" fi if [[ -z "$_pv_value" ]]; then if [[ "$_pv_allow_empty" == "yes" ]]; then _pv_ref=""; return 0; fi msg_warn "A value is required." continue fi if "$_pv_validator" "$_pv_value"; then _pv_ref="$_pv_value" return 0 fi if ! is_clean_ascii "$_pv_value"; then msg_warn "Input contains invisible/non-ASCII characters — please re-type (do not paste)." else msg_warn "Invalid value: '$_pv_value' — please retry." fi done } # Validate an env-provided value (non-interactive: abort instead of re-prompt). require_valid() { [[ "$1" == _pv_* || "$1" == _rv_* ]] && { msg_err "require_valid: reserved variable name '$1'"; return 2; } local -n _rv_ref="$1" local _rv_validator="$2" _rv_label="$3" _rv_ref="$(sanitize_input "$_rv_ref")" "$_rv_validator" "$_rv_ref" || { msg_err "$_rv_label invalid: '$_rv_ref'"; exit 1; } } # ── prompts ────────────────────────────────────────────────────────────────── # Each prompt is skipped if the corresponding variable is already set in env # (env values are still validated — abort on invalid, no silent use). # VLAN_TAG and NAMESERVER use ${VAR+x} so that explicitly setting them to "" # via env skips the prompt (= "no VLAN" / "inherit DNS from host"). prompt_lxc_config() { if [[ -z "${CTID:-}" ]]; then # Eigener Loop statt prompt_validated: leer = auto (pvesh nextid), # ungültig = Re-Prompt (Review-Finding: vorher Abbruch statt Re-Prompt). while true; do if ! read -rp "Container ID [auto]: " CTID; then msg_err "No input available for prompt: Container ID" return 1 fi CTID="$(sanitize_input "$CTID")" if [[ -z "$CTID" ]]; then CTID=$(pvesh get /cluster/nextid) break fi is_uint "$CTID" && break msg_warn "Invalid value: '$CTID' — please retry (digits only)." done else require_valid CTID is_uint "Container ID" fi echo " → CTID: $CTID" if [[ -z "${CT_HOSTNAME:-}" ]]; then prompt_validated CT_HOSTNAME "Hostname [$DEFAULT_HOSTNAME]: " is_hostname "$DEFAULT_HOSTNAME" else require_valid CT_HOSTNAME is_hostname "Hostname" fi if [[ -z "${DISK_SIZE:-}" ]]; then prompt_validated DISK_SIZE "Disk size in GB [$DEFAULT_DISK]: " is_uint "$DEFAULT_DISK" else require_valid DISK_SIZE is_uint "Disk size" fi if [[ -z "${CORES:-}" ]]; then prompt_validated CORES "vCPU cores [$DEFAULT_CORES]: " is_uint "$DEFAULT_CORES" else require_valid CORES is_uint "vCPU cores" fi if [[ -z "${RAM:-}" ]]; then prompt_validated RAM "RAM in MB [$DEFAULT_RAM]: " is_uint "$DEFAULT_RAM" else require_valid RAM is_uint "RAM" fi if [[ -z "${BRIDGE:-}" ]]; then prompt_validated BRIDGE "Bridge [$DEFAULT_BRIDGE]: " is_token "$DEFAULT_BRIDGE" else require_valid BRIDGE is_token "Bridge" fi if [[ -z "${VLAN_TAG+x}" ]]; then # The 2026-06-11 incident prompt: junk bytes re-prompt, empty = no VLAN. prompt_validated VLAN_TAG "VLAN tag (empty for none): " is_vlan_tag "" yes elif [[ -n "${VLAN_TAG:-}" ]]; then require_valid VLAN_TAG is_vlan_tag "VLAN tag" fi if [[ -z "${TEMPLATE_STORAGE:-}" ]]; then prompt_validated TEMPLATE_STORAGE \ "Template storage [$DEFAULT_TEMPLATE_STORAGE]: " is_token "$DEFAULT_TEMPLATE_STORAGE" else require_valid TEMPLATE_STORAGE is_token "Template storage" fi if [[ -z "${ROOTFS_STORAGE:-}" ]]; then prompt_validated ROOTFS_STORAGE \ "Rootfs storage [$DEFAULT_ROOTFS_STORAGE]: " is_token "$DEFAULT_ROOTFS_STORAGE" else require_valid ROOTFS_STORAGE is_token "Rootfs storage" fi if [[ -z "${IPCFG:-}" ]]; then prompt_validated IPCFG "Network: IP/CIDR or 'dhcp' [dhcp]: " is_ipcfg "dhcp" else require_valid IPCFG is_ipcfg "Network (IP/CIDR or dhcp)" fi GATEWAY="${GATEWAY:-}" if [[ "$IPCFG" != "dhcp" ]]; then if [[ -z "$GATEWAY" ]]; then prompt_validated GATEWAY "Gateway: " is_ipv4 else require_valid GATEWAY is_ipv4 "Gateway" fi fi # DNS is driven by the VLAN tag via the network profile (lib/networks.conf), # so the right resolvers get set even with DHCP. Precedence: # 1. explicit NAMESERVER from env (even "" = inherit) → respected as-is # 2. profile match for this VLAN tag → use its DNS # 3. static IP, no profile → ask # 4. DHCP, no profile → inherit from host apply_network_profile if [[ -n "${NAMESERVER+x}" ]]; then # Explizites Override aus env: "" = inherit bleibt erlaubt, aber ein # gesetzter Wert wird validiert (Review-Finding: lief vorher ungeprüft # bis in pct create). if [[ -n "${NAMESERVER:-}" ]]; then require_valid NAMESERVER is_ipv4_list "DNS server" fi elif [[ -n "$PROFILE_DNS" ]]; then NAMESERVER="$PROFILE_DNS" msg_info "DNS for VLAN ${VLAN_TAG:-none} (${PROFILE_SUBNET:-?}): $NAMESERVER" elif [[ "$IPCFG" != "dhcp" ]]; then local default_ns="${DEFAULT_NAMESERVER:-$GATEWAY}" prompt_validated NAMESERVER \ "DNS server [$default_ns] (empty = inherit from PVE host): " \ is_ipv4_list "$default_ns" yes else msg_warn "No network profile for VLAN ${VLAN_TAG:-none}; DHCP DNS will be inherited." fi # SSH-Root-Login (Default: ja, Homelab-Komfort). Umgesetzt wird das im # Install-Pfad per sshd-Drop-in (configure_ssh_root_login, lib/install.func); # bootstrap_install_script reicht den normalisierten Wert in den Container. if [[ -z "${SSH_ROOT_LOGIN:-}" ]]; then prompt_validated SSH_ROOT_LOGIN "SSH-Root-Login erlauben? [Y/n]: " is_yesno "y" else require_valid SSH_ROOT_LOGIN is_yesno "SSH root login (y/n)" fi SSH_ROOT_LOGIN="$(normalize_yesno "$SSH_ROOT_LOGIN")" echo " → SSH root login: $SSH_ROOT_LOGIN" } # ── template ───────────────────────────────────────────────────────────────── resolve_debian_template() { msg_info "Resolving template ($DEFAULT_TEMPLATE_PATTERN)..." pveam update >/dev/null 2>&1 || true TEMPLATE=$(pveam available --section system \ | awk -v t="$DEFAULT_TEMPLATE_PATTERN" '$2 ~ t {print $2}' \ | sort -V | tail -n1) [[ -n "$TEMPLATE" ]] || { msg_err "No template matching '$DEFAULT_TEMPLATE_PATTERN' found"; exit 1; } if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then msg_info "Downloading template $TEMPLATE to $TEMPLATE_STORAGE..." pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" fi msg_ok "Template: $TEMPLATE" } # ── LXC ────────────────────────────────────────────────────────────────────── create_lxc() { ROOT_PASSWORD=$(openssl rand -base64 18) local net_opts="name=eth0,bridge=$BRIDGE" [[ -n "${VLAN_TAG:-}" ]] && net_opts+=",tag=$VLAN_TAG" if [[ "$IPCFG" == "dhcp" ]]; then net_opts+=",ip=dhcp" else net_opts+=",ip=$IPCFG,gw=$GATEWAY" fi # Assemble pct args as an array so conditional flags stay clean. local pct_args=( "$CTID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" --hostname "$CT_HOSTNAME" --cores "$CORES" --memory "$RAM" --swap 512 --rootfs "$ROOTFS_STORAGE:$DISK_SIZE" --net0 "$net_opts" --features nesting=1 --unprivileged 1 --onboot 1 --ostype debian --password "$ROOT_PASSWORD" ) [[ -n "${NAMESERVER:-}" ]] && pct_args+=(--nameserver "${NAMESERVER//,/ }") msg_info "Creating unprivileged LXC $CTID ($CT_HOSTNAME)..." msg_info " net0: $net_opts" [[ -n "${NAMESERVER:-}" ]] && msg_info " dns: ${NAMESERVER//,/ }" pct create "${pct_args[@]}" msg_ok "LXC $CTID created" msg_info "Starting LXC..." pct start "$CTID" sleep 5 msg_info "Waiting for network + DNS..." local i ping_ok=0 dns_ok=0 # We test L3 (gateway/internet) and DNS separately so we can give a useful # error message instead of a generic "network never came up". for i in {1..30}; do if [[ $ping_ok -eq 0 ]] && pct exec "$CTID" -- bash -c "ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 || ping -c 1 -W 2 ${GATEWAY:-1.1.1.1} >/dev/null 2>&1"; then ping_ok=1 fi if pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then dns_ok=1 break fi sleep 2 done if [[ $dns_ok -eq 1 ]]; then msg_ok "Network + DNS up" return 0 fi if [[ $ping_ok -eq 1 ]]; then msg_err "L3 connectivity OK but DNS resolution failed." msg_err "Container can reach the internet but can't resolve names." msg_err "Re-run with NAMESERVER= set, or fix /etc/resolv.conf inside the LXC." else msg_err "Network never came up. Check bridge/VLAN/gateway settings:" msg_err " pct exec $CTID -- ip a" msg_err " pct exec $CTID -- ip r" msg_err " pct exec $CTID -- ping -c2 ${GATEWAY:-}" fi exit 1 } # ── bootstrap installer inside the container ───────────────────────────────── bootstrap_install_script() { local url="$1" msg_info "Installing curl in container..." pct exec "$CTID" -- bash -c "apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null" msg_info "Running installer ($url)..." # SSH_ROOT_LOGIN ist durch normalize_yesno kanonisch yes|no — als Env in # den Container durchreichen (configure_ssh_root_login wertet es aus). pct exec "$CTID" -- bash -c "curl -fsSL '$url' -o /root/${APP}-install.sh && SSH_ROOT_LOGIN='${SSH_ROOT_LOGIN:-yes}' bash /root/${APP}-install.sh" } # ── summary ────────────────────────────────────────────────────────────────── get_container_ip() { pct exec "$CTID" -- bash -c "hostname -I | awk '{print \$1}'" | tr -d '\r\n' } # Override in ct/.sh for app-specific output. print_app_summary() { echo " (no app-specific summary defined)" } print_summary() { IP_CT=$(get_container_ip) echo msg_ok "Installation complete!" echo echo "─────────────────────────────────────────────────────────────" echo " $APP LXC #$CTID — $CT_HOSTNAME" echo "─────────────────────────────────────────────────────────────" print_app_summary echo echo " LXC root password: $ROOT_PASSWORD" echo "─────────────────────────────────────────────────────────────" echo } # ── orchestrator ───────────────────────────────────────────────────────────── run_installer() { trap _on_error ERR preflight_pve show_header "$APP" "${APP_DESCRIPTION:-}" prompt_lxc_config resolve_debian_template create_lxc bootstrap_install_script "$INSTALL_SCRIPT_URL" print_summary }