diff --git a/.github/workflows/verify-split-files.yml b/.github/workflows/verify-split-files.yml index 9f4cb45..96d0694 100644 --- a/.github/workflows/verify-split-files.yml +++ b/.github/workflows/verify-split-files.yml @@ -34,6 +34,7 @@ on: - "tests/forge-connection.sh" - "tests/failed-command-output.sh" - "tests/forge-release-json.sh" + - "tests/container-identity.sh" - ".github/workflows/verify-split-files.yml" push: branches: [main] @@ -59,6 +60,7 @@ on: - "tests/forge-connection.sh" - "tests/failed-command-output.sh" - "tests/forge-release-json.sh" + - "tests/container-identity.sh" - ".github/workflows/verify-split-files.yml" workflow_dispatch: @@ -80,6 +82,9 @@ jobs: done < <(find $ENGINE_DIRS -name '*.func' -type f) exit $fail + - name: Container identity regression tests + run: bash tests/container-identity.sh + - name: ISO storage regression tests run: bash tests/vm-iso-storage.sh diff --git a/core/build.func b/core/build.func index bf748a4..647d928 100644 --- a/core/build.func +++ b/core/build.func @@ -235,7 +235,7 @@ _CS_ENGINE_FILES=( lib/alpine.func api/api.func api/errorlog.func api/exitcodes.func api/sysinfo.func api/telemetry.func - ui/build-ui.func ui/advanced.func ui/defaults.func + ui/build-ui.func ui/advanced.func ui/defaults.func ui/identity.func ui/menu.func ui/validate.func vm/cloud-init.func vm/common.func core/core.func core/dev-mode.func core/error_handler.func lxc/install.func lxc/platform.func host/preflight.func diff --git a/pve/backend.func b/pve/backend.func index ee523c9..34f4121 100644 --- a/pve/backend.func +++ b/pve/backend.func @@ -135,6 +135,7 @@ reclaim_tty() { # umask. Scoped to the call, so the operator's umask still governs everything # else we write on the host. _pct_create() { + _identity_assert_id "$1" || return 1 local prev rc prev="$(umask)" umask 022 @@ -358,6 +359,7 @@ lxc_os_support_check() { build_container() { # if [ "$VERBOSE" == "yes" ]; then set -x; fi + _identity_assert_id "$CT_ID" || exit 208 lxc_os_support_check NET_STRING="-net0 name=eth0,bridge=${BRG:-vmbr0}" @@ -1717,7 +1719,8 @@ PROFILE echo -e "${BFR}${CM}${GN}Container ${CTID} removed${CL}" echo "" local old_ctid="$CTID" - export CTID=$(get_valid_container_id "$CTID") + CTID=$(get_valid_container_id "$CTID") || exit 109 + export CTID export VERBOSE="yes" export var_verbose="yes" @@ -1819,7 +1822,8 @@ PROFILE local old_ctid="$CTID" local old_ram="$RAM_SIZE" local old_cpu="$CORE_COUNT" - export CTID=$(get_valid_container_id "$CTID") + CTID=$(get_valid_container_id "$CTID") || exit 109 + export CTID export RAM_SIZE=$((RAM_SIZE * 2)) export CORE_COUNT=$((CORE_COUNT * 2)) export var_ram="$RAM_SIZE" @@ -1850,7 +1854,8 @@ PROFILE echo "" local old_ctid="$CTID" local old_disk="$DISK_SIZE" - export CTID=$(get_valid_container_id "$CTID") + CTID=$(get_valid_container_id "$CTID") || exit 109 + export CTID export DISK_SIZE=$((DISK_SIZE * 2)) export var_disk="$DISK_SIZE" export VERBOSE="yes" @@ -1878,7 +1883,8 @@ PROFILE echo -e "${BFR}${CM}${GN}Container ${CTID} removed${CL}" echo "" local old_ctid="$CTID" - export CTID=$(get_valid_container_id "$CTID") + CTID=$(get_valid_container_id "$CTID") || exit 109 + export CTID export DNS_RETRY_OVERRIDE="true" export VERBOSE="yes" export var_verbose="yes" @@ -2853,7 +2859,7 @@ create_lxc_container() { } if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then msg_warn "Container/VM ID $CTID is already in use (detected late). Reassigning..." - CTID=$(get_valid_container_id "$((CTID + 1))") + CTID=$(get_valid_container_id "$((CTID + 1))") || exit 109 export CTID msg_ok "Reassigned to container ID $CTID" fi @@ -3412,7 +3418,7 @@ create_lxc_container() { if grep -qiE 'already exists|already in use' "$LOGFILE"; then local old_ctid="$CTID" - CTID=$(get_valid_container_id "$((CTID + 1))") + CTID=$(get_valid_container_id "$((CTID + 1))") || exit 109 export CTID msg_warn "Container ID $old_ctid was claimed by another process. Retrying with ID $CTID" LOGFILE="/tmp/pct_create_${CTID}_$(date +%Y%m%d_%H%M%S)_${SESSION_ID}.log" diff --git a/tests/container-identity.sh b/tests/container-identity.sh new file mode 100755 index 0000000..3b3a5e6 --- /dev/null +++ b/tests/container-identity.sh @@ -0,0 +1,778 @@ +#!/usr/bin/env bash +# Isolated engine regression tests. No guest, host config, network, or firewall writes. +set -eo pipefail +CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ $# == 0 ]]; then + failed=0 passed=0 + for scenario in legacy-pve legacy-incus incus-reject bounds holes generic-1200 generic-3000 generic-high-id local-orphan empty-cluster api-failure malformed-resources exhausted explicit-id tokens invalid-templates ipv6 defaults global-defaults precedence env-precedence save-reload save-ipv6-bounds save-ipv6-mac save-ipv6-all save-ipv6-legacy wizard wizard-accept wizard-id-change wizard-clear-mac ipv6-literal-implicit ipv6-literal-methods wizard-back-edits wizard-back-clear wizard-modes wizard-modes-static wizard-cancel-ipv6 final-net0 late-collision pct-collision; do + if bash "$0" "$scenario"; then + echo "PASS $scenario" + passed=$((passed + 1)) + else + echo "FAIL $scenario" >&2 + failed=$((failed + 1)) + fi + done + echo "$passed passed, $failed failed" + ((failed == 0)) + exit $? +fi + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEST_DIR"' EXIT +# Tests must not inherit the operator's policy or proxy configuration. +for key in ${!var_@} ${!_IDENTITY_@}; do unset "$key"; done +if [[ "$1" == env-precedence ]]; then + export var_ctid_min=612 var_mac=02:00:00:00:99:09 var_net=dhcp +fi +_cs_source_func() { source "$CORE_DIR/$1"; } +source "$CORE_DIR/ui/build-ui.func" >/dev/null 2>&1 +trap - ERR EXIT +trap 'rm -rf -- "$TEST_DIR"' EXIT +set -eo pipefail +msg_info() { :; } +msg_ok() { :; } +msg_warn() { :; } +msg_error() { echo "$*" >&2; } +ensure_dependencies() { command -v jq >/dev/null; } +lvs() { :; } +pvesh() { + case "$*" in + 'get /cluster/nextid') echo 900 ;; + 'get /cluster/resources --type vm --output-format json') + [[ "${API_FAIL:-0}" == 0 ]] || return 42 + echo "${RESOURCES:-[]}" + ;; + *) + echo "Unexpected pvesh call: $*" >&2 + return 1 + ;; + esac +} +resolve_os_version() { :; } +detect_lxc_platform() { echo pve; } +APP=Identity NSAPP=identity NEXTID=900 timezone=UTC var_os=debian var_version=13 + +same() { [[ "$1" == "$2" ]] || { + echo "Expected <$1>, got <$2>" >&2 + return 1 +}; } +reject() { + if ("$@") >"$TEST_DIR/rejected" 2>&1; then + echo "Unexpected success: $*" >&2 + return 1 + fi +} +assert_lacks() { + # Negated commands suppress errexit and later assertions can hide failure. + # Exit explicitly on a match and also propagate grep's I/O errors. + if grep -Fq -- "$1" "$2"; then + echo "Unexpected <$1> in $2" >&2 + exit 1 + else + local rc=$? + if ((rc != 1)); then exit "$rc"; fi + fi +} +policy() { + var_ctid_min=604 var_ctid_max=9999 + var_mac_template='02:00:00:00:{id_hi2}:{id_lo2}' +} +static_policy() { + policy + var_net_template='10.20.{id_hi}.{id_lo}/16' + var_ipv6_template='2001:db8::{id_hi}:{id_lo}/64' +} +backend() { + source "$CORE_DIR/pve/backend.func" + trap - ERR EXIT + trap 'rm -rf -- "$TEST_DIR"' EXIT +} +# Exercise the complete real build_container through its final PCT_OPTIONS. +# Stop at the creation boundary, before any guest or host writes. +net0_capture() { + backend + trap - EXIT + lxc_os_support_check() { :; } + _cs_fetch_text() { :; } + cs_testing_state() { :; } + pvesm() { :; } + pct_timezone() { echo UTC; } + create_lxc_container() { + printf '%s\n' "$PCT_OPTIONS" >"$TEST_DIR/pct-options" + rm -rf -- "$TEMP_DIR" + exit 0 + } + DIAGNOSTICS=no RANDOM_UUID=test EXECUTION_ID=test SESSION_ID=test BUILD_LOG=/dev/null + COMMUNITY_SCRIPTS_CORE_URL=unused IPV6_GATE="" + build_container +} +# Actual wizard, with whiptail's response stream and noninteractive host stubs. +wizard_run() { + local wizard_pid="$BASHPID" + populate_advanced_bridge_menu() { BRIDGES=vmbr0; } + tput() { :; } + timedatectl() { :; } + configure_ssh_settings() { :; } + validate_bridge() { [[ "$1" == vmbr0 ]]; } + whiptail() { + local title="" previous="" menu_default="" arg + for arg in "$@"; do + [[ "$previous" == --title ]] && title="$arg" + [[ "$previous" == --default-item ]] && menu_default="$arg" + previous="$arg" + done + if [[ "${WIZARD_ACCEPT:-0}" == 1 ]]; then + case "$title" in + 'STATIC IPv4 ADDRESS') same "$NET" "${!#}" || exit 96 ;; + 'STATIC IPv6 ADDRESS') same "$IPV6_ADDR" "${!#}" || exit 96 ;; + 'MAC ADDRESS') same "${MAC#*,hwaddr=}" "${!#}" || exit 96 ;; + 'IPv4 CONFIGURATION' | 'IPv6 CONFIGURATION') [[ " $* " == *' --default-item static '* ]] || exit 96 ;; + esac + fi + printf '%s\n' "$title" >>"$TEST_DIR/dialogs" + if [[ $(wc -l <"$TEST_DIR/dialogs") -gt 90 ]]; then + tail -12 "$TEST_DIR/dialogs" >&2 + kill -TERM "$wizard_pid" + return 98 + fi + if [[ -n "${WIZARD_NAVIGATION:-}" ]]; then + local visit + visit=$(grep -c -x "$title" "$TEST_DIR/dialogs") + printf '%s|%s|%s\n' "$title" "$menu_default" "${!#}" >>"$TEST_DIR/dialog-state" + case "$title" in + 'IPv4 CONFIGURATION') + if [[ "$WIZARD_NAVIGATION" == back-* ]]; then + if ((visit == 1)); then echo static >&2; else echo "$menu_default" >&2; fi + elif ((visit == 1)) || [[ "$WIZARD_NAVIGATION" == modes || "$WIZARD_NAVIGATION" == cancel-ipv6 ]]; then + echo dhcp >&2 + else + echo static >&2 + fi + return 0 + ;; + 'STATIC IPv4 ADDRESS') + if ((visit == 1)); then echo 10.20.9.3/16 >&2; else echo "${!#}" >&2; fi + return 0 + ;; + 'IPv6 CONFIGURATION') + if ((visit == 1)); then return 1; fi # Back to IPv4 after its accepted edit/mode change. + if [[ "$WIZARD_NAVIGATION" == back-* ]]; then + if ((visit == 2)); then echo static >&2; else echo "$menu_default" >&2; fi + elif ((visit == 2)); then + echo "${WIZARD_IPV6_MODE:-dhcp}" >&2 + elif [[ "$WIZARD_NAVIGATION" == modes-static || ("$WIZARD_NAVIGATION" == cancel-ipv6 && "$visit" == 3) ]]; then + echo static >&2 + else + echo "$menu_default" >&2 + fi + return 0 + ;; + 'STATIC IPv6 ADDRESS') + if [[ "$WIZARD_NAVIGATION" == cancel-ipv6 ]]; then return 1; fi + if ((visit == 1)); then echo 2001:db8::93/64 >&2; else echo "${!#}" >&2; fi + return 0 + ;; + 'MTU SIZE') + if ((visit == 1)); then return 1; fi # Back to IPv6 after its accepted edit/mode change. + ;; + 'MAC ADDRESS') + if ((visit == 1)); then + if [[ "$WIZARD_NAVIGATION" == back-clear ]]; then echo '' >&2; else echo 02:00:00:00:99:03 >&2; fi + else + echo "${!#}" >&2 + fi + return 0 + ;; + 'VLAN TAG') + if ((visit == 1)); then return 1; fi # Back to MAC, including intentional clearing. + ;; + esac + fi + case "$title" in + 'CONTAINER TYPE') echo 1 >&2 ;; + 'ROOT PASSWORD') echo '' >&2 ;; + 'CONTAINER ID') echo "${WIZARD_ID:-$CT_ID}" >&2 ;; + HOSTNAME) echo identity >&2 ;; + 'DISK SIZE') echo 4 >&2 ;; + 'CPU CORES') echo 1 >&2 ;; + 'RAM SIZE') echo 1024 >&2 ;; + 'IPv4 CONFIGURATION') echo static >&2 ;; + 'STATIC IPv4 ADDRESS') echo "${WIZARD_NET:-$NET}" >&2 ;; + 'GATEWAY IP') echo 10.20.0.1 >&2 ;; + 'IPv6 CONFIGURATION') echo static >&2 ;; + 'STATIC IPv6 ADDRESS') echo "${WIZARD_IPV6:-$IPV6_ADDR}" >&2 ;; + 'IPv6 GATEWAY') echo '' >&2 ;; + 'MAC ADDRESS') echo "${WIZARD_MAC-${MAC#*,hwaddr=}}" >&2 ;; + 'VLAN TAG') echo 6 >&2 ;; + 'CONTAINER TAGS') echo community-script >&2 ;; + 'CONTAINER TIMEZONE') echo UTC >&2 ;; + 'NESTING SUPPORT' | 'CONFIRM SETTINGS') return 0 ;; + 'MTU SIZE' | 'DNS SEARCH DOMAIN' | 'DNS SERVER' | 'MOUNT FILESYSTEMS' | 'POST-INSTALL HOOK (HOST)') echo '' >&2 ;; + 'FUSE SUPPORT' | 'TUN/TAP SUPPORT' | 'GPU PASSTHROUGH' | 'KEYCTL SUPPORT' | 'APT CACHER PROXY' | 'HTTP/HTTPS PROXY' | 'HOST CA INHERITANCE' | 'CONTAINER PROTECTION' | 'DEVICE NODE CREATION' | 'VERBOSE MODE') return 1 ;; + *) + echo "Unexpected wizard dialog: $title / $*" >&2 + exit 99 + ;; + esac + } + # Older wizard arithmetic is not errexit-safe (e.g. ((STEP++))). + set +e + advanced_settings >"$TEST_DIR/wizard-output" + local rc=$? + set -e + return "$rc" +} + +case "$1" in +legacy-pve) + base_settings + same 900 "$CT_ID" + same dhcp "$NET" + same '' "$MAC" + same none "$IPV6_METHOD" + same 900 "$(get_valid_container_id)" + cat <<'VARS' >"$TEST_DIR/legacy.vars" +var_ipv6_static=2001:db8::42/64 +VARS + load_vars_file "$TEST_DIR/legacy.vars" yes + same '' "${var_ipv6_static:-}" + backend + pct() { echo "$1 $2" >"$TEST_DIR/pct-calls"; } + _pct_create 613 ignored + same 'create 613' "$(cat "$TEST_DIR/pct-calls")" + ;; +legacy-incus | incus-reject) + # Use the actual Incus entry point and its identifier overrides. + _INCUS_CORE_FUNC_LOADED=1 _INCUS_TOOLS_FUNC_LOADED=1 + source "$CORE_DIR/incus/build.func" + trap - ERR EXIT SIGHUP SIGINT SIGTERM + trap 'rm -rf -- "$TEST_DIR"' EXIT + resolve_os_version() { :; } + incus_container_exists() { return 1; } + incus_pick_default_network() { echo incusbr0; } + NEXTID=identity + if [[ "$1" == incus-reject ]]; then + for option in var_ctid_min var_mac_template var_net_template var_ipv6_template; do + if [[ "$option" == var_ctid_min ]]; then value=604; else value='{id}'; fi + ( + export "$option=$value" + reject base_settings + ) + done + else + cat <<'VARS' >"$TEST_DIR/incus.vars" +var_cpu=2 +var_net=dhcp +VARS + load_vars_file "$TEST_DIR/incus.vars" yes + base_settings + same 2 "$CORE_COUNT" + same identity "$CT_ID" + same incusbr0 "$BRG" + same dhcp "$NET" + same '' "$MAC" + same identity "$(get_valid_container_id identity)" + fi + ;; +bounds) + var_ctid_min=604 var_ctid_max=606 + same 604 "$(get_valid_container_id)" + for value in 99 0604 1000000000 '1+603' 999999999999999999999999; do + ( + var_ctid_min="$value" + reject get_valid_container_id + ) + done + var_ctid_min=607 + reject get_valid_container_id + ;; +local-orphan) + var_ctid_min=604 var_ctid_max=606 + lvs() { echo vm-604-disk-0; } + same 605 "$(get_valid_container_id)" + ;; +holes) + var_ctid_min=604 var_ctid_max=620 + RESOURCES='[{"vmid":604,"type":"qemu","node":"other"},{"vmid":606,"type":"lxc","node":"other"},{"vmid":610,"type":"qemu"}]' + same 605 "$(get_valid_container_id)" + base_settings + same 605 "$CT_ID" + ;; +generic-1200) + static_policy + var_ctid_min=1200 var_ctid_max=1299 var_vlan=42 + RESOURCES='[{"vmid":1200,"type":"qemu","node":"other"}]' + same 1201 "$(get_valid_container_id)" + base_settings + same 1201 "$CT_ID" + same 02:00:00:00:12:01 "$MAC" + same 10.20.12.1/16 "$NET" + same 2001:db8::12:1/64 "$IPV6_ADDR" + (net0_capture) + grep -q -- 'hwaddr=02:00:00:00:12:01,ip=10.20.12.1/16,tag=42,ip6=2001:db8::12:1/64' "$TEST_DIR/pct-options" + ;; +generic-3000) + static_policy + var_ctid_min=3000 var_ctid_max=3999 var_vlan=77 + var_mac_template='02:00:00:01:{id_hi2}:{id_lo2}' + # Mixed remote VM/CT occupancy below a hole, with another guest above it. + RESOURCES=$(jq -n '[range(3000;3042) | {vmid:., type:(if . % 2 == 0 then "qemu" else "lxc" end), node:"other"}] + [{vmid:3043,type:"qemu",node:"other"}]') + same 3042 "$(get_valid_container_id)" + base_settings + same 3042 "$CT_ID" + same 02:00:00:01:30:42 "$MAC" + same 10.20.30.42/16 "$NET" + same 2001:db8::30:42/64 "$IPV6_ADDR" + (net0_capture) + grep -q -- 'hwaddr=02:00:00:01:30:42,ip=10.20.30.42/16,tag=77,ip6=2001:db8::30:42/64' "$TEST_DIR/pct-options" + ;; +generic-high-id) + # Allocation has the PVE ID limit, independent of any MAC template's limit. + var_ctid_min=20012 var_ctid_max=20099 + RESOURCES='[{"vmid":20013,"type":"lxc","node":"other"}]' + same 20012 "$(get_valid_container_id)" + base_settings + same 20012 "$CT_ID" + same '' "$MAC" + same dhcp "$NET" + same '' "${_IDENTITY_BOUND_ID:-}" + same 20014 "$(get_valid_container_id 20014)" + var_ipv6_template='2001:db8::{id_hi}:{id_lo}/64' + var_vlan=123 + base_settings + same 20012 "$CT_ID" + same 2001:db8::200:12/64 "$IPV6_ADDR" + same '' "$MAC" + same dhcp "$NET" + (net0_capture) + grep -q -- 'bridge=vmbr0,ip=dhcp,tag=123,ip6=2001:db8::200:12/64' "$TEST_DIR/pct-options" + reject _identity_render '02:00:00:00:{id_hi2}:{id_lo2}' 20012 mac + grep -q 'without truncation' "$TEST_DIR/rejected" + ;; +empty-cluster) + var_ctid_min=604 var_ctid_max=604 + RESOURCES='[ ]' + same 604 "$(get_valid_container_id)" + # A standalone assignment under errexit must also succeed for []. + selected=$(get_valid_container_id) + same 604 "$selected" + ;; +api-failure) + var_ctid_min=604 API_FAIL=1 + reject get_valid_container_id + grep -q 'refusing to guess' "$TEST_DIR/rejected" + var_ctid=612 + reject base_settings + ;; +malformed-resources) + var_ctid_min=604 + for RESOURCES in null '{}' '' '[{}]' '[{"vmid":"604","type":"qemu"}]' '[{"vmid":604.5,"type":"lxc"}]' '[{"vmid":604,"type":"node"}]' '[{"vmid":1,"type":"qemu"}]' 'garbage'; do + # pvesh stub defaults only unset resources, not empty, in this scenario. + pvesh() { echo "$RESOURCES"; } + reject get_valid_container_id + done + ;; +exhausted) + var_ctid_min=604 var_ctid_max=605 + RESOURCES='[{"vmid":604,"type":"lxc"},{"vmid":605,"type":"qemu"}]' + reject get_valid_container_id + # Inclusive upper boundary. + RESOURCES='[{"vmid":604,"type":"lxc"}]' + same 605 "$(get_valid_container_id)" + ;; +explicit-id) + policy + var_ctid=612 + base_settings + same 612 "$CT_ID" + same 02:00:00:00:06:12 "$MAC" + unset _IDENTITY_BOUND_ID + var_ctid=603 + base_settings + same 603 "$CT_ID" + same 02:00:00:00:06:03 "$MAC" + unset _IDENTITY_BOUND_ID + var_ctid=10000 var_mac=02:00:00:00:99:09 + base_settings + same 10000 "$CT_ID" + same 02:00:00:00:99:09 "$MAC" + ;; +tokens) + same 02:00:00:00:06:12 "$(_identity_render '02:00:00:00:{id_hi2}:{id_lo2}' 612 mac)" + same 10.20.6.12/16 "$(_identity_render '10.20.{id_hi}.{id_lo}/16' 612 net)" + same 192.0.2.150/24 "$(_identity_render '192.0.2.{id}/24' 150 net)" + same 192.0.2.104/24 "$(_identity_render '192.0.2.{id}/24' 104 net)" + same 02:00:00:00:06:04 "$(_identity_render '02:00:00:00:{id_hi2}:{id_lo2}' 604 mac)" + same 02:00:00:00:99:99 "$(_identity_render '02:00:00:00:{id_hi2}:{id_lo2}' 9999 mac)" + ;; +invalid-templates) + reject _identity_render '02:00:00:00:{id_hi2}:{id_lo2}' 10000 mac + reject _identity_render '02:00:00:00:00:{id_lo2}' 612 mac + reject _identity_render '02:00:00:00:{id_hi2}:{unknown}' 612 mac + reject _identity_render '10.20.{id_hi}.{id_lo}/16' 999999999 net + reject _identity_render '10.20.08.{id_lo}/16' 612 net + reject _identity_render '10.20.1.{id_lo}/033' 612 net + reject _identity_render '10.20.1.$(touch /tmp/identity-unsafe)' 612 net + reject _identity_render '10.20.{id_hi}.{id_lo}/24' 700 net + reject _identity_render '192.0.2.{id}/24' 255 net + same 192.0.2.100/31 "$(_identity_render '192.0.2.{id}/31' 100 net)" + same 192.0.2.100/32 "$(_identity_render '192.0.2.{id}/32' 100 net)" + reject _identity_render '2001:db8::{id}/64' 999999 ipv6 + ;; +ipv6) + _identity_validate_address ipv6 1:2:3:4:5:6:7::/64 + _identity_validate_address ipv6 ::/128 + reject _identity_validate_address ipv6 1:2:3:4:5:6:7:8::/64 + reject _identity_validate_address ipv6 2001:db8::1/999999999999999999999 + for template in '2001:db8::{id}/64' '1:2:3:4:5:6:7::{id_lo}/64' '1:2:3:4:5:6:{id}::/64' '::{id}/128' '{id}::/64' '{id}::1/64' '1:2:3:4:5:6:7:{id}/64'; do + case "$template" in + '1:2:3:4:5:6:7::{id_lo}/64') reject _identity_render "$template" 612 ipv6 ;; + *) _identity_render "$template" 612 ipv6 >/dev/null ;; + esac + done + for template in '1::2::{id}/64' '1:::2:{id}/64' ':1:2:3:4:5:6:{id}/64' '1:2:3:{id}/64' '1:2:3:4:5:6:7:{id}:9/64' '2001:db8::{id}/064' '2001:db8::{id}/129' '2001:db8::{id}/64/64'; do + reject _identity_render "$template" 612 ipv6 + done + ;; +defaults) + cat <<'VARS' >"$TEST_DIR/default.vars" +var_ctid_min=100 +var_ctid_max=199 +var_net_template='192.0.2.{id}/24' +VARS + load_vars_file "$TEST_DIR/default.vars" + base_settings + same 100 "$CT_ID" + same 192.0.2.100/24 "$NET" + cat <<'VARS' >"$TEST_DIR/bad.vars" +var_mac_template='02:00:00:00:{bad}:{id_lo2}' +VARS + reject load_vars_file "$TEST_DIR/bad.vars" + ;; +global-defaults) + community_scripts_dir() { echo "$TEST_DIR"; } + header_info() { :; } + echo_default() { :; } + cat <<'VARS' >"$TEST_DIR/default.vars" +var_ctid_min=150 +var_ctid_max=199 +var_net_template=192.0.2.{id}/24 +var_net=dhcp +VARS + default_var_settings >/dev/null + same 150 "$CT_ID" + same dhcp "$NET" + unset var_net + base_settings + same 192.0.2.150/24 "$NET" + ;; +env-precedence) + cat <<'VARS' >"$TEST_DIR/app.vars" +var_ctid_min=610 +var_mac=02:00:00:00:99:01 +var_net=192.0.2.42/24 +var_mac_template=02:00:00:00:{id_hi2}:{id_lo2} +var_net_template=10.20.{id_hi}.{id_lo}/16 +VARS + load_vars_file "$TEST_DIR/app.vars" yes + base_settings + same 612 "$CT_ID" + same 02:00:00:00:99:09 "$MAC" + same dhcp "$NET" + ;; +precedence) + # App defaults override global/app-script values; genuine exported env wins. + policy + cat <<'VARS' >"$TEST_DIR/app.vars" +var_ctid_min=610 +var_mac_template=02:00:00:00:{id_hi2}:{id_lo2} +var_net_template=10.20.{id_hi}.{id_lo}/16 +var_net=dhcp +var_mac=02:00:00:00:99:01 +var_ipv6_template=2001:db8::{id}/64 +var_ipv6_method=none +VARS + load_vars_file "$TEST_DIR/app.vars" yes + base_settings + same 610 "$CT_ID" + same 02:00:00:00:99:01 "$MAC" + same dhcp "$NET" + same none "$IPV6_METHOD" + _IDENTITY_ENV_KEYS=' var_ctid_min var_mac var_net' + var_ctid_min=612 var_mac=02:00:00:00:99:02 var_net=192.0.2.42/24 + load_vars_file "$TEST_DIR/app.vars" yes + base_settings + same 612 "$CT_ID" + same 02:00:00:00:99:02 "$MAC" + same 192.0.2.42/24 "$NET" + ;; +save-reload) + static_policy + var_ctid=612 + base_settings + var_vlan=6 VLAN=,tag=6 + saved=$(_build_current_app_vars_tmp) + cp "$saved" "$TEST_DIR/saved.vars" + rm -f "$saved" + grep -Fqx 'var_mac_template=02:00:00:00:{id_hi2}:{id_lo2}' "$TEST_DIR/saved.vars" + grep -Fqx 'var_net_template=10.20.{id_hi}.{id_lo}/16' "$TEST_DIR/saved.vars" + grep -Fqx 'var_ipv6_template=2001:db8::{id_hi}:{id_lo}/64' "$TEST_DIR/saved.vars" + if grep -Eq '^var_(mac|net|ipv6_static|ipv6_method)=' "$TEST_DIR/saved.vars"; then exit 1; fi + unset var_ctid _IDENTITY_BOUND_ID _IDENTITY_GENERATED_MAC _IDENTITY_GENERATED_NET _IDENTITY_GENERATED_IPV6 + load_vars_file "$TEST_DIR/saved.vars" yes + base_settings + same 604 "$CT_ID" + same 02:00:00:00:06:04 "$MAC" + same 10.20.6.4/16 "$NET" + same 2001:db8::6:4/64 "$IPV6_ADDR" + same 6 "$VLAN" + (net0_capture) + grep -q -- ',ip6=2001:db8::6:4/64$' "$TEST_DIR/pct-options" + ;; +save-ipv6-bounds | save-ipv6-mac | save-ipv6-all | save-ipv6-legacy) + var_ctid=612 + case "$1" in + save-ipv6-bounds) var_ctid_min=604 ;; + save-ipv6-mac) var_mac_template='02:00:00:00:{id_hi2}:{id_lo2}' ;; + save-ipv6-all) static_policy ;; + save-ipv6-legacy) : ;; + esac + base_settings + WIZARD_MAC="${MAC#*,hwaddr=}" + WIZARD_NET=10.20.9.3/16 WIZARD_IPV6=2001:db8::93/64 + wizard_run + same static "$IPV6_METHOD" + same 2001:db8::93/64 "$IPV6_ADDR" + saved=$(_build_current_app_vars_tmp) + cp "$saved" "$TEST_DIR/saved.vars" + rm -f "$saved" + grep -Fqx 'var_ipv6_method=static' "$TEST_DIR/saved.vars" + if [[ "$1" == save-ipv6-legacy ]]; then + assert_lacks 'var_ipv6_static=' "$TEST_DIR/saved.vars" + else + grep -Fqx 'var_ipv6_static=2001:db8::93/64' "$TEST_DIR/saved.vars" + fi + case "$1" in + save-ipv6-mac | save-ipv6-all) grep -Fqx 'var_mac_template=02:00:00:00:{id_hi2}:{id_lo2}' "$TEST_DIR/saved.vars" ;; + esac + if [[ "$1" == save-ipv6-all ]]; then + grep -Fqx 'var_net_template=10.20.{id_hi}.{id_lo}/16' "$TEST_DIR/saved.vars" + grep -Fqx 'var_ipv6_template=2001:db8::{id_hi}:{id_lo}/64' "$TEST_DIR/saved.vars" + fi + # Clear both config and rendered state; reload solely from the saved file. + for key in ${!var_@} ${!_IDENTITY_@}; do unset "$key"; done + var_ctid=614 var_os=debian var_version=13 + IPV6_ADDR='' + load_vars_file "$TEST_DIR/saved.vars" yes + base_settings + same static "$IPV6_METHOD" + same 614 "$CT_ID" + same 10.20.9.3/16 "$NET" + (net0_capture) + if [[ "$1" == save-ipv6-legacy ]]; then + same '' "${IPV6_ADDR:-}" + assert_lacks ',ip6=' "$TEST_DIR/pct-options" + else + same 2001:db8::93/64 "$IPV6_ADDR" + grep -q -- ',ip6=2001:db8::93/64$' "$TEST_DIR/pct-options" + fi + case "$1" in + save-ipv6-mac | save-ipv6-all) + same 02:00:00:00:06:14 "$MAC" + grep -q -- ',hwaddr=02:00:00:00:06:14,' "$TEST_DIR/pct-options" + ;; + esac + ;; +wizard | wizard-accept | wizard-id-change | wizard-clear-mac) + static_policy + var_ctid=612 + base_settings + if [[ "$1" == wizard-id-change ]]; then + WIZARD_ID=613 + reject wizard_run + grep -q 'templates were rendered for CTID 612' "$TEST_DIR/rejected" + else + case "$1" in + wizard-clear-mac) + WIZARD_MAC='' + WIZARD_NET=10.20.9.3/16 WIZARD_IPV6=2001:db8::93/64 + ;; + wizard-accept) + WIZARD_ACCEPT=1 + WIZARD_MAC="$MAC" + WIZARD_NET="$NET" WIZARD_IPV6="$IPV6_ADDR" + ;; + *) + WIZARD_MAC=02:00:00:00:99:03 + WIZARD_NET=10.20.9.3/16 WIZARD_IPV6=2001:db8::93/64 + ;; + esac + wizard_run + same 612 "$CT_ID" + same ",hwaddr=${WIZARD_MAC}" "${MAC:-,hwaddr=}" + saved=$(_build_current_app_vars_tmp) + cp "$saved" "$TEST_DIR/saved.vars" + rm -f "$saved" + unset var_mac var_net var_ipv6_static var_ipv6_method _IDENTITY_BOUND_ID + load_vars_file "$TEST_DIR/saved.vars" yes + base_settings + same "$WIZARD_MAC" "$MAC" + same "$WIZARD_NET" "$NET" + same "$WIZARD_IPV6" "$IPV6_ADDR" + fi + ;; +ipv6-literal-implicit) + var_ctid=612 + cat <<'VARS' >"$TEST_DIR/ipv6-literal.vars" +var_ctid_min=604 +var_ipv6_template=2001:db8::{id}/64 +var_ipv6_static=2001:db8::42/64 +VARS + load_vars_file "$TEST_DIR/ipv6-literal.vars" yes + base_settings + same static "$IPV6_METHOD" + same 2001:db8::42/64 "$IPV6_ADDR" + (net0_capture) + grep -q -- ',ip6=2001:db8::42/64$' "$TEST_DIR/pct-options" + ;; +ipv6-literal-methods) + for method in static none auto dhcp disable; do + ( + cat <"$TEST_DIR/ipv6-method.vars" +var_ctid_min=604 +var_ipv6_template=2001:db8::{id}/64 +var_ipv6_static=2001:db8::42/64 +var_ipv6_method=$method +VARS + load_vars_file "$TEST_DIR/ipv6-method.vars" yes + base_settings + same "$method" "$IPV6_METHOD" + (net0_capture) + case "$method" in + static) grep -q -- ',ip6=2001:db8::42/64$' "$TEST_DIR/pct-options" ;; + auto | dhcp) grep -q -- ",ip6=$method$" "$TEST_DIR/pct-options" ;; + none | disable) assert_lacks ',ip6=' "$TEST_DIR/pct-options" ;; + esac + ) + done + ;; +wizard-back-edits | wizard-back-clear | wizard-modes | wizard-modes-static | wizard-cancel-ipv6) + # Mode variants each drive real forward/back dialogs, then the actual builder. + for method in auto dhcp none disable; do + ( + static_policy + var_ctid=612 + base_settings + WIZARD_NAVIGATION="${1#wizard-}" + WIZARD_IPV6_MODE="$method" + rm -f "$TEST_DIR/dialogs" "$TEST_DIR/dialog-state" + wizard_run + if [[ "$WIZARD_NAVIGATION" == back-clear ]]; then same '' "$MAC"; fi + if [[ "$WIZARD_NAVIGATION" == back-* ]]; then + same 10.20.9.3/16 "$NET" + same 2001:db8::93/64 "$IPV6_ADDR" + grep -q '^STATIC IPv4 ADDRESS||10.20.9.3/16$' "$TEST_DIR/dialog-state" + grep -q '^STATIC IPv6 ADDRESS||2001:db8::93/64$' "$TEST_DIR/dialog-state" + else + grep -q '^IPv4 CONFIGURATION|dhcp|' "$TEST_DIR/dialog-state" + grep -q "^IPv6 CONFIGURATION|$method|" "$TEST_DIR/dialog-state" + fi + if [[ "$WIZARD_NAVIGATION" == back-clear ]]; then + same '' "$MAC" + grep -q '^MAC ADDRESS||$' "$TEST_DIR/dialog-state" + else + same ,hwaddr=02:00:00:00:99:03 "$MAC" + grep -q '^MAC ADDRESS||02:00:00:00:99:03$' "$TEST_DIR/dialog-state" + fi + (net0_capture) + case "$WIZARD_NAVIGATION" in + back-*) grep -q -- ',ip=10.20.9.3/16,gw=10.20.0.1,tag=6,ip6=2001:db8::93/64' "$TEST_DIR/pct-options" ;; + modes-static) + same static "$IPV6_METHOD" + same 10.20.9.3/16 "$NET" + grep -q '^STATIC IPv4 ADDRESS||$' "$TEST_DIR/dialog-state" + grep -q '^STATIC IPv6 ADDRESS||$' "$TEST_DIR/dialog-state" + grep -q -- ',ip=10.20.9.3/16,gw=10.20.0.1,tag=6,ip6=2001:db8::93/64' "$TEST_DIR/pct-options" + ;; + modes | cancel-ipv6) + same dhcp "$NET" + same "$method" "$IPV6_METHOD" + if [[ "$WIZARD_NAVIGATION" == cancel-ipv6 ]]; then + same 2 "$(grep -c "^IPv6 CONFIGURATION|$method|" "$TEST_DIR/dialog-state")" + fi + case "$method" in + auto | dhcp) grep -q -- ",ip=dhcp,tag=6,ip6=$method$" "$TEST_DIR/pct-options" ;; + none | disable) assert_lacks ',ip6=' "$TEST_DIR/pct-options" ;; + esac + ;; + esac + if [[ "$WIZARD_NAVIGATION" == back-clear ]]; then + assert_lacks ',hwaddr=' "$TEST_DIR/pct-options" + else + grep -q -- ',hwaddr=02:00:00:00:99:03,' "$TEST_DIR/pct-options" + fi + ) + # Back/edit scenarios are independent of nonstatic mode choices. + if [[ "$1" == wizard-back-* ]]; then break; fi + done + ;; +final-net0) + static_policy + var_ctid=612 var_vlan=6 + base_settings + (net0_capture) + grep -q -- '-net0 name=eth0,bridge=vmbr0,hwaddr=02:00:00:00:06:12,ip=10.20.6.12/16,tag=6,ip6=2001:db8::6:12/64' "$TEST_DIR/pct-options" + # Reservation-only path keeps DHCP and no static IPv6. + unset var_net_template var_ipv6_template _IDENTITY_BOUND_ID + base_settings + (net0_capture) + grep -q -- 'hwaddr=02:00:00:00:06:12,ip=dhcp,tag=6$' "$TEST_DIR/pct-options" + ;; +late-collision) + policy + var_ctid=612 + base_settings + backend + pct() { echo 'unexpected pct invocation' >>"$TEST_DIR/pct-calls"; } + reject get_valid_container_id 613 + reject _pct_create 613 ignored + [[ ! -e "$TEST_DIR/pct-calls" ]] + # Exercise real create_lxc_container's late collision branch before storage. + CTID=612 PCT_OSTYPE=debian + qm() { return 0; } + msg_debug() { :; } + reject create_lxc_container + grep -q 'templates were rendered for CTID 612' "$TEST_DIR/rejected" + [[ ! -e "$TEST_DIR/pct-calls" ]] + ;; +pct-collision) + policy + var_ctid=612 + base_settings + backend + { + echo 'collision_branch() {' + awk '/ # First attempt \(PCT_OPTIONS/ {copy=1} copy && /^ else$/ {exit} copy {print}' "$CORE_DIR/pve/backend.func" + echo 'fi; fi; }' + } >"$TEST_DIR/collision-branch.sh" + source "$TEST_DIR/collision-branch.sh" + CTID=612 TEMPLATE_STORAGE=local TEMPLATE=fixture SESSION_ID=test + LOGFILE="$TEST_DIR/pct.log" PCT_OPTIONS='-net0 name=eth0,hwaddr=02:00:00:00:06:12,ip=dhcp' + msg_debug() { :; } + pct() { + echo "$*" >>"$TEST_DIR/pct-calls" + echo 'VM 612 already exists' + return 1 + } + reject collision_branch + same 1 "$(wc -l <"$TEST_DIR/pct-calls" | tr -d ' ')" + grep -q '^create 612 ' "$TEST_DIR/pct-calls" + grep -q 'templates were rendered for CTID 612' "$TEST_DIR/rejected" + ;; +*) + echo "Unknown scenario $1" >&2 + exit 1 + ;; +esac diff --git a/ui/API.txt b/ui/API.txt index 4df57b1..5c559c5 100644 --- a/ui/API.txt +++ b/ui/API.txt @@ -34,6 +34,16 @@ _eh_signal_exit _eh_warn _eh_write_debug_log _get_current_ip +_identity_apply_templates +_identity_assert_id +_identity_decimal_id +_identity_enabled +_identity_error +_identity_policy +_identity_render +_identity_select_id +_identity_template_syntax +_identity_validate_address _in_zone_tab _is_container_context _is_whitelisted_key diff --git a/ui/advanced.func b/ui/advanced.func index 188622f..b312a68 100644 --- a/ui/advanced.func +++ b/ui/advanced.func @@ -44,6 +44,7 @@ advanced_settings() { local _pw="" local _pw_display="Automatic Login" local _ct_id="$NEXTID" + if _identity_enabled; then _ct_id="$CT_ID"; fi local _hostname="$NSAPP" local _disk_size="${var_disk:-4}" local _core_count="${var_cpu:-1}" @@ -65,6 +66,14 @@ advanced_settings() { local _ns="${var_ns:-}" local _mac="${var_mac:-}" local _vlan="${var_vlan:-}" + local -a _identity_ipv4_menu=() _identity_ipv6_menu=() + local _identity_static_ipv4="" _identity_static_ipv6="" _identity_mac="" + if _identity_enabled; then + _net="$NET" + _mac="${MAC#*,hwaddr=}" + _ipv6_method="$IPV6_METHOD" + _ipv6_addr="${IPV6_ADDR:-}" + fi local _tags="$TAGS" local _enable_fuse="${var_fuse:-no}" local _enable_tun="${var_tun:-no}" @@ -253,11 +262,13 @@ advanced_settings() { fi fi + _identity_assert_id "$input_id" || exit 208 + # Check if ID is already in use if ! validate_container_id "$input_id"; then if whiptail --backtitle "Proxmox VE Helper Scripts" --title "ID Already In Use" \ --yesno "Container/VM ID $input_id is already in use.\n\nWould you like to use the next available ID ($(get_valid_container_id "$input_id"))?" 10 58; then - _ct_id=$(get_valid_container_id "$input_id") + _ct_id=$(get_valid_container_id "$input_id") || exit 109 else continue fi @@ -410,9 +421,19 @@ advanced_settings() { # STEP 9: IPv4 Configuration # ═══════════════════════════════════════════════════════════════════════════ 9) + if _identity_enabled; then + _identity_static_ipv4="" + if [[ "$_net" == dhcp ]]; then + _identity_ipv4_menu=(--default-item dhcp) + else + _identity_ipv4_menu=(--default-item static) + _identity_static_ipv4="$_net" + fi + fi if result=$(whiptail --backtitle "Proxmox VE Helper Scripts [Step $STEP/$MAX_STEP]" \ --title "IPv4 CONFIGURATION" \ --ok-button "Next" --cancel-button "Back" \ + "${_identity_ipv4_menu[@]}" \ --menu "\nSelect IPv4 Address Assignment:" 16 65 3 \ "dhcp" "Automatic (DHCP, recommended)" \ "static" "Static (manual entry)" \ @@ -425,7 +446,7 @@ advanced_settings() { if static_ip=$(whiptail --backtitle "Proxmox VE Helper Scripts [Step $STEP/$MAX_STEP]" \ --title "STATIC IPv4 ADDRESS" \ --ok-button "Next" --cancel-button "Back" \ - --inputbox "\nEnter Static IPv4 CIDR Address\n(e.g. 192.168.1.100/24)" 12 58 "" \ + --inputbox "\nEnter Static IPv4 CIDR Address\n(e.g. 192.168.1.100/24)" 12 58 "$_identity_static_ipv4" \ 3>&1 1>&2 2>&3); then if validate_ip_address "$static_ip"; then # Get gateway @@ -507,9 +528,15 @@ advanced_settings() { # STEP 10: IPv6 Configuration # ═══════════════════════════════════════════════════════════════════════════ 10) + if _identity_enabled; then + _identity_ipv6_menu=(--default-item "$_ipv6_method") + _identity_static_ipv6="" + [[ "$_ipv6_method" == static ]] && _identity_static_ipv6="$_ipv6_addr" + fi if result=$(whiptail --backtitle "Proxmox VE Helper Scripts [Step $STEP/$MAX_STEP]" \ --title "IPv6 CONFIGURATION" \ --ok-button "Next" --cancel-button "Back" \ + "${_identity_ipv6_menu[@]}" \ --menu "\nSelect IPv6 Address Management:" 16 70 5 \ "auto" "SLAAC/AUTO (recommended) - Dynamic IPv6 from network" \ "dhcp" "DHCPv6 - DHCP-assigned IPv6 address" \ @@ -518,15 +545,15 @@ advanced_settings() { "disable" "Fully Disabled - (breaks some services)" \ 3>&1 1>&2 2>&3); then - _ipv6_method="$result" case "$result" in static) local ipv6_addr if ipv6_addr=$(whiptail --backtitle "Proxmox VE Helper Scripts" \ --title "STATIC IPv6 ADDRESS" \ - --inputbox "\nEnter IPv6 CIDR address\n(e.g. 2001:db8::1/64)" 12 58 "" \ + --inputbox "\nEnter IPv6 CIDR address\n(e.g. 2001:db8::1/64)" 12 58 "$_identity_static_ipv6" \ 3>&1 1>&2 2>&3); then if validate_ipv6_address "$ipv6_addr"; then + _ipv6_method=static _ipv6_addr="$ipv6_addr" # Optional gateway - loop until valid or empty local ipv6_gw_valid=false @@ -557,17 +584,20 @@ advanced_settings() { fi ;; dhcp) + _ipv6_method=dhcp _ipv6_addr="dhcp" _ipv6_gate="" ((STEP++)) ;; none) + _ipv6_method=none _ipv6_addr="none" _ipv6_gate="" ((STEP++)) ;; *) + _ipv6_method="$result" _ipv6_addr="" _ipv6_gate="" ((STEP++)) @@ -642,10 +672,12 @@ advanced_settings() { # STEP 14: MAC Address # ═══════════════════════════════════════════════════════════════════════════ 14) + # Use the accepted value even when the user deliberately cleared it. + if _identity_enabled; then _identity_mac="$_mac"; fi if result=$(whiptail --backtitle "Proxmox VE Helper Scripts [Step $STEP/$MAX_STEP]" \ --title "MAC ADDRESS" \ --ok-button "Next" --cancel-button "Back" \ - --inputbox "\nSet MAC Address\n(leave blank for auto-generated, format: XX:XX:XX:XX:XX:XX)" 12 62 "" \ + --inputbox "\nSet MAC Address\n(leave blank for auto-generated, format: XX:XX:XX:XX:XX:XX)" 12 62 "$_identity_mac" \ 3>&1 1>&2 2>&3); then if validate_mac_address "$result"; then _mac="$result" @@ -1181,6 +1213,7 @@ Advanced: # ═══════════════════════════════════════════════════════════════════════════ CT_TYPE="$_ct_type" PW="$_pw" + _identity_assert_id "$_ct_id" || exit 208 CT_ID="$_ct_id" HN="$_hostname" DISK_SIZE="$_disk_size" diff --git a/ui/build-ui.func b/ui/build-ui.func index 017147c..0f11a00 100644 --- a/ui/build-ui.func +++ b/ui/build-ui.func @@ -235,6 +235,7 @@ maxkeys_check() { # maintenance change only -- every function kept its name, body and behaviour. # # validate.func container id, hostname, network and IP range validators +# identity.func opt-in PVE CTID bounds and address templates # defaults.func storage selection, .vars files, app defaults # advanced.func the advanced settings wizard # menu.func settings and diagnostics menus, install_script, start @@ -243,6 +244,7 @@ maxkeys_check() { # Platform-independent value checks; the VM paths load the same file. _build_ui_source "host/validate.func" _build_ui_source "ui/validate.func" +_build_ui_source "ui/identity.func" _build_ui_source "ui/defaults.func" _build_ui_source "ui/advanced.func" _build_ui_source "ui/menu.func" diff --git a/ui/defaults.func b/ui/defaults.func index 7039615..b781463 100644 --- a/ui/defaults.func +++ b/ui/defaults.func @@ -188,6 +188,7 @@ resolve_os_version() { return 0 } base_settings() { + _identity_policy || exit 208 # Default Settings CT_TYPE=${var_unprivileged:-"1"} @@ -244,14 +245,17 @@ base_settings() { # Validate and set Container ID local requested_id="${var_ctid:-$NEXTID}" - if ! validate_container_id "$requested_id"; then + if [[ -n "${var_ctid_min:-}${var_ctid_max:-}" ]]; then + requested_id=$(_identity_select_id "${var_ctid:-}" "${var_ctid:+explicit}") || exit 109 + elif ! validate_container_id "$requested_id"; then # Only show warning if user manually specified an ID (not auto-assigned) if [[ -n "${var_ctid:-}" ]]; then msg_warn "Container ID $requested_id is already in use. Using next available ID: $(get_valid_container_id "$requested_id")" fi - requested_id=$(get_valid_container_id "$requested_id") + requested_id=$(get_valid_container_id "$requested_id") || exit 109 fi CT_ID="$requested_id" + _identity_assert_id "$CT_ID" || exit 208 # Validate and set Hostname/FQDN local requested_hostname="${var_hostname:-$NSAPP}" @@ -282,6 +286,17 @@ base_settings() { IPV6_METHOD=${var_ipv6_method:-"none"} IPV6_STATIC=${var_ipv6_static:-""} GATE=${var_gateway:-""} + MAC=${var_mac:-""} + if _identity_enabled; then + IPV6_ADDR="${var_ipv6_static:-}" + _identity_apply_templates || exit 208 + if [[ "$IPV6_METHOD" == static && -n "$IPV6_ADDR" ]]; then + _identity_validate_address ipv6 "$IPV6_ADDR" || { + _identity_error "invalid static IPv6 address" + exit 208 + } + fi + fi # Guard against invalid gateway combinations from defaults/app vars: # - DHCP must not force a static gateway @@ -328,7 +343,6 @@ base_settings() { [[ -n "$_sd_val" ]] && SD="-searchdomain=$_sd_val" || SD="" _ns_val="${var_ns:-""}" [[ -n "$_ns_val" ]] && NS="-nameserver=$_ns_val" || NS="" - MAC=${var_mac:-""} VLAN=${var_vlan:-""} SSH=${var_ssh:-"no"} SSH_AUTHORIZED_KEY=${var_ssh_authorized_key:-""} @@ -404,11 +418,16 @@ load_vars_file() { local protected="${3:-}" # space-separated var_* keys the user genuinely exported before this file loaded; never overwritten [ -f "$file" ] || return 0 msg_info "Loading defaults from ${file}" + local identity_file_options=0 + if _identity_enabled || grep -Eq '^[[:space:]]*var_(ctid_min|ctid_max|mac_template|net_template|ipv6_template)=' "$file"; then + identity_file_options=1 + fi # Allowed var_* keys local VAR_WHITELIST=( var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_http_no_proxy var_http_proxy var_inherit_host_ca var_keyctl var_gateway var_hostname var_ipv6_method var_mac var_mknod var_mount_fs var_mtu + var_ctid_min var_ctid_max var_mac_template var_net_template var_ipv6_template var_ipv6_static var_net var_nesting var_ns var_os var_post_install var_protection var_pw var_ram var_sdn_vnet var_searchdomain var_tags var_timezone var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage ) @@ -438,6 +457,12 @@ load_vars_file() { [[ "$var_key" != var_* ]] && continue _is_whitelisted "$var_key" || continue + [[ "$var_key" == var_ipv6_static && "$identity_file_options" == 0 ]] && continue + # Environment identity settings win even over force-loaded app defaults. + # Check before validation so ignored lower-priority values cannot fail a run. + if [[ " $_IDENTITY_ENV_KEYS " == *" $var_key "* && "$identity_file_options" == 1 ]]; then + continue + fi # Strip inline comments (anything after unquoted #) # Only strip if not inside quotes @@ -458,6 +483,24 @@ load_vars_file() { # Validate values before setting (skip empty values - they use defaults) if [[ -n "$var_val" ]]; then case "$var_key" in + var_ctid_min | var_ctid_max) + _identity_decimal_id "$var_val" || { + _identity_error "invalid $var_key in $file" + return 1 + } + ;; + var_mac_template | var_net_template | var_ipv6_template) + _identity_template_syntax "$var_val" || { + _identity_error "invalid $var_key in $file" + return 1 + } + ;; + var_ipv6_static) + _identity_validate_address ipv6 "$var_val" || { + _identity_error "invalid var_ipv6_static in $file" + return 1 + } + ;; var_mac) if ! validate_mac_address "$var_val"; then msg_warn "Invalid MAC address '$var_val' in $file, ignoring" @@ -584,8 +627,8 @@ load_vars_file() { fi ;; var_ipv6_method) - if [[ "$var_val" != "auto" && "$var_val" != "dhcp" && "$var_val" != "static" && "$var_val" != "none" ]]; then - msg_warn "Invalid IPv6 method '$var_val' in $file (must be auto/dhcp/static/none), ignoring" + if [[ "$var_val" != "auto" && "$var_val" != "dhcp" && "$var_val" != "static" && "$var_val" != "none" && "$var_val" != "disable" ]]; then + msg_warn "Invalid IPv6 method '$var_val' in $file (must be auto/dhcp/static/none/disable), ignoring" continue fi ;; @@ -662,6 +705,7 @@ default_var_settings() { local VAR_WHITELIST=( var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_http_no_proxy var_http_proxy var_inherit_host_ca var_keyctl var_gateway var_hostname var_ipv6_method var_mac var_mknod var_mount_fs var_mtu + var_ctid_min var_ctid_max var_mac_template var_net_template var_ipv6_template var_ipv6_static var_net var_nesting var_ns var_os var_post_install var_protection var_pw var_ram var_sdn_vnet var_searchdomain var_tags var_timezone var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage ) @@ -805,7 +849,7 @@ EOF # Later steps need the file they are reading from: a storage named here that # no longer holds the content has to be corrected in this file, not a guess. export CS_DEFAULT_VARS_FILE="$dv" - load_vars_file "$dv" "no" "${!_HARD_ENV[*]}" + load_vars_file "$dv" "no" "${!_HARD_ENV[*]}" || return 1 # 3) Map var_verbose → VERBOSE if [[ -n "${var_verbose:-}" ]]; then @@ -854,6 +898,7 @@ if ! declare -p VAR_WHITELIST >/dev/null 2>&1; then declare -ag VAR_WHITELIST=( var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_http_no_proxy var_http_proxy var_inherit_host_ca var_keyctl var_gateway var_hostname var_ipv6_method var_mac var_mknod var_mount_fs var_mtu + var_ctid_min var_ctid_max var_mac_template var_net_template var_ipv6_template var_ipv6_static var_net var_nesting var_ns var_os var_post_install var_protection var_pw var_ram var_sdn_vnet var_searchdomain var_tags var_timezone var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage ) @@ -981,6 +1026,7 @@ _build_current_app_vars_tmp() { _mtu="" _vlan="" _mac="" + if _identity_enabled; then _mac="${MAC:-}"; fi case "${MTU:-}" in ,mtu=*) _mtu=$(echo "$MTU" | sed 's/^,mtu=//') ;; esac @@ -1032,7 +1078,22 @@ _build_current_app_vars_tmp() { _tpl_storage="${TEMPLATE_STORAGE:-${var_template_storage:-}}" _ct_storage="${CONTAINER_STORAGE:-${var_container_storage:-}}" + if [[ -n "${_IDENTITY_GENERATED_MAC:-}" && "${MAC#*,hwaddr=}" == "$_IDENTITY_GENERATED_MAC" ]]; then _mac=""; fi + if [[ -n "${_IDENTITY_GENERATED_NET:-}" && "$_net" == "$_IDENTITY_GENERATED_NET" ]]; then _net=""; fi + { + local identity_key + for identity_key in var_ctid_min var_ctid_max var_mac_template var_net_template var_ipv6_template; do + if [[ "$identity_key" == var_mac_template && -n "${var_mac_template:-}" && -z "${MAC:-}" ]]; then + printf 'var_mac_template=\n' + continue + fi + [[ -n "${!identity_key:-}" ]] && printf '%s=%s\n' "$identity_key" "${!identity_key}" + done + # An explicit DHCP/manual IPv6 wizard choice must survive a template reload. + if [[ -n "${var_ipv6_template:-}" && "$_ipv6_method" == static && "$_ipv6_static" == "${_IDENTITY_GENERATED_IPV6:-}" ]]; then + _ipv6_method="" + fi echo "# App-specific defaults for ${APP} (${NSAPP})" echo "# Generated on $(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo @@ -1051,7 +1112,9 @@ _build_current_app_vars_tmp() { [ -n "$_ns" ] && echo "var_ns=$(_sanitize_value "$_ns")" [ -n "$_ipv6_method" ] && echo "var_ipv6_method=$(_sanitize_value "$_ipv6_method")" - # var_ipv6_static removed - static IPs are unique, can't be default + if _identity_enabled && [[ "$_ipv6_method" == static && -n "$_ipv6_static" ]]; then + printf 'var_ipv6_static=%s\n' "$_ipv6_static" + fi [ -n "$_ssh" ] && echo "var_ssh=$(_sanitize_value "$_ssh")" [ -n "$_ssh_auth" ] && echo "var_ssh_authorized_key=$(_sanitize_value "$_ssh_auth")" diff --git a/ui/identity.func b/ui/identity.func new file mode 100644 index 0000000..7082359 --- /dev/null +++ b/ui/identity.func @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Copyright (c) 2021-2026 community-scripts ORG +# License: MIT | https://github.com/community-scripts/core/raw/main/LICENSE + +# Opt-in PVE identity policy. No config is sourced or evaluated. +[[ -n "${_BUILD_UI_IDENTITY_LOADED:-}" ]] && return 0 +_BUILD_UI_IDENTITY_LOADED=1 + +# Capture genuine environment settings before the defaults loader exports values. +_IDENTITY_ENV_KEYS="" +for _identity_key in var_ctid_min var_ctid_max var_mac_template var_net_template var_ipv6_template var_mac var_net var_ipv6_static var_ipv6_method; do + if printenv "$_identity_key" >/dev/null 2>&1; then + _IDENTITY_ENV_KEYS+=" $_identity_key" + fi +done +unset _identity_key + +_identity_enabled() { + [[ -n "${var_ctid_min:-}${var_ctid_max:-}${var_mac_template:-}${var_net_template:-}${var_ipv6_template:-}" ]] +} + +_identity_error() { + msg_error "Container identity: $*" >&2 + return 1 +} + +_identity_decimal_id() { + # Canonical decimal only; bound length before shell arithmetic can overflow. + [[ "$1" =~ ^[1-9][0-9]{2,8}$ ]] && ((10#$1 <= 999999999)) +} + +_identity_policy() { + _identity_enabled || return 0 + if [[ "${_INCUS_BUILD_FUNC_LOADED:-}" == "1" || "${LXC_PLATFORM:-}" == incus* ]]; then + _identity_error "CTID bounds and address templates require Proxmox VE; remove them for Incus." + return 1 + fi + local lower="${var_ctid_min:-100}" upper="${var_ctid_max:-999999999}" + if ! _identity_decimal_id "$lower" || ! _identity_decimal_id "$upper" || ((lower > upper)); then + _identity_error "use CTID bounds 100..999999999 with min <= max." + return 1 + fi +} + +_identity_assert_id() { + _identity_enabled || return 0 + _identity_policy || return 1 + local id="$1" + if ! _identity_decimal_id "$id"; then + _identity_error "CTID '$id' must be a decimal Proxmox guest ID." + return 1 + fi + if [[ -n "${_IDENTITY_BOUND_ID:-}" && "$id" != "$_IDENTITY_BOUND_ID" ]]; then + _identity_error "templates were rendered for CTID $_IDENTITY_BOUND_ID; CTID changed to $id. Restart with the new ID before creating a guest." + return 1 + fi +} + +_identity_select_id() { + _identity_policy || return 1 + ensure_dependencies jq >&2 || return 1 + local candidate="${1:-${var_ctid_min:-100}}" upper="${var_ctid_max:-999999999}" + _identity_decimal_id "$candidate" || { + _identity_error "invalid CTID candidate '$candidate'." + return 1 + } + _identity_assert_id "$candidate" || return 1 + local resources ids + resources=$(pvesh get /cluster/resources --type vm --output-format json) || { + _identity_error "cluster resources lookup failed; refusing to guess a free CTID." + return 1 + } + ids=$(jq -esr 'if length == 1 then .[0] else error("expected one JSON resource list") end | if type == "array" and all(.[]; (.type == "qemu" or .type == "lxc") and (.vmid | type == "number" and . >= 100 and . <= 999999999 and floor == .)) then [.[].vmid] | unique | map(tostring) | join("\n") else error("invalid cluster guest list") end' <<<"$resources") || { + _identity_error "invalid cluster resources response; refusing to guess a free CTID." + return 1 + } + local -A occupied=() + local id + while IFS= read -r id; do + [[ -n "$id" ]] && occupied["$id"]=1 + done <<<"$ids" + if [[ "${2:-}" == explicit && -z "${occupied[$candidate]:-}" ]] && validate_container_id "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + # Bounds apply to automatic selection, never to an explicit free ID. + if ((candidate < ${var_ctid_min:-100} || candidate > upper)); then + candidate="${var_ctid_min:-100}" + fi + while ((candidate <= upper)); do + # Reuse the existing local/pmxcfs/LVM checks as well as the strict snapshot. + if [[ -z "${occupied[$candidate]:-}" ]] && validate_container_id "$candidate"; then + _identity_assert_id "$candidate" || return 1 + printf '%s\n' "$candidate" + return 0 + fi + candidate=$((candidate + 1)) + done + _identity_error "no free CTID in the configured range." +} + +_identity_template_syntax() { + local value="$1" token + [[ "$value" == *'{'* ]] || return 1 + for token in id id_hi id_lo id_hi2 id_lo2; do + value="${value//\{$token\}/0}" + done + [[ "$value" =~ ^[0-9a-fA-F:./]+$ ]] +} + +_identity_render() { + local template="$1" id="$2" kind="$3" + _identity_decimal_id "$id" && _identity_template_syntax "$template" || return 1 + local hi=$((id / 100)) lo=$((id % 100)) hi2 lo2 rendered="$template" + if [[ "$template" == *'{id_hi2}'* ]] && ((hi > 99)); then + _identity_error "{id_hi2} cannot represent CTID $id without truncation." + return 1 + fi + # A MAC suffix must encode the whole ID, never only its last two digits. + if [[ "$kind" == mac && "$template" != *'{id}'* ]]; then + if [[ "$template" != *'{id_hi}'* && "$template" != *'{id_hi2}'* ]] || + [[ "$template" != *'{id_lo}'* && "$template" != *'{id_lo2}'* ]]; then + _identity_error "MAC templates must include {id} or both high and low ID tokens." + return 1 + fi + fi + printf -v hi2 '%02d' "$hi" + printf -v lo2 '%02d' "$lo" + rendered="${rendered//\{id\}/$id}" + rendered="${rendered//\{id_hi\}/$hi}" + rendered="${rendered//\{id_lo\}/$lo}" + rendered="${rendered//\{id_hi2\}/$hi2}" + rendered="${rendered//\{id_lo2\}/$lo2}" + if [[ "$rendered" == *'{'* || "$rendered" == *'}'* || ! "$rendered" =~ ^[0-9a-fA-F:./]+$ ]]; then + _identity_error "unknown token or invalid character in $kind template." + return 1 + fi + _identity_validate_address "$kind" "$rendered" || return 1 + printf '%s\n' "$rendered" +} + +_identity_validate_address() { + local kind="$1" rendered="$2" + case "$kind" in + mac) + validate_mac_address "$rendered" || return 1 + local first_octet="${rendered%%:*}" + (((16#$first_octet & 1) == 0)) && [[ "$rendered" != 00:00:00:00:00:00 ]] || return 1 + ;; + net) + # The shared validator uses shell arithmetic: reject octal-looking fields. + [[ ! "$rendered" =~ (^|[./])0[0-9] ]] && validate_ip_address "$rendered" || return 1 + local cidr="${rendered##*/}" address host_mask host_part + address=$(ip_to_int "${rendered%/*}") + if ((cidr <= 30)); then + host_mask=$(((1 << (32 - cidr)) - 1)) + host_part=$((address & host_mask)) + ((host_part != 0 && host_part != host_mask)) || return 1 + fi + ;; + ipv6) + [[ "$rendered" =~ /[1-9][0-9]{0,2}$ ]] && validate_ipv6_address "$rendered" || return 1 + # The shared validator checks characters/hextet sizes, but not group count. + local addr="${rendered%%/*}" colons + [[ "${rendered%/*}" != */* ]] || return 1 + colons="${addr//[^:]/}" + if [[ "$addr" == *::* ]]; then + [[ "$addr" != *:::* ]] || return 1 + local segment count=0 + local -a segments + IFS=: read -r -a segments <<<"$addr" + for segment in "${segments[@]}"; do + [[ -n "$segment" ]] && count=$((count + 1)) + done + ((count < 8)) || return 1 + [[ "$addr" != :* || "$addr" == ::* ]] && [[ "$addr" != *: || "$addr" == *:: ]] || return 1 + else + ((${#colons} == 7)) && [[ "$addr" != :* && "$addr" != *: ]] || return 1 + fi + ;; + *) return 1 ;; + esac + return 0 +} + +_identity_apply_templates() { + _identity_policy || return 1 + unset _IDENTITY_GENERATED_MAC _IDENTITY_GENERATED_NET _IDENTITY_GENERATED_IPV6 + local field template literal rendered + for field in mac net ipv6; do + template="var_${field}_template" + [[ -n "${!template:-}" ]] || continue + case "$field" in + mac) literal="${var_mac:-}" ;; + net) literal="${var_net:-}" ;; + ipv6) literal="${var_ipv6_static:-}" ;; + esac + _identity_template_syntax "${!template}" || { + _identity_error "invalid $template" + return 1 + } + if [[ -n "$literal" ]]; then + # A literal replaces the IPv6 template's address, including its implicit + # static mode. Explicit nonstatic methods continue to take precedence. + if [[ "$field" == ipv6 && -z "${var_ipv6_method:-}" ]]; then + IPV6_METHOD=static + fi + continue + fi + if [[ "$field" == ipv6 && -n "${var_ipv6_method:-}" && "$var_ipv6_method" != static ]]; then + continue + fi + rendered=$(_identity_render "${!template}" "$CT_ID" "$field") || { + _identity_error "invalid $template for CTID $CT_ID." + return 1 + } + case "$field" in + mac) + MAC="$rendered" + _IDENTITY_GENERATED_MAC="$rendered" + ;; + net) + NET="$rendered" + _IDENTITY_GENERATED_NET="$rendered" + ;; + ipv6) + IPV6_METHOD=static + IPV6_ADDR="$rendered" + IPV6_STATIC="$rendered" + _IDENTITY_GENERATED_IPV6="$rendered" + ;; + esac + _IDENTITY_BOUND_ID="$CT_ID" + done +} diff --git a/ui/menu.func b/ui/menu.func index fd9c170..ed15146 100644 --- a/ui/menu.func +++ b/ui/menu.func @@ -328,7 +328,7 @@ install_script() { header_info echo -e "${DEFAULT}${BOLD}${BL}Using App Defaults for ${APP} on node $PVEHOST_NAME${CL}" METHOD="appdefaults" - load_vars_file "$(get_app_defaults_path)" "yes" # Force override script defaults + load_vars_file "$(get_app_defaults_path)" "yes" || exit 208 # Force override script defaults base_settings echo_default defaults_target="$(get_app_defaults_path)" diff --git a/ui/validate.func b/ui/validate.func index d3c8b44..18b9eb2 100644 --- a/ui/validate.func +++ b/ui/validate.func @@ -243,6 +243,13 @@ validate_container_id() { # - Calls validate_container_id() to check availability # ------------------------------------------------------------------------------ get_valid_container_id() { + if [[ -n "${var_ctid_min:-}${var_ctid_max:-}" ]]; then + _identity_select_id "${1:-}" + return $? + fi + if [[ -n "${_IDENTITY_BOUND_ID:-}" ]]; then + _identity_assert_id "${1:-$_IDENTITY_BOUND_ID}" || return 1 + fi local suggested_id="${1:-$(pvesh get /cluster/nextid 2>/dev/null || echo 100)}" # A failed pvesh used to leave this empty, and $((empty + 1)) is 1 -- so the @@ -263,6 +270,7 @@ get_valid_container_id() { fi done + _identity_assert_id "$suggested_id" || return 1 echo "$suggested_id" }