- validation helpers: sanitize_input trims CR/edge whitespace only; embedded control/non-ASCII bytes FAIL validation and re-prompt with a hint (2026-06-11 incident: invisible byte in a pasted VLAN tag broke pct create mid-run) - never silently stripped - prompt_lxc_config: every prompt validated (uint for CTID/disk/cores/ RAM, VLAN 1-4094, hostname/token formats, IP/CIDR/gateway, DNS list); env-provided values are sanitized + validated too (abort, no re-prompt loop in non-interactive use); helpers reusable for app prompts - tests/test_validation.sh: 34 cases incl. the 2<0x80>0 repro, re-prompt simulation, BASH_REMATCH clobbering regression (is_cidr), env dry-run of prompt_lxc_config without PVE/TTY - tests/check_ct_source.sh: every ct/*.sh must source build.func (bug shipped twice); negative proof via prepared fixture in the test suite - .gitea/workflows/ci.yml: bash -n over all scripts, source-check, validation tests, shellcheck if present (documented skip otherwise) - README: contributions via PR with cross-review (binding)
417 lines
17 KiB
Bash
417 lines
17 KiB
Bash
#!/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/<app>.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/<app>-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() {
|
|
local leftover
|
|
leftover="$(printf '%s' "$1" | LC_ALL=C tr -d '\40-\176')"
|
|
[[ -z "$leftover" ]]
|
|
}
|
|
|
|
is_uint() { is_clean_ascii "$1" && [[ "$1" =~ ^[0-9]+$ ]]; }
|
|
is_vlan_tag() { is_uint "$1" && (( $1 >= 1 && $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 (( 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" && (( _prefix >= 1 && _prefix <= 32 ))
|
|
}
|
|
|
|
is_ipcfg() { [[ "$1" == "dhcp" ]] || is_cidr "$1"; }
|
|
|
|
# Space/comma-separated list of IPv4s (DNS prompt).
|
|
is_ipv4_list() {
|
|
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() {
|
|
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
|
|
read -rp "$_pv_prompt" _pv_value
|
|
_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() {
|
|
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
|
|
read -rp "Container ID [auto]: " CTID
|
|
CTID="$(sanitize_input "$CTID")"
|
|
[[ -z "${CTID:-}" ]] && CTID=$(pvesh get /cluster/nextid)
|
|
fi
|
|
require_valid CTID is_uint "Container ID"
|
|
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
|
|
: # explicit override from env, leave untouched
|
|
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
|
|
}
|
|
|
|
# ── 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=<dns-ip> 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:-<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)..."
|
|
pct exec "$CTID" -- bash -c "curl -fsSL '$url' -o /root/${APP}-install.sh && 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/<app>.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
|
|
}
|