Skip to content

Instantly share code, notes, and snippets.

@jaminmc
Last active August 30, 2026 05:57
Show Gist options
  • Select an option

  • Save jaminmc/7e786a8947746439f7b8a8e2726e629d to your computer and use it in GitHub Desktop.

Select an option

Save jaminmc/7e786a8947746439f7b8a8e2726e629d to your computer and use it in GitHub Desktop.
Install OpenWrt in a Container on Proxmox 8+!
#!/bin/bash
# Create an OpenWrt LXC on Proxmox (x86_64).
# Stable / old-stable / RC / snapshots
# Saves last-run settings to /root/.openwrt-proxmox-lxc.conf (never the password)
# Default resource values (overridden by last-run config if present)
DEFAULT_MEMORY="256" # MB
DEFAULT_CORES="2" # CPU cores
DEFAULT_STORAGE="0.5" # GB
DEFAULT_LAN_CIDR="10.23.45.1/24" # LAN IPv4/prefix
ARCH="x86_64" # Architecture
TEMPLATE_DIR="/var/lib/vz/template/cache" # Default template location
CONFIG_FILE="/root/.openwrt-proxmox-lxc.conf"
OW_VERSIONS_JSON="https://downloads.openwrt.org/.versions.json"
OW_FALLBACK_STABLE="25.12.5"
OW_FALLBACK_OLDSTABLE="24.10.8"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
# Exit handler
exit_script() {
local code=$1
local msg=$2
[ -n "$msg" ] && echo -e "${RED}$msg${NC}"
exit "$code"
}
# Must run as root
[ "$EUID" -ne 0 ] && exit_script 1 "This script must be run as root"
# Check required commands
for cmd in wget pct pvesm ip curl whiptail pvesh bridge stat tar numfmt; do
command -v "$cmd" &>/dev/null || exit_script 1 "Required command not found: $cmd"
done
# ────────────────────────────────────────────────
# Helper functions
# ────────────────────────────────────────────────
whiptail_radiolist() {
local title="$1" prompt="$2" height="$3" width="$4" items=("${@:5}")
local rows=$(( ${#items[@]} / 3 ))
local list_h="$rows"
# Keep the item list inside the box; leave room for title + prompt + buttons
[ "$list_h" -gt 12 ] && list_h=12
[ "$height" -lt $((list_h + 8)) ] && height=$((list_h + 8))
[ "$width" -lt 72 ] && width=72
local selection
selection=$(whiptail --title "$title" --radiolist "$prompt" "$height" "$width" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) \
|| exit_script 1 "Aborted by user"
echo "$selection"
}
whiptail_input() {
local title="$1" prompt="$2" default="$3" var="$4"
local height="${5:-11}"
local width="${6:-74}"
local input
input=$(whiptail --title "$title" --inputbox "${prompt}" "$height" "$width" "$default" 3>&1 1>&2 2>&3) \
|| exit_script 1 "Aborted by user"
eval "$var=\"${input:-$default}\""
}
json_string_field() {
printf '%s' "$1" | tr -d '\n' | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
}
fetch_versions_json() {
curl -fsSL --max-time 20 -A "openwrt-proxmox-lxc" "$OW_VERSIONS_JSON" 2>/dev/null
}
scrape_latest_release_dir() {
curl -fsSL --max-time 20 -A "openwrt-proxmox-lxc" "https://downloads.openwrt.org/releases/" 2>/dev/null |
grep -oE 'href="[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?/"' |
grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?'
}
parse_versions_list() {
printf '%s' "$1" | tr -d '\n' |
sed -n 's/.*"versions_list"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' |
tr -d '"' | tr ',' '\n' | tr -d ' ' | grep -E '^[0-9]+\.[0-9]+'
}
detect_versions() {
local json scraped newest
json=$(fetch_versions_json)
STABLE_VER=$(json_string_field "$json" "stable_version")
OLDSTABLE_VER=$(json_string_field "$json" "oldstable_version")
UPCOMING_VER=$(json_string_field "$json" "upcoming_version")
mapfile -t VERSIONS_LIST < <(parse_versions_list "$json")
if [ -z "$STABLE_VER" ]; then
scraped=$(scrape_latest_release_dir)
mapfile -t VERSIONS_LIST < <(printf '%s\n' "$scraped" | sort -uV | tac)
STABLE_VER=$(printf '%s\n' "${VERSIONS_LIST[@]}" | grep -vE -- '-(rc|beta|alpha|test)' | sort -V | tail -1)
NEWEST_VER=$(printf '%s\n' "${VERSIONS_LIST[@]}" | sort -V | tail -1)
else
newest=$(printf '%s\n' "${VERSIONS_LIST[@]}" | sort -V | tail -1)
NEWEST_VER="${UPCOMING_VER:-$newest}"
[ -z "$NEWEST_VER" ] && NEWEST_VER="$STABLE_VER"
fi
[ -z "$STABLE_VER" ] && STABLE_VER="$OW_FALLBACK_STABLE"
[ -z "$OLDSTABLE_VER" ] && OLDSTABLE_VER="$OW_FALLBACK_OLDSTABLE"
[ -z "$NEWEST_VER" ] && NEWEST_VER="$STABLE_VER"
[ ${#VERSIONS_LIST[@]} -eq 0 ] && VERSIONS_LIST=("$STABLE_VER" "$OLDSTABLE_VER")
}
version_gt() {
[ -n "$1" ] && [ -n "$2" ] && [ "$1" != "$2" ] &&
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ]
}
pkg_tool_for_version() {
local ver="$1"
if [ "$ver" = "snapshot" ]; then
echo apk
return
fi
local major="${ver%%.*}"
if [ "$major" -ge 25 ] 2>/dev/null; then
echo apk
else
echo opkg
fi
}
series_of() {
local v="$1"
v="${v%-rc*}"
echo "${v%.*}"
}
version_label() {
local v="$1"
if [ "$v" = "$STABLE_VER" ]; then
echo "Current stable"
elif [ "$v" = "$OLDSTABLE_VER" ]; then
echo "Old stable"
elif [ -n "$UPCOMING_VER" ] && [ "$v" = "$UPCOMING_VER" ]; then
echo "Upcoming RC"
elif [[ "$v" == *"-rc"* ]]; then
echo "Release candidate"
else
echo "Release"
fi
}
select_version() {
local prefer="$1"
local stable_series old_series v state
local -a menu=()
local count=0 max=16
stable_series=$(series_of "$STABLE_VER")
old_series=$(series_of "$OLDSTABLE_VER")
state="OFF"
[ "$prefer" = "snapshot" ] && state="ON"
menu+=("snapshot" "Development snapshot (apk)" "$state")
for v in "${VERSIONS_LIST[@]}"; do
[ -z "$v" ] && continue
case "$v" in
"$stable_series"*|"$old_series"*) ;;
*) continue ;;
esac
state="OFF"
[ "$prefer" = "$v" ] && state="ON"
menu+=("$v" "$(printf '%-24s' "$(version_label "$v")") ($(pkg_tool_for_version "$v"))" "$state")
count=$((count + 1))
[ "$count" -ge "$max" ] && break
done
state="OFF"
[ "$prefer" = "custom" ] && state="ON"
menu+=("custom" "Type a version that is not listed" "$state")
# If prefer wasn't snapshot/custom and wasn't in the trimmed list, keep custom handy
# but still default first ON item: if nothing ON, mark stable ON
local has_on=0 i
for ((i = 2; i < ${#menu[@]}; i += 3)); do
[ "${menu[$i]}" = "ON" ] && has_on=1 && break
done
if [ "$has_on" -eq 0 ]; then
for ((i = 0; i < ${#menu[@]}; i += 3)); do
if [ "${menu[$i]}" = "$STABLE_VER" ]; then
menu[$((i + 2))]="ON"
has_on=1
break
fi
done
fi
[ "$has_on" -eq 0 ] && menu[2]="ON"
whiptail_radiolist "OpenWrt Version" \
"Pick a listed build, snapshot, or Custom. 25.12+ uses apk; 24.10 and older use opkg." \
20 78 "${menu[@]}"
}
select_storage() {
local prefer="$1"
local -a menu
local tag type free state
while read -r line || [ -n "$line" ]; do
tag=$(echo "$line" | awk '{print $1}')
type=$(echo "$line" | awk '{printf "%-10s", $2}')
free=$(echo "$line" | numfmt --field 4-6 --from-unit=K --to=iec --format %.2f | awk '{printf "%9sB", $6}')
state="OFF"
[ -n "$prefer" ] && [ "$tag" = "$prefer" ] && state="ON"
menu+=("$tag" "$(printf 'type %-10s free %s' "$type" "$free")" "$state")
done < <(pvesm status -content rootdir | awk 'NR>1')
[ ${#menu[@]} -eq 0 ] && exit_script 1 "No storage pools found"
[ $((${#menu[@]} / 3)) -eq 1 ] && echo "${menu[0]}" && return
local has_on=0 i
for ((i = 2; i < ${#menu[@]}; i += 3)); do
[ "${menu[$i]}" = "ON" ] && has_on=1 && break
done
[ "$has_on" -eq 0 ] && menu[2]="ON"
whiptail_radiolist "Storage Pools" "Select storage for the container rootfs:" 18 78 "${menu[@]}"
}
detect_network_options() {
BRIDGE_LIST=($(ip link | grep -o 'vmbr[0-9]\+' | sort -u))
BRIDGE_COUNT=${#BRIDGE_LIST[@]}
local all_devs=$(ip link show | grep -oE '^[0-9]+: ([^:]+):' | awk '{print $2}' | cut -d':' -f1 | grep -vE '^(lo|vmbr|veth|tap|fwbr|fwpr|fwln)')
readarray -t ALL_DEVICES <<<"$all_devs"
local bridged_devs=$(bridge link show | cut -d ":" -f2 | cut -d " " -f2)
readarray -t BRIDGED_DEVICES <<<"$bridged_devs"
UNBRIDGED_DEVICES=()
for dev in "${ALL_DEVICES[@]}"; do
local bridged=false
for bdev in "${BRIDGED_DEVICES[@]}"; do [ "$dev" = "$bdev" ] && bridged=true && break; done
[ "$bridged" = false ] && UNBRIDGED_DEVICES+=("$dev")
done
UNBRIDGED_COUNT=${#UNBRIDGED_DEVICES[@]}
}
select_network_option() {
local type="$1" eth="$2" prefer="$3"
local -a menu=("None" "No network assigned" "OFF")
local b d state
for b in "${BRIDGE_LIST[@]}"; do
state="OFF"
[ "$prefer" = "bridge:$b" ] && state="ON"
menu+=("bridge:$b" "Linux bridge $b" "$state")
done
for d in "${UNBRIDGED_DEVICES[@]}"; do
state="OFF"
[ "$prefer" = "device:$d" ] && state="ON"
menu+=("device:$d" "Host NIC $d" "$state")
done
local has_on=0 i
for ((i = 2; i < ${#menu[@]}; i += 3)); do
[ "${menu[$i]}" = "ON" ] && has_on=1 && break
done
[ "$has_on" -eq 0 ] && menu[2]="ON"
whiptail_radiolist "$type interface ($eth)" \
"Where should $type / $eth attach? None leaves that interface unconnected." \
18 72 "${menu[@]}"
}
detect_next_ctid() {
local id=$(pvesh get /cluster/nextid 2>/dev/null)
echo "${id:-100}"
}
guest_exec() {
pct exec "$CTID" -- /bin/sh -c "$1"
}
wait_for_guest() {
local i
echo -e "${GREEN}Waiting for container userspace...${NC}"
for i in $(seq 1 40); do
if pct exec "$CTID" -- /bin/sh -c 'command -v uci >/dev/null && command -v ubus >/dev/null' >/dev/null 2>&1; then
# give procd a moment to publish objects
pct exec "$CTID" -- /bin/sh -c 'ubus wait_for system 2>/dev/null || true' >/dev/null 2>&1
return 0
fi
sleep 1
done
echo -e "${RED}Container started, but uci is not ready yet β€” continuing anyway${NC}"
return 1
}
destroy_container() {
echo -e "${RED}Removing container $CTID...${NC}"
pct stop "$CTID" --timeout 15 >/dev/null 2>&1 || true
if ! pct destroy "$CTID" --purge >/dev/null 2>&1; then
pct destroy "$CTID" >/dev/null 2>&1 || true
fi
echo -e "${RED}Container $CTID removed.${NC}"
}
abort_after_failure() {
local reason="$1"
echo -e "${RED}$reason${NC}"
if whiptail --title "Install failed" --yesno \
"$reason\n\nDestroy container $CTID ($CTNAME) and abort?\n\nYes = stop + delete the CT\nNo = keep it and continue" \
14 74; then
destroy_container
exit_script 1 "Aborted after failure; container $CTID removed"
fi
echo -e "${GREEN}Keeping container $CTID despite the failure${NC}"
}
wait_for_wan() {
local i
echo -e "${GREEN}Waiting for IPv4 default route...${NC}"
for i in $(seq 1 25); do
if pct exec "$CTID" -- /bin/sh -c \
"ip route show 2>/dev/null | grep -q '^default ' || ip -4 route show 2>/dev/null | grep -q default" \
>/dev/null 2>&1; then
echo -e "${GREEN}IPv4 WAN is up${NC}"
return 0
fi
# Kick DHCP once if nothing appeared
[ "$i" -eq 8 ] && guest_exec "ip link set eth0 up 2>/dev/null; udhcpc -i eth0 -n -q -t 3 -T 2 >/dev/null 2>&1 || true" >/dev/null 2>&1 || true
sleep 1
done
echo -e "${RED}No IPv4 default route in the guest${NC}"
return 1
}
# Latest <pkg>-<version>.apk name from an OpenWrt feed directory listing
feed_apk_name() {
local repo="$1" pkg="$2"
curl -fsSL --max-time 20 "$repo" 2>/dev/null |
grep -oE "href=\"${pkg}-[0-9][^\"]*\\.apk\"" |
sed 's/^href="//;s/"$//' |
sort -V | tail -1
}
install_luci_from_host() {
local work repo_luci repo_base name dest f
[ "$PKG_TOOL" = "apk" ] || return 1
work=$(mktemp -d /tmp/owrt-luci.XXXXXX) || return 1
dest="/tmp/luci-apks"
repo_luci="https://downloads.openwrt.org/snapshots/packages/x86_64/luci/"
repo_base="https://downloads.openwrt.org/snapshots/packages/x86_64/base/"
echo -e "${GREEN}Downloading LuCI packages on the Proxmox host (guest wget is broken in this LXC)...${NC}"
local luci_pkgs=(
luci luci-light luci-base luci-theme-bootstrap
luci-mod-admin-full luci-mod-network luci-mod-status luci-mod-system
luci-app-package-manager luci-app-firewall luci-lib-uqr
luci-proto-ipv6 luci-proto-ppp
liblucihttp0 liblucihttp-ucode rpcd-mod-luci
)
local base_pkgs=(
cgi-io uhttpd uhttpd-mod-ubus
rpcd-mod-ucode rpcd-mod-iwinfo rpcd-mod-rrdns
ucode-mod-html ucode-mod-log
libiwinfo-data
)
for name in "${luci_pkgs[@]}"; do
f=$(feed_apk_name "$repo_luci" "$name")
[ -n "$f" ] || { echo -e "${RED}No apk listed for $name in luci feed${NC}"; rm -rf "$work"; return 1; }
echo " $f"
curl -fsSL --max-time 60 -o "$work/$f" "${repo_luci}${f}" || { rm -rf "$work"; return 1; }
done
for name in "${base_pkgs[@]}"; do
f=$(feed_apk_name "$repo_base" "$name")
[ -n "$f" ] || { echo -e "${RED}No apk listed for $name in base feed${NC}"; rm -rf "$work"; return 1; }
echo " $f"
curl -fsSL --max-time 60 -o "$work/$f" "${repo_base}${f}" || { rm -rf "$work"; return 1; }
done
# SONAME-stamped libiwinfo, e.g. libiwinfo20230701
f=$(curl -fsSL --max-time 20 "$repo_base" 2>/dev/null | grep -oE 'href="libiwinfo[0-9][^"]*\.apk"' | sed 's/^href="//;s/"$//' | sort -V | tail -1)
if [ -n "$f" ]; then
echo " $f"
curl -fsSL --max-time 60 -o "$work/$f" "${repo_base}${f}" || { rm -rf "$work"; return 1; }
fi
guest_exec "mkdir -p $dest" >/dev/null 2>&1 || true
for f in "$work"/*.apk; do
pct push "$CTID" "$f" "$dest/$(basename "$f")" || { rm -rf "$work"; return 1; }
done
rm -rf "$work"
echo -e "${GREEN}Installing pushed LuCI packages inside the container...${NC}"
pct exec "$CTID" -- /bin/sh -c "apk add --allow-untrusted $dest/*.apk" || return 1
guest_exec "
[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd restart >/dev/null 2>&1 || true
[ -x /etc/init.d/uhttpd ] && /etc/init.d/uhttpd restart >/dev/null 2>&1 || true
" >/dev/null 2>&1 || true
return 0
}
install_luci() {
if wait_for_wan; then
echo -e "${GREEN}Trying in-guest $PKG_TOOL add luci...${NC}"
if [ "$PKG_TOOL" = "apk" ]; then
pct exec "$CTID" -- /bin/sh -c "apk update && apk add luci" && return 0
else
pct exec "$CTID" -- /bin/sh -c "opkg update && opkg install luci" && return 0
fi
echo -e "${RED}In-guest package fetch failed β€” falling back to host download${NC}"
else
echo -e "${RED}Guest has no WAN route β€” installing LuCI via host download${NC}"
fi
install_luci_from_host
}
valid_mac() {
[[ "$1" =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]]
}
load_saved_config() {
SAVED=0
[ -f "$CONFIG_FILE" ] || return
# shellcheck disable=SC1090
. "$CONFIG_FILE" || return
SAVED=1
}
save_config() {
umask 077
cat > "$CONFIG_FILE" <<EOF
# Last successful OpenWrt LXC settings (password is never stored)
# Generated by install_openwrt_proxmox.sh
SAVED_VER=$(printf '%q' "$VER")
SAVED_RELEASE_TYPE=$(printf '%q' "$RELEASE_TYPE")
SAVED_CTID=$(printf '%q' "$CTID")
SAVED_CTNAME=$(printf '%q' "$CTNAME")
SAVED_MEMORY=$(printf '%q' "$MEMORY")
SAVED_CORES=$(printf '%q' "$CORES")
SAVED_STORAGE_SIZE=$(printf '%q' "$STORAGE_SIZE")
SAVED_STORAGE=$(printf '%q' "$STORAGE")
SAVED_LAN_CIDR=$(printf '%q' "$LAN_CIDR")
SAVED_WAN_OPTION=$(printf '%q' "$WAN_OPTION")
SAVED_LAN_OPTION=$(printf '%q' "$LAN_OPTION")
SAVED_WAN_MAC=$(printf '%q' "$WAN_MAC")
SAVED_LAN_MAC=$(printf '%q' "$LAN_MAC")
SAVED_DISABLE_SYNTPD=$(printf '%q' "$DISABLE_SYNTPD")
SAVED_DISABLE_FIREWALL=$(printf '%q' "$DISABLE_FIREWALL")
SAVED_INSTALL_LUCI=$(printf '%q' "${INSTALL_LUCI:-0}")
EOF
echo -e "${GREEN}Saved settings to $CONFIG_FILE${NC}"
}
# ────────────────────────────────────────────────
# Main logic
# ────────────────────────────────────────────────
load_saved_config
USE_SAVED=0
if [ "$SAVED" -eq 1 ]; then
echo -e "${GREEN}Found last-run settings in $CONFIG_FILE${NC}"
if whiptail --title "Reuse last settings?" --yesno \
"Use saved values as defaults? You can still change any prompt.\n\n Version: ${SAVED_VER:-unknown}\n Name: ${SAVED_CTNAME:-unknown}\n LAN IPv4/CIDR: ${SAVED_LAN_CIDR:-unknown}\n WAN: ${SAVED_WAN_OPTION:-None}\n LAN: ${SAVED_LAN_OPTION:-None}" \
16 74; then
USE_SAVED=1
fi
fi
echo -e "${GREEN}Fetching OpenWrt version info...${NC}"
detect_versions
echo -e "${GREEN}Latest stable: $STABLE_VER${NC}"
echo -e "${GREEN}Old stable: $OLDSTABLE_VER${NC}"
[ -n "$UPCOMING_VER" ] && echo -e "${GREEN}Upcoming: $UPCOMING_VER${NC}"
PREF_VER="$STABLE_VER"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_VER" ] && PREF_VER="$SAVED_VER"
SELECTED_VER=$(select_version "$PREF_VER")
if [ "$SELECTED_VER" = "custom" ]; then
CUSTOM_DEFAULT="$STABLE_VER"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_VER" ] && [ "$SAVED_VER" != "snapshot" ] && CUSTOM_DEFAULT="$SAVED_VER"
whiptail_input "OpenWrt Version" "Enter version (e.g. $STABLE_VER, $OLDSTABLE_VER, 25.12.0-rc5)" "$CUSTOM_DEFAULT" VER
elif [ "$SELECTED_VER" = "snapshot" ]; then
VER="snapshot"
else
VER="$SELECTED_VER"
fi
if [ "$VER" = "snapshot" ]; then
RELEASE_TYPE="Snapshot"
DOWNLOAD_URL="https://downloads.openwrt.org/snapshots/targets/x86/64/openwrt-x86-64-rootfs.tar.gz"
TEMPLATE_FILE="openwrt-snapshot-${ARCH}.tar.gz"
LUCI_DEFAULT="--yesno"
[ "$USE_SAVED" -eq 1 ] && [ "${SAVED_INSTALL_LUCI:-0}" = "0" ] && LUCI_DEFAULT="--defaultno --yesno"
if whiptail --title "LuCI" $LUCI_DEFAULT "Install LuCI automatically (snapshot)?" 10 60; then
INSTALL_LUCI=1
else
INSTALL_LUCI=0
fi || exit_script 1 "Aborted by user"
else
if [ "$(series_of "$VER")" = "$(series_of "$OLDSTABLE_VER")" ] && [ "$(series_of "$VER")" != "$(series_of "$STABLE_VER")" ]; then
RELEASE_TYPE="OldStable"
else
RELEASE_TYPE="Stable"
fi
DOWNLOAD_URL="https://downloads.openwrt.org/releases/$VER/targets/x86/64/openwrt-${VER}-x86-64-rootfs.tar.gz"
TEMPLATE_FILE="openwrt-${VER}-${ARCH}.tar.gz"
INSTALL_LUCI=0
fi
PKG_TOOL=$(pkg_tool_for_version "$VER")
echo -e "${GREEN}Using $VER ($RELEASE_TYPE, $PKG_TOOL)${NC}"
NEXT_CTID=$(detect_next_ctid)
CTID_DEFAULT="$NEXT_CTID"
if [ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_CTID" ]; then
if pct list | awk '{print $1}' | grep -q "^${SAVED_CTID}$"; then
echo -e "${GREEN}Saved CTID $SAVED_CTID is in use β†’ offering $NEXT_CTID${NC}"
else
CTID_DEFAULT="$SAVED_CTID"
fi
fi
whiptail_input "Container ID" "Enter ID" "$CTID_DEFAULT" CTID || exit_script 1 "Aborted"
if [ "$VER" = "snapshot" ] || [ "$RELEASE_TYPE" = "Snapshot" ]; then
NAME_DEFAULT="openwrt-snap-$CTID"
else
NAME_DEFAULT="openwrt-$CTID"
fi
# Keep a previously chosen custom hostname, but never reuse openwrt-<old-id>
if [ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_CTNAME" ]; then
case "$SAVED_CTNAME" in
openwrt-[0-9]*|openwrt-snap-[0-9]*) ;;
*) NAME_DEFAULT="$SAVED_CTNAME" ;;
esac
fi
whiptail_input "Container Name" "Enter name" "$NAME_DEFAULT" CTNAME || exit_script 1 "Aborted"
# Password (never saved)
while true; do
PASSWORD=$(whiptail --title "Root Password" --passwordbox "Enter password (blank = skip)" 10 50 3>&1 1>&2 2>&3)
ret=$?
[ $ret -ne 0 ] && { PASSWORD=""; break; }
PASSWORD_CONFIRM=$(whiptail --title "Confirm" --passwordbox "Confirm password" 10 50 3>&1 1>&2 2>&3) \
|| exit_script 1 "Aborted by user"
if [ -z "$PASSWORD" ] && [ -z "$PASSWORD_CONFIRM" ]; then
echo -e "${GREEN}Password skipped.${NC}"
break
elif [ "$PASSWORD" = "$PASSWORD_CONFIRM" ]; then
break
else
whiptail --title "Error" --msgbox "Passwords do not match." 8 50
fi
done
SYNTPD_ON="ON"; SYNTPD_OFF="OFF"
[ "$USE_SAVED" -eq 1 ] && [ "$SAVED_DISABLE_SYNTPD" = "No" ] && { SYNTPD_ON="OFF"; SYNTPD_OFF="ON"; }
DISABLE_SYNTPD=$(whiptail --title "sysntpd" --radiolist \
"Disable sysntpd? Recommended for containers (host already keeps time)." 13 74 2 \
"Yes" "Disable sysntpd (default)" "$SYNTPD_ON" \
"No" "Leave sysntpd running" "$SYNTPD_OFF" 3>&1 1>&2 2>&3) \
|| exit_script 1 "Aborted by user"
FW_DISABLE="OFF"; FW_KEEP="ON"
[ "$USE_SAVED" -eq 1 ] && [ "$SAVED_DISABLE_FIREWALL" = "Yes" ] && { FW_DISABLE="ON"; FW_KEEP="OFF"; }
DISABLE_FIREWALL=$(whiptail --title "Firewall" --radiolist \
"OpenWrt firewall inside the container?" 13 74 2 \
"No" "Keep firewall enabled (default)" "$FW_KEEP" \
"Yes" "Stop and disable it (only if something else filters)" "$FW_DISABLE" 3>&1 1>&2 2>&3) \
|| exit_script 1 "Aborted by user"
MEM_DEFAULT="$DEFAULT_MEMORY"
CORES_DEFAULT="$DEFAULT_CORES"
SIZE_DEFAULT="$DEFAULT_STORAGE"
CIDR_DEFAULT="$DEFAULT_LAN_CIDR"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_MEMORY" ] && MEM_DEFAULT="$SAVED_MEMORY"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_CORES" ] && CORES_DEFAULT="$SAVED_CORES"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_STORAGE_SIZE" ] && SIZE_DEFAULT="$SAVED_STORAGE_SIZE"
[ "$USE_SAVED" -eq 1 ] && [ -n "$SAVED_LAN_CIDR" ] && CIDR_DEFAULT="$SAVED_LAN_CIDR"
whiptail_input "Memory (MB)" "Memory size" "$MEM_DEFAULT" MEMORY || exit_script 1 "Aborted"
whiptail_input "CPU Cores" "Number of cores" "$CORES_DEFAULT" CORES || exit_script 1 "Aborted"
whiptail_input "Storage (GB)" "Storage limit" "$SIZE_DEFAULT" STORAGE_SIZE || exit_script 1 "Aborted"
whiptail_input "LAN IPv4/CIDR" \
"Container LAN address and prefix β€” not just the network.\nExample: 10.23.45.1/24 means IP 10.23.45.1 with mask 255.255.255.0" \
"$CIDR_DEFAULT" LAN_CIDR 12 74 || exit_script 1 "Aborted"
# Basic validation
[[ "$CTID" =~ ^[0-9]+$ && "$CTID" -ge 100 ]] || exit_script 1 "ID must be >= 100"
pct list | awk '{print $1}' | grep -q "^$CTID$" && exit_script 1 "ID $CTID in use"
[[ "$MEMORY" =~ ^[0-9]+$ && "$MEMORY" -ge 64 ]] || exit_script 1 "Memory >= 64"
[[ "$CORES" =~ ^[0-9]+$ && "$CORES" -ge 1 ]] || exit_script 1 "Cores >= 1"
[[ "$STORAGE_SIZE" =~ ^[0-9]*\.?[0-9]+$ ]] || exit_script 1 "Storage > 0 required"
awk -v s="$STORAGE_SIZE" 'BEGIN { exit !(s+0 > 0) }' || exit_script 1 "Storage > 0 required"
[[ "$LAN_CIDR" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || exit_script 1 "LAN IPv4/CIDR must look like 10.23.45.1/24"
LAN_IP=$(echo "$LAN_CIDR" | cut -d'/' -f1)
LAN_PREFIX=$(echo "$LAN_CIDR" | cut -d'/' -f2)
case "$LAN_PREFIX" in
24) LAN_NETMASK="255.255.255.0" ;;
23) LAN_NETMASK="255.255.254.0" ;;
22) LAN_NETMASK="255.255.252.0" ;;
16) LAN_NETMASK="255.255.0.0" ;;
*) exit_script 1 "Unsupported prefix /$LAN_PREFIX (use /24, /23, /22, or /16)" ;;
esac
STORAGE_PREF=""
[ "$USE_SAVED" -eq 1 ] && STORAGE_PREF="$SAVED_STORAGE"
STORAGE=$(select_storage "$STORAGE_PREF")
detect_network_options
[ "$BRIDGE_COUNT" -eq 0 ] && [ "$UNBRIDGED_COUNT" -eq 0 ] && echo -e "${RED}Warning: No network devices found${NC}"
WAN_PREF=""; LAN_PREF=""
[ "$USE_SAVED" -eq 1 ] && WAN_PREF="$SAVED_WAN_OPTION"
[ "$USE_SAVED" -eq 1 ] && LAN_PREF="$SAVED_LAN_OPTION"
WAN_OPTION=$(select_network_option "WAN" "eth0" "$WAN_PREF") || exit_script 1 "Aborted"
LAN_OPTION=$(select_network_option "LAN" "eth1" "$LAN_PREF") || exit_script 1 "Aborted"
WAN_BRIDGE=""; WAN_DEVICE=""
[[ "$WAN_OPTION" == bridge:* ]] && WAN_BRIDGE="${WAN_OPTION#bridge:}"
[[ "$WAN_OPTION" == device:* ]] && WAN_DEVICE="${WAN_OPTION#device:}"
LAN_BRIDGE=""; LAN_DEVICE=""
[[ "$LAN_OPTION" == bridge:* ]] && LAN_BRIDGE="${LAN_OPTION#bridge:}"
[[ "$LAN_OPTION" == device:* ]] && LAN_DEVICE="${LAN_OPTION#device:}"
WAN_MAC_DEFAULT=""
LAN_MAC_DEFAULT=""
[ "$USE_SAVED" -eq 1 ] && WAN_MAC_DEFAULT="$SAVED_WAN_MAC"
[ "$USE_SAVED" -eq 1 ] && LAN_MAC_DEFAULT="$SAVED_LAN_MAC"
# Device passthrough default: copy host NIC MAC if none saved
if [ -z "$WAN_MAC_DEFAULT" ] && [ -n "$WAN_DEVICE" ]; then
WAN_MAC_DEFAULT=$(ip link show "$WAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)
fi
if [ -z "$LAN_MAC_DEFAULT" ] && [ -n "$LAN_DEVICE" ]; then
LAN_MAC_DEFAULT=$(ip link show "$LAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)
fi
whiptail_input "WAN MAC (eth0)" \
"Hardware address for WAN/eth0.\nLeave blank to let Proxmox assign one." \
"$WAN_MAC_DEFAULT" WAN_MAC || exit_script 1 "Aborted"
whiptail_input "LAN MAC (eth1)" \
"Hardware address for LAN/eth1.\nLeave blank to let Proxmox assign one." \
"$LAN_MAC_DEFAULT" LAN_MAC || exit_script 1 "Aborted"
WAN_MAC="${WAN_MAC,,}"
LAN_MAC="${LAN_MAC,,}"
[ -n "$WAN_MAC" ] && ! valid_mac "$WAN_MAC" && exit_script 1 "Invalid WAN MAC: $WAN_MAC"
[ -n "$LAN_MAC" ] && ! valid_mac "$LAN_MAC" && exit_script 1 "Invalid LAN MAC: $LAN_MAC"
# Summary
SUMMARY="Summary:\n"
SUMMARY+=" Version: $VER ($PKG_TOOL)\n"
SUMMARY+=" ID/Name: $CTID / $CTNAME\n"
SUMMARY+=" Password: $( [ -n "$PASSWORD" ] && echo Set || echo Skipped )\n"
SUMMARY+=" sysntpd: $( [ "$DISABLE_SYNTPD" = "Yes" ] && echo DISABLED || echo Enabled )\n"
SUMMARY+=" firewall: $( [ "$DISABLE_FIREWALL" = "Yes" ] && echo DISABLED || echo Enabled )\n"
SUMMARY+=" Memory/Cores/Storage: $MEMORY MB / $CORES / $STORAGE_SIZE GB on $STORAGE\n"
SUMMARY+=" LAN IPv4/CIDR: $LAN_CIDR\n"
SUMMARY+=" WAN: ${WAN_BRIDGE:-${WAN_DEVICE:-None}} (eth0) MAC ${WAN_MAC:-auto}\n"
SUMMARY+=" LAN: ${LAN_BRIDGE:-${LAN_DEVICE:-None}} (eth1) MAC ${LAN_MAC:-auto}\n"
[ "$RELEASE_TYPE" = "Snapshot" ] && [ "${INSTALL_LUCI:-0}" -eq 1 ] && SUMMARY+=" LuCI: auto-install\n"
whiptail --title "Confirm" --yesno "$SUMMARY\n\nCreate container?" 22 78 \
|| exit_script 0 "Aborted by user"
# ────────────────────────────────────────────────
# Template handling
# ────────────────────────────────────────────────
TEMPLATE_PATH="$TEMPLATE_DIR/$TEMPLATE_FILE"
download_needed=1
if [ -f "$TEMPLATE_PATH" ]; then
FILE_SIZE=$(stat -c %s "$TEMPLATE_PATH" 2>/dev/null || echo 0)
if [ "$FILE_SIZE" -eq 0 ]; then
echo -e "${RED}Existing file empty β†’ redownload${NC}"
rm -f "$TEMPLATE_PATH"
elif ! tar tzf "$TEMPLATE_PATH" >/dev/null 2>&1; then
echo -e "${RED}Corrupt template detected${NC}"
if whiptail --title "Corrupt File" --yesno "Redownload?" 10 60; then
rm -f "$TEMPLATE_PATH"
else
exit_script 1 "Aborted - corrupt file kept"
fi
else
SIZE_H=$(numfmt --to=iec --format %.2f "$FILE_SIZE")
if whiptail --title "Reuse Template?" --yesno \
"Found:\n $TEMPLATE_FILE\n Size: $SIZE_H\n\nReuse? (No = redownload)" 12 70; then
echo -e "${GREEN}Reusing existing template${NC}"
download_needed=0
else
rm -f "$TEMPLATE_PATH"
fi
fi
fi
if [ "$RELEASE_TYPE" = "Snapshot" ] && [ "$download_needed" -eq 0 ]; then
FILE_AGE=$(($(date +%s) - $(stat -c %Y "$TEMPLATE_PATH" 2>/dev/null || echo 0)))
if [ "$FILE_AGE" -gt 86400 ]; then
echo -e "${GREEN}Snapshot >1 day old β†’ refreshing${NC}"
rm -f "$TEMPLATE_PATH"
download_needed=1
fi
fi
if [ "$download_needed" -eq 1 ]; then
echo -e "${GREEN}Downloading $VER rootfs...${NC}"
echo " $DOWNLOAD_URL"
wget --show-progress "$DOWNLOAD_URL" -O "$TEMPLATE_PATH.part" || {
rm -f "$TEMPLATE_PATH.part"
exit_script 1 "Download failed: $DOWNLOAD_URL"
}
mv "$TEMPLATE_PATH.part" "$TEMPLATE_PATH"
if ! tar tzf "$TEMPLATE_PATH" >/dev/null 2>&1; then
rm -f "$TEMPLATE_PATH"
exit_script 1 "Downloaded file corrupt"
fi
echo -e "${GREEN}Download verified${NC}"
fi
# ────────────────────────────────────────────────
# Container creation
# ────────────────────────────────────────────────
echo -e "${GREEN}Creating container $CTID...${NC}"
NET_OPTS=()
if [ -n "$WAN_BRIDGE" ]; then
net="name=eth0,bridge=$WAN_BRIDGE"
[ -n "$WAN_MAC" ] && net+=",hwaddr=$WAN_MAC"
NET_OPTS+=("--net0" "$net")
elif [ -n "$WAN_DEVICE" ]; then
net="name=eth0"
if [ -n "$WAN_MAC" ]; then
net+=",hwaddr=$WAN_MAC"
else
net+=",hwaddr=$(ip link show "$WAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)"
fi
NET_OPTS+=("--net0" "$net")
fi
if [ -n "$LAN_BRIDGE" ]; then
net="name=eth1,bridge=$LAN_BRIDGE"
[ -n "$LAN_MAC" ] && net+=",hwaddr=$LAN_MAC"
NET_OPTS+=("--net1" "$net")
elif [ -n "$LAN_DEVICE" ]; then
net="name=eth1"
if [ -n "$LAN_MAC" ]; then
net+=",hwaddr=$LAN_MAC"
else
net+=",hwaddr=$(ip link show "$LAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)"
fi
NET_OPTS+=("--net1" "$net")
fi
pct create "$CTID" "$TEMPLATE_PATH" \
--arch amd64 \
--hostname "$CTNAME" \
--rootfs "$STORAGE:$STORAGE_SIZE" \
--memory "$MEMORY" \
--cores "$CORES" \
--unprivileged 1 \
--features nesting=1 \
--ostype unmanaged \
"${NET_OPTS[@]}" || exit_script 1 "pct create failed"
if ! pct start "$CTID"; then
abort_after_failure "pct start failed for container $CTID."
fi
wait_for_guest
guest_exec "sed -i 's!procd_add_jail!: procd_add_jail!g' /etc/init.d/dnsmasq 2>/dev/null || true" >/dev/null 2>&1 || true
[ "$DISABLE_SYNTPD" = "Yes" ] && {
echo -e "${GREEN}Disabling sysntpd...${NC}"
guest_exec "rm -f /etc/rc.d/*sysntpd" >/dev/null 2>&1 || true
}
[ "$DISABLE_FIREWALL" = "Yes" ] && {
echo -e "${GREEN}Disabling firewall...${NC}"
guest_exec "
/etc/init.d/firewall stop >/dev/null 2>&1 || true
/etc/init.d/firewall disable >/dev/null 2>&1 || true
rm -f /etc/rc.d/*firewall
if /sbin/uci -q get firewall.@defaults[0] >/dev/null; then
/sbin/uci -q set firewall.@defaults[0].input='ACCEPT'
/sbin/uci -q set firewall.@defaults[0].forward='ACCEPT'
/sbin/uci -q set firewall.@defaults[0].output='ACCEPT'
/sbin/uci -q commit firewall
fi
" >/dev/null 2>&1 || echo -e "${RED}Firewall disable warning${NC}"
}
echo -e "${GREEN}Configuring OpenWrt network (eth0=WAN dhcp/dhcpv6, eth1=LAN static)...${NC}"
NETCFG=$(mktemp)
{
printf '%s\n' \
"config interface 'loopback'" \
" option device 'lo'" \
" option proto 'static'" \
" option ipaddr '127.0.0.1'" \
" option netmask '255.0.0.0'" \
"" \
"config globals 'globals'" \
" option packet_steering '1'" \
""
if [ -n "$LAN_BRIDGE" ] || [ -n "$LAN_DEVICE" ]; then
printf '%s\n' \
"config device" \
" option name 'br-lan'" \
" option type 'bridge'" \
" list ports 'eth1'" \
"" \
"config interface 'lan'" \
" option device 'br-lan'" \
" option proto 'static'" \
" option ipaddr '$LAN_IP'" \
" option netmask '$LAN_NETMASK'" \
""
fi
if [ -n "$WAN_BRIDGE" ] || [ -n "$WAN_DEVICE" ]; then
printf '%s\n' \
"config interface 'wan'" \
" option device 'eth0'" \
" option proto 'dhcp'" \
"" \
"config interface 'wan6'" \
" option device 'eth0'" \
" option proto 'dhcpv6'" \
" option reqaddress 'try'" \
" option reqprefix 'auto'" \
""
fi
} > "$NETCFG"
if pct push "$CTID" "$NETCFG" /etc/config/network; then
guest_exec "/etc/init.d/network restart >/dev/null 2>&1 || true" >/dev/null 2>&1 || true
echo -e "${GREEN}Wrote /etc/config/network${NC}"
else
echo -e "${RED}Failed to push /etc/config/network${NC}"
fi
rm -f "$NETCFG"
[ "$RELEASE_TYPE" = "Snapshot" ] && [ "${INSTALL_LUCI:-0}" -eq 1 ] && {
if ! install_luci; then
abort_after_failure "LuCI install failed ($PKG_TOOL could not fetch packages)."
fi
}
[ -n "$PASSWORD" ] && {
echo -e "${GREEN}Setting password...${NC}"
if ! echo -e "$PASSWORD\n$PASSWORD" | pct exec "$CTID" -- passwd; then
abort_after_failure "Setting the root password failed."
fi
}
save_config
echo -e "${GREEN}Done! Container $CTID ($CTNAME) ready.${NC}"
echo "Next:"
echo " pct exec $CTID /bin/sh"
echo " uci show network"
[ "$DISABLE_SYNTPD" = "Yes" ] && echo " sysntpd disabled"
[ "$DISABLE_FIREWALL" = "Yes" ] && echo " firewall disabled"
echo " Settings saved: $CONFIG_FILE"
echo " LuCI: http://$LAN_IP (if LAN is up)"
[ -z "$PASSWORD" ] && echo " Set password: pct exec $CTID passwd"
if [ "$RELEASE_TYPE" = "Snapshot" ] && [ "${INSTALL_LUCI:-0}" -eq 0 ]; then
if [ "$PKG_TOOL" = "apk" ]; then
echo " apk update && apk add luci"
else
echo " opkg update && opkg install luci"
fi
fi
exit 0
@jaminmc

jaminmc commented Jun 9, 2025

Copy link
Copy Markdown
Author

I have no way of testing an ARM setup... But if you know what the Arch you are needing, you can modify the script to download that...
I did run the script through Grok, and asked it to adapt the script to also work with ARM... So it may or may not work.

#!/bin/bash

# Script to create an OpenWrt LXC container in Proxmox
# Downloads from openwrt.org with latest stable or snapshot version, detects bridges/devices, IDs, configures network, sets optional password
# Pre-configures WAN/LAN in UCI, includes summary and confirmation, optional LuCI install for snapshots with apk
# Modified to support ARM (aarch64 and armv7) alongside x86_64, using correct armsr/armv8 and armsr/armv7 targets

# Default resource values
DEFAULT_MEMORY="256"                      # MB
DEFAULT_CORES="2"                         # CPU cores
DEFAULT_STORAGE="0.5"                     # GB
DEFAULT_SUBNET="10.23.45.1/24"            # LAN subnet
ARCH="x86_64"                             # Default architecture
TEMPLATE_DIR="/var/lib/vz/template/cache" # Default template location

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

# Exit handler for cleanup and messages
exit_script() {
    local code=$1
    local msg=$2
    [ -n "$msg" ] && echo -e "${RED}$msg${NC}"
    exit "$code"
}

# Check if running as root
[ "$EUID" -ne 0 ] && exit_script 1 "Error: This script must be run as root"

# Check required tools
for cmd in wget pct pvesm ip curl whiptail pvesh bridge stat; do
    command -v "$cmd" &>/dev/null || exit_script 1 "Error: $cmd is not installed. Please install it first."
done

# Generic whiptail radiolist function
whiptail_radiolist() {
    local title="$1" prompt="$2" height="$3" width="$4" items=("${@:5}")
    local selection
    selection=$(whiptail --title "$title" --radiolist "$prompt" "$height" "$width" "$((${#items[@]} / 3))" "${items[@]}" 3>&1 1>&2 2>&3) || \
        exit_script 1 "Error: $title selection aborted"
    echo "$selection"
}

# Detect latest stable OpenWrt version (silent)
detect_latest_version() {
    local ver
    ver=$(curl -sSf "https://downloads.openwrt.org/releases/" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -V | tail -1)
    [ -z "$ver" ] && ver="24.10.0"  # Default to 24.10.0 if detection fails
    echo "$ver"
}

# Select storage
select_storage() {
    local content='rootdir' label='Container'
    local -a menu
    while read -r line || [ -n "$line" ]; do
        local tag=$(echo "$line" | awk '{print $1}')
        local type=$(echo "$line" | awk '{printf "%-10s", $2}')
        local free=$(echo "$line" | numfmt --field 4-6 --from-unit=K --to=iec --format %.2f | awk '{printf "%9sB", $6}')
        menu+=("$tag" "Type: $type Free: $free" "OFF")
    done < <(pvesm status -content "$content" | awk 'NR>1')

    [ ${#menu[@]} -eq 0 ] && exit_script 1 "Error: No storage pools found for $label"
    [ $((${#menu[@]} / 3)) -eq 1 ] && echo "${menu[0]}" && return
    whiptail_radiolist "Storage Pools" "Which storage pool for the ${label,,}?\nUse Spacebar to select." 16 $(( $(echo "${menu[*]}" | wc -L) + 23 )) "${menu[@]}"
}

# Detect network options (bridges and unbridged devices)
detect_network_options() {
    BRIDGE_LIST=($(ip link | grep -o 'vmbr[0-9]\+' | sort -u))
    BRIDGE_COUNT=${#BRIDGE_LIST[@]}

    local all_devs
    all_devs=$(ip link show | grep -oE '^[0-9]+: ([^:]+):' | awk '{print $2}' | cut -d':' -f1 | grep -vE '^(lo|vmbr|veth|tap|fwbrdg|fwpr|fwln)')
    readarray -t ALL_DEVICES <<<"$all_devs"

    local bridged_devs
    bridged_devs=$(bridge link show | cut -d ":" -f2 | cut -d " " -f2)
    readarray -t BRIDGED_DEVICES <<<"$bridged_devs"

    UNBRIDGED_DEVICES=()
    for dev in "${ALL_DEVICES[@]}"; do
        bridged=false
        for bridged_dev in "${BRIDGED_DEVICES[@]}"; do
            [ "$dev" = "$bridged_dev" ] && bridged=true && break
        done
        [ "$bridged" = false ] && UNBRIDGED_DEVICES+=("$dev")
    done
    UNBRIDGED_COUNT=${#UNBRIDGED_DEVICES[@]}
}

# Select network option
select_network_option() {
    local type="$1" eth="$2"
    local -a menu=("None" "No network assigned" "OFF")
    for bridge in "${BRIDGE_LIST[@]}"; do
        menu+=("bridge:$bridge" "Bridge $bridge" "OFF")
    done
    for device in "${UNBRIDGED_DEVICES[@]}"; do
        menu+=("device:$device" "Device $device" "OFF")
    done
    whiptail_radiolist "$type Network Selection" "Select a bridge or device for $type ($eth) or 'None':\nUse Spacebar to select." 16 60 "${menu[@]}"
}

# Detect next available Container ID
detect_next_ctid() {
    local id
    id=$(pvesh get /cluster/nextid)
    echo "${id:-100}"
}

# Prompt with default value
prompt_with_default() {
    local prompt="$1" default="$2" var="$3"
    read -e -p "$prompt (default: $default): " -i "$default" input
    eval "$var=\"${input:-$default}\""
}

# Main execution
echo -e "${GREEN}Fetching latest stable OpenWrt version...${NC}"
STABLE_VER=$(detect_latest_version)
echo -e "${GREEN}Detected latest stable version: $STABLE_VER${NC}"

# Select architecture (x86_64, aarch64, or armv7)
ARCH=$(whiptail --title "Architecture Selection" --radiolist \
    "Choose the architecture for the OpenWrt container:\nUse Spacebar to select." 12 60 3 \
    "x86_64" "64-bit Intel/AMD (default)" "ON" \
    "aarch64" "64-bit ARM (armv8)" "OFF" \
    "armv7" "32-bit ARM" "OFF" 3>&1 1>&2 2>&3) || exit_script 1 "Error: Architecture selection aborted"
echo -e "${GREEN}Selected architecture: $ARCH${NC}"

# Select OpenWrt release type
RELEASE_TYPE=$(whiptail --title "OpenWrt Release Type" --radiolist \
    "Choose the OpenWrt release type (Stable allows manual version input):\nUse Spacebar to select." 10 60 2 \
    "Stable" "Stable version (e.g., $STABLE_VER)" "ON" \
    "Snapshot" "Latest daily snapshot" "OFF" 3>&1 1>&2 2>&3) || exit_script 1 "Error: Release type selection aborted"

# Set architecture-specific target and download URL
if [ "$ARCH" = "x86_64" ]; then
    TARGET="x86/64"
    PCT_ARCH="amd64"
elif [ "$ARCH" = "aarch64" ]; then
    TARGET="armsr/armv8"
    PCT_ARCH="arm64"
elif [ "$ARCH" = "armv7" ]; then
    TARGET="armsr/armv7"
    PCT_ARCH="arm"
else
    exit_script 1 "Error: Unsupported architecture $ARCH"
fi

if [ "$RELEASE_TYPE" = "Stable" ]; then
    prompt_with_default "Enter OpenWrt stable version" "$STABLE_VER" VER
    DOWNLOAD_URL="https://downloads.openwrt.org/releases/$VER/targets/$TARGET/openwrt-$VER-$TARGET-rootfs.tar.gz"
    # Replace '/' with '-' in TARGET for filename
    FILENAME_TARGET=$(echo "$TARGET" | tr '/' '-')
    TEMPLATE_FILE="openwrt-$VER-$FILENAME_TARGET.tar.gz"
else
    VER="snapshot"
    DOWNLOAD_URL="https://downloads.openwrt.org/snapshots/targets/$TARGET/openwrt-$TARGET-rootfs.tar.gz"
    # Replace '/' with '-' in TARGET for filename
    FILENAME_TARGET=$(echo "$TARGET" | tr '/' '-')
    TEMPLATE_FILE="openwrt-snapshot-$FILENAME_TARGET.tar.gz"
    # Prompt for LuCI installation
    if whiptail --title "Install LuCI" --yesno "Would you like to automatically install LuCI (graphical web interface) for the snapshot?" 10 60 3>&1 1>&2 2>&3; then
        INSTALL_LUCI=1
    else
        INSTALL_LUCI=0
    fi
fi

NEXT_CTID=$(detect_next_ctid)
prompt_with_default "Enter Container ID" "$NEXT_CTID" CTID
prompt_with_default "Enter Container Name" "openwrt-$CTID" CTNAME

while true; do
    read -s -p "Enter root password (leave blank to skip): " PASSWORD; echo
    read -s -p "Confirm root password: " PASSWORD_CONFIRM; echo
    if [ -z "$PASSWORD" ] && [ -z "$PASSWORD_CONFIRM" ]; then
        echo -e "${GREEN}Root password skipped.${NC}"
        break
    elif [ "$PASSWORD" = "$PASSWORD_CONFIRM" ]; then
        break
    else
        echo -e "${RED}Passwords do not match. Please try again.${NC}"
    fi
done

prompt_with_default "Enter memory size in MB" "$DEFAULT_MEMORY" MEMORY
prompt_with_default "Enter number of CPU cores" "$DEFAULT_CORES" CORES
prompt_with_default "Enter storage limit in GB" "$DEFAULT_STORAGE" STORAGE_SIZE
prompt_with_default "Enter LAN subnet" "$DEFAULT_SUBNET" SUBNET

# Validate inputs
[[ "$CTID" =~ ^[0-9]+$ && "$CTID" -ge 100 ]] || exit_script 1 "Error: Container ID must be a number >= 100"
pct list | awk '{print $1}' | grep -q "^$CTID$" && exit_script 1 "Error: Container ID $CTID is already in use"
[[ "$MEMORY" =~ ^[0-9]+$ && "$MEMORY" -ge 64 ]] || exit_script 1 "Error: Memory size must be a number >= 64 MB"
[[ "$CORES" =~ ^[0-9]+$ && "$CORES" -ge 1 ]] || exit_script 1 "Error: Core count must be a number >= 1"
[[ "$STORAGE_SIZE" =~ ^[0-9]*\.?[0-9]+$ && "$(echo "$STORAGE_SIZE > 0" | bc)" -eq 1 ]] || exit_script 1 "Error: Storage limit must be a positive number"

# Parse subnet
LAN_IP=$(echo "$SUBNET" | cut -d'/' -f1)
LAN_PREFIX=$(echo "$SUBNET" | cut -d'/' -f2)
case "$LAN_PREFIX" in
    24) LAN_NETMASK="255.255.255.0" ;;
    23) LAN_NETMASK="255.255.254.0" ;;
    22) LAN_NETMASK="255.255.252.0" ;;
    16) LAN_NETMASK="255.255.0.0" ;;
    *) exit_script 1 "Error: Unsupported subnet prefix /$LAN_PREFIX. Use /16, /22, /23, or /24" ;;
esac

STORAGE=$(select_storage container)
detect_network_options
[ "$BRIDGE_COUNT" -eq 0 ] && [ "$UNBRIDGED_COUNT" -eq 0 ] && echo -e "${RED}Warning: No network options found. Selecting 'None' for WAN/LAN.${NC}"

WAN_OPTION=$(select_network_option "WAN" "eth0")
LAN_OPTION=$(select_network_option "LAN" "eth1")

WAN_BRIDGE=""; WAN_DEVICE=""
if [[ "$WAN_OPTION" == bridge:* ]]; then
    WAN_BRIDGE="${WAN_OPTION#bridge:}"
elif [[ "$WAN_OPTION" == device:* ]]; then
    WAN_DEVICE="${WAN_OPTION#device:}"
fi

LAN_BRIDGE=""; LAN_DEVICE=""
if [[ "$LAN_OPTION" == bridge:* ]]; then
    LAN_BRIDGE="${LAN_OPTION#bridge:}"
elif [[ "$LAN_OPTION" == device:* ]]; then
    LAN_DEVICE="${LAN_OPTION#device:}"
fi

# Summary and confirmation
SUMMARY="Container Configuration Summary:\n"
SUMMARY+="  OpenWrt Version: $VER\n"
SUMMARY+="  Architecture: $ARCH\n"
SUMMARY+="  Container ID: $CTID\n"
SUMMARY+="  Container Name: $CTNAME\n"
SUMMARY+="  Root Password: $( [ -n "$PASSWORD" ] && echo "Set" || echo "Not set" )\n"
SUMMARY+="  Memory: $MEMORY MB\n"
SUMMARY+="  CPU Cores: $CORES\n"
SUMMARY+="  Storage: $STORAGE_SIZE GB on $STORAGE\n"
SUMMARY+="  LAN Subnet: $SUBNET\n"
SUMMARY+="  WAN Interface: ${WAN_BRIDGE:-${WAN_DEVICE:-None}} (eth0, DHCP/DHCPv6)\n"
SUMMARY+="  LAN Interface: ${LAN_BRIDGE:-${LAN_DEVICE:-None}} (eth1, static)\n"
[ "$RELEASE_TYPE" = "Snapshot" ] && [ "$INSTALL_LUCI" -eq 1 ] && SUMMARY+="  LuCI: Will be installed automatically\n"

whiptail --title "Confirm Container Creation" --yesno "$SUMMARY\nProceed with container creation?" 20 60 || exit_script 0 "Container creation aborted by user"

# Download template with snapshot age check
if [ ! -f "$TEMPLATE_DIR/$TEMPLATE_FILE" ]; then
    echo -e "${GREEN}Downloading OpenWrt $VER rootfs for $ARCH...${NC}"
    wget -q "$DOWNLOAD_URL" -O "$TEMPLATE_DIR/$TEMPLATE_FILE" || exit_script 1 "Error: Failed to download OpenWrt $VER image for $ARCH"
else
    if [ "$RELEASE_TYPE" = "Snapshot" ]; then
        # Check if snapshot file is older than 1 day (86400 seconds)
        FILE_AGE=$(($(date +%s) - $(stat -c %Y "$TEMPLATE_DIR/$TEMPLATE_FILE")))
        if [ "$FILE_AGE" -gt 86400 ]; then
            echo -e "${GREEN}Snapshot is older than 1 day, refreshing...${NC}"
            rm -f "$TEMPLATE_DIR/$TEMPLATE_FILE"
            wget -q "$DOWNLOAD_URL" -O "$TEMPLATE_DIR/$TEMPLATE_FILE" || exit_script 1 "Error: Failed to download OpenWrt snapshot for $ARCH"
        else
            echo -e "${GREEN}Using existing OpenWrt snapshot: $TEMPLATE_FILE${NC}"
        fi
    else
        echo -e "${GREEN}Using existing OpenWrt image: $TEMPLATE_FILE${NC}"
    fi
fi

# Build pct create command with corrected network options
echo -e "${GREEN}Creating LXC container $CTID...${NC}"
NET_OPTS=()
[ -n "$WAN_BRIDGE" ] && NET_OPTS+=("--net0" "name=eth0,bridge=$WAN_BRIDGE")
[ -n "$WAN_DEVICE" ] && NET_OPTS+=("--net0" "name=eth0,hwaddr=$(ip link show "$WAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)")
[ -n "$LAN_BRIDGE" ] && NET_OPTS+=("--net1" "name=eth1,bridge=$LAN_BRIDGE")
[ -n "$LAN_DEVICE" ] && NET_OPTS+=("--net1" "name=eth1,hwaddr=$(ip link show "$LAN_DEVICE" | grep -o 'ether [0-9a-f:]\+' | cut -d' ' -f2)")

pct create "$CTID" "$TEMPLATE_DIR/$TEMPLATE_FILE" \
    --arch "$PCT_ARCH" \
    --hostname "$CTNAME" \
    --rootfs "$STORAGE:$STORAGE_SIZE" \
    --memory "$MEMORY" \
    --cores "$CORES" \
    --unprivileged 1 \
    --features nesting=1 \
    --ostype unmanaged \
    "${NET_OPTS[@]}" || exit_script 1 "Error: Failed to create container"

echo -e "${GREEN}Starting container $CTID...${NC}"
pct start "$CTID" || exit_script 1 "Error: Failed to start container"

pct exec "$CTID" -- sh -c "sed -i 's!procd_add_jail!: procd_add_jail!g' /etc/init.d/dnsmasq"
sleep 10

echo -e "${GREEN}Configuring network...${NC}"
pct exec "$CTID" -- sh -c "
    # Configure WAN (eth0) with DHCP and DHCPv6
    uci set network.wan=interface
    uci set network.wan.proto='dhcp'
    uci set network.wan.device='eth0'
    uci set network.wan6=interface
    uci set network.wan6.proto='dhcpv6'
    uci set network.wan6.device='eth0'

    # Configure LAN (eth1) with static IP
    uci set network.lan=interface
    uci set network.lan.proto='static'
    uci set network.@device[0].ports='eth1'
    uci set network.lan.ipaddr='$LAN_IP'
    uci set network.lan.netmask='$LAN_NETMASK'

    # Commit changes and restart network
    uci commit network
    /etc/init.d/network restart" || echo -e "${RED}Warning: Network configuration failed${NC}"

if [ "$RELEASE_TYPE" = "Snapshot" ] && [ "$INSTALL_LUCI" -eq 1 ]; then
    echo -e "${GREEN}Waiting 15 seconds for internet connectivity...${NC}"
    sleep 15
    echo -e "${GREEN}Installing LuCI...${NC}"
    pct exec "$CTID" -- sh -c "apk update; apk add luci" || echo -e "${RED}Warning: LuCI installation failed${NC}"
fi

[ -n "$PASSWORD" ] && {
    echo -e "${GREEN}Setting root password...${NC}"
    echo -e "$PASSWORD\n$PASSWORD" | pct exec "$CTID" -- passwd || echo -e "${RED}Warning: Failed to set root password${NC}"
} || echo -e "${GREEN}Root password not set (left blank).${NC}"

echo -e "${GREEN}Container $CTID ($CTNAME) created and started!${NC}"
echo "Next steps:"
echo "1. Access: pct exec $CTID /bin/sh"
echo "2. Verify network: uci show network"
if [ "$RELEASE_TYPE" = "Snapshot" ]; then
    echo "3. Update: apk update"
    if [ "$INSTALL_LUCI" -eq 1 ]; then
        echo "4. LuCI installed: Access at http://$LAN_IP (if LAN configured)"
    else
        echo "4. Install LuCI: apk add luci"
        [ -n "$LAN_BRIDGE" ] || [ -n "$LAN_DEVICE" ] && echo "5. LuCI: http://$LAN_IP" || echo "5. Add eth1 to activate LAN: http://$LAN_IP"
    fi
else
    echo "3. Update: opkg update"
    echo "4. Install LuCI: opkg install luci"
    [ -n "$LAN_BRIDGE" ] || [ -n "$LAN_DEVICE" ] && echo "5. LuCI: http://$LAN_IP" || echo "5. Add eth1 to activate LAN: http://$LAN_IP"
fi
[ -z "$PASSWORD" ] && echo "6. Set password if needed: pct exec $CTID passwd"

@7thgenerationdesign

Copy link
Copy Markdown

Thank you @jaminmc for this! My context is the same as @brightplastik above... Wanting to install proxmox on the RK3588 (on the CM3588 NAS board) with OpenWRT as a VM alongside Ubuntu.

I'm very new to proxmox. Would I run the ARM-adapted script in proxmox the same way I would run a script in ubuntu - copy into a directory, and then run it from proxmox terminal?

@brightplastik , did you have any luck with the ARM-adapted script above?

Thanks again!

@jaminmc

jaminmc commented Jul 23, 2025

Copy link
Copy Markdown
Author

Since I do not have an ARM system to test it on, I don't know.

@brightplastik

Copy link
Copy Markdown

Hello @7thgenerationdesign, I tried hard, even pasting all commands in shell to see if it worked, but there's some problem with wget the image on the board. It fails. I eventually managed to install owrt as a privileged CT, but with a seriously convoluted procedure I might not be able to tell you in depth. I hadto do it manually though, despite the faith I had toward this script! All I can tell you is that it is doable, spending nights to address very weird errors. 😬

@bob-u

bob-u commented Aug 5, 2025

Copy link
Copy Markdown

Thank you for the script! Btw, AI said that the script is not malicious, well structured and directory agnostic :)
The script worked for me after fixing one issue: storage selection was being cut off (off screen) so I changed one line to hard-code width to 78:
whiptail_radiolist "Storage Pools" "Which storage pool for the ${label,,}?\nUse Spacebar to select." 16 78 "${menu[@]}"

@DeViLRuNNeR-dev

Copy link
Copy Markdown

Thanks for your script !

Is your luci/admin/status/realtime/connections working ?

That doesn't look like it's working, I believe there is a kernel module mismatch between proxmox kernel modules and some openwrt expects, namely proc netfilter.

@tew42

tew42 commented Oct 20, 2025

Copy link
Copy Markdown

Nice, compared to the linuxcontainers.org builds this has some clear config advantages regarding the network defaults - but I think sysntpd service removal should be implemented here also to avoid conflicts between container and host?
i.e., rm -f /etc/rc.d/*sysntpd

@jaminmc

jaminmc commented Oct 20, 2025

Copy link
Copy Markdown
Author

Nice, compared to the linuxcontainers.org builds this has some clear config advantages regarding the network defaults - but I think sysntpd service removal should be implemented here also to avoid conflicts between container and host? i.e., rm -f /etc/rc.d/*sysntpd

Good idea! I added a prompt to disable it during the setup. It could still be useful if the OpenWrt is a NTP server, so I made it an option. Additionally, I simplified the installation process by using whiptail for all the prompts to maintain consistency in the user interface.

@DeViLRuNNeR-dev

DeViLRuNNeR-dev commented Oct 26, 2025

Copy link
Copy Markdown

Is anybody else also having the issue that the lists or pages in luci/admin/status/realtime/connections are empty?

@Freedbot

Copy link
Copy Markdown

I'll be trying this soon and finding out for myself I guess, but how much (if at all) did this break with the ProxMox 9.1 update (November 19th). Also, any thoughts on OpenWRT's release candidate instead of stable? By that I just mean is the RC reasonably reliable and does it run on a more similar linux kernal version to ProxMox 9.1 or not?

@jaminmc

jaminmc commented Feb 9, 2026

Copy link
Copy Markdown
Author

OpenWrt LXC Creator Script – Changelog

  • Added automatic detection of latest stable + newer release candidates (RCs)
  • Prompt user if a newer RC exists β†’ option to use it
  • When choosing RC, manual version input now defaults to the selected RC version
  • Robust template handling: check size + tar integrity, prompt to reuse/redownload, clean up partial/corrupt files
  • Force snapshot refresh if >1 day old
  • Exit cleanly on Esc/Cancel in all whiptail dialogs
  • Improved messages, prompt clarity, and summary display

Now safer, smarter about versions, and friendlier to cancel out of. πŸš€

@galeksandrp

galeksandrp commented May 21, 2026

Copy link
Copy Markdown
From b53d44d8db59f7f44f60070dafe87a1379ee68a9 Mon Sep 17 00:00:00 2001
From: Alexander Georgievskiy <galeksandrp@gmail.com>
Date: Thu, 21 May 2026 13:36:36 +0300
Subject: [PATCH]

---
 install_openwrt_proxmox.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/install_openwrt_proxmox.sh b/install_openwrt_proxmox.sh
index 1fa27aa..d82e270 100644
--- a/install_openwrt_proxmox.sh
+++ b/install_openwrt_proxmox.sh
@@ -57,7 +57,7 @@ whiptail_input() {
 detect_latest_stable() {
     local ver
     ver=$(curl -sSf "https://downloads.openwrt.org/" |
-          grep -oP '(?<=OpenWrt )\d+\.\d+\.\d+(?=\s|</strong>|Released)' |
+          grep -oP '(?<=OpenWrt )\d+\.\d+\.\d+(?=\s|</a></strong>|Released)' |
           head -1)
     if [ -z "$ver" ]; then
         ver=$(curl -sSf "https://downloads.openwrt.org/releases/" |
-- 
2.54.0.windows.1

OpenWrt 25.12.4 is here.

@jaminmc

jaminmc commented Aug 29, 2026

Copy link
Copy Markdown
Author

OpenWrt changed the downloads homepage HTML, so the old scrape no longer saw current releases. Versions now come from https://downloads.openwrt.org/.versions.json (stable, old-stable, RCs, snapshot), with a directory-name fallback if that file is missing. You can pick a listed build or type one.

Last successful settings are saved to /root/.openwrt-proxmox-lxc.conf and offered as defaults on the next run (ID/name, storage, bridges, MACs, LAN IPv4/CIDR, memory/cores, sysntpd, firewall, LuCI). The root password is never stored. If the old CTID is taken, the next free ID is offered. Auto hostnames are openwrt-, or openwrt-snap- for snapshots; a custom name is kept.

Guest network is written as a full /etc/config/network:

  • eth0 / net0 = WAN, DHCP + DHCPv6
  • eth1 / net1 = LAN, br-lan static at the address you enter (field is labeled LAN IPv4/CIDR, e.g. 10.23.45.1/24)

Optional WAN/LAN MACs (aa:bb:cc:dd:ee:ff, blank = Proxmox assigns). Firewall stays enabled by default; you can still disable it. Snapshot LuCI still uses apk; 24.10 uses opkg. If a later step fails you can destroy the new CT and abort.

Tested on snapshot, 25.12.5, and 24.10.8.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment