diff --git a/.gitignore b/.gitignore index c7571c1f..9f43e5df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ __pycache__/ releases/* testing/stubs/local.* -build +build/ hostid zpool.cache + +# Generated by contrib/remote-ssh-build.sh +cmdline.d/ +dracut.conf.d/ +dropbear/ diff --git a/contrib/dracut-modules/30rfc3442fix/module-setup.sh b/contrib/dracut-modules/30rfc3442fix/module-setup.sh new file mode 100644 index 00000000..cd1b07da --- /dev/null +++ b/contrib/dracut-modules/30rfc3442fix/module-setup.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Dracut module to install patched dhclient-script before the network-legacy module +# This module uses prefix 30 to load BEFORE 35network-legacy (which has the buggy version) + +check() { + return 0 +} + +depends() { + return 0 +} + +install() { + # Install our patched dhclient-script + # Since we run before 35network-legacy, our version will be in place first + # and dracut won't overwrite it + if [ -f /sbin/dhclient-script ]; then + inst_simple /sbin/dhclient-script /sbin/dhclient-script + fi +} diff --git a/contrib/network-hooks/dhclient-script.patched b/contrib/network-hooks/dhclient-script.patched new file mode 100755 index 00000000..499826b1 --- /dev/null +++ b/contrib/network-hooks/dhclient-script.patched @@ -0,0 +1,305 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin + +type getarg > /dev/null 2>&1 || . /lib/dracut-lib.sh +type ip_to_var > /dev/null 2>&1 || . /lib/net-lib.sh + +# We already need a set netif here +netif=$interface + +setup_interface() { + ip=$new_ip_address + mtu=$new_interface_mtu + mask=$new_subnet_mask + bcast=$new_broadcast_address + gw=${new_routers%%,*} + domain=$new_domain_name + # get rid of control chars + search=$(printf -- "%s" "$new_domain_search" | tr -d '[:cntrl:]') + namesrv=$new_domain_name_servers + hostname=$new_host_name + [ -n "$new_dhcp_lease_time" ] && lease_time=$new_dhcp_lease_time + [ -n "$new_max_life" ] && lease_time=$new_max_life + preferred_lft=$lease_time + [ -n "$new_preferred_life" ] && preferred_lft=$new_preferred_life + + # shellcheck disable=SC1090 + [ -f /tmp/net."$netif".override ] && . /tmp/net."$netif".override + + # Taken from debian dhclient-script: + # The 576 MTU is only used for X.25 and dialup connections + # where the admin wants low latency. Such a low MTU can cause + # problems with UDP traffic, among other things. As such, + # disallow MTUs from 576 and below by default, so that broken + # MTUs are ignored, but higher stuff is allowed (1492, 1500, etc). + if [ -n "$mtu" ] && [ "$mtu" -gt 576 ]; then + if ! ip link set "$netif" mtu "$mtu"; then + ip link set "$netif" down + ip link set "$netif" mtu "$mtu" + linkup "$netif" + fi + fi + + ip addr add "$ip"${mask:+/$mask} ${bcast:+broadcast $bcast} dev "$netif" \ + ${lease_time:+valid_lft $lease_time} \ + ${preferred_lft:+preferred_lft ${preferred_lft}} + + if [ -n "$gw" ]; then + if [ "$mask" = "255.255.255.255" ]; then + # point-to-point connection => set explicit route to gateway + echo ip route add "$gw" dev "$netif" > /tmp/net."$netif".gw + fi + + echo "$gw" | { + IFS=' ' read -r main_gw other_gw + echo ip route replace default via "$main_gw" dev "$netif" >> /tmp/net."$netif".gw + if [ -n "$other_gw" ]; then + for g in $other_gw; do + echo ip route add default via "$g" dev "$netif" >> /tmp/net."$netif".gw + done + fi + } + fi + + if getargbool 1 rd.peerdns; then + [ -n "${search}${domain}" ] && echo "search $search $domain" > /tmp/net."$netif".resolv.conf + if [ -n "$namesrv" ]; then + for s in $namesrv; do + echo nameserver "$s" + done + fi >> /tmp/net."$netif".resolv.conf + fi + # Note: hostname can be fqdn OR short hostname, so chop off any + # trailing domain name and explicitly add any domain if set. + [ -n "$hostname" ] && echo "echo ${hostname%."$domain"}${domain:+.$domain} > /proc/sys/kernel/hostname" > /tmp/net."$netif".hostname +} + +setup_interface6() { + domain=$new_domain_name + # get rid of control chars + search=$(printf -- "%s" "$new_dhcp6_domain_search" | tr -d '[:cntrl:]') + namesrv=$new_dhcp6_name_servers + hostname=$new_host_name + [ -n "$new_dhcp_lease_time" ] && lease_time=$new_dhcp_lease_time + [ -n "$new_max_life" ] && lease_time=$new_max_life + preferred_lft=$lease_time + [ -n "$new_preferred_life" ] && preferred_lft=$new_preferred_life + + # shellcheck disable=SC1090 + [ -f /tmp/net."$netif".override ] && . /tmp/net."$netif".override + + ip -6 addr add "${new_ip6_address}"/"${new_ip6_prefixlen}" \ + dev "${netif}" scope global \ + ${lease_time:+valid_lft $lease_time} \ + ${preferred_lft:+preferred_lft ${preferred_lft}} + + if getargbool 1 rd.peerdns; then + [ -n "${search}${domain}" ] && echo "search $search $domain" > /tmp/net."$netif".resolv.conf + if [ -n "$namesrv" ]; then + for s in $namesrv; do + echo nameserver "$s" + done + fi >> /tmp/net."$netif".resolv.conf + fi + + # Note: hostname can be fqdn OR short hostname, so chop off any + # trailing domain name and explicitly add any domain if set. + [ -n "$hostname" ] && echo "echo ${hostname%."$domain"}${domain:+.$domain} > /proc/sys/kernel/hostname" > /tmp/net."$netif".hostname +} + +parse_option_121() { + # RFC 3442 classless static routes format: + # Each route is: + # mask_width determines how many destination octets follow (0-4) + # + # This version validates arguments before operations to prevent + # "integer expression expected" and "shift count out of range" errors. + + while [ $# -ge 5 ]; do + mask="$1" + + # Validate mask is a number between 0-32 + case "$mask" in + ''|*[!0-9]*) return 0 ;; + esac + if [ "$mask" -lt 0 ] 2>/dev/null || [ "$mask" -gt 32 ] 2>/dev/null; then + return 0 + fi + shift + + # Calculate how many destination address bytes we need based on mask + if [ "$mask" -gt 24 ]; then + need_dest=4 + elif [ "$mask" -gt 16 ]; then + need_dest=3 + elif [ "$mask" -gt 8 ]; then + need_dest=2 + elif [ "$mask" -gt 0 ]; then + need_dest=1 + else + need_dest=0 + fi + + # We need: destination bytes + 4 gateway bytes + need_total=$((need_dest + 4)) + if [ $# -lt $need_total ]; then + return 0 + fi + + # Check if destination is multicast (224.0.0.0 - 239.255.255.255) + multicast=0 + if [ $need_dest -ge 1 ]; then + case "$1" in + ''|*[!0-9]*) return 0 ;; + esac + if [ "$1" -ge 224 ] 2>/dev/null && [ "$1" -lt 240 ] 2>/dev/null; then + multicast=1 + fi + fi + + # Build destination address based on mask width + if [ "$mask" -gt 24 ]; then + destination="$1.$2.$3.$4/$mask" + shift 4 + elif [ "$mask" -gt 16 ]; then + destination="$1.$2.$3.0/$mask" + shift 3 + elif [ "$mask" -gt 8 ]; then + destination="$1.$2.0.0/$mask" + shift 2 + elif [ "$mask" -gt 0 ]; then + destination="$1.0.0.0/$mask" + shift 1 + else + destination="0.0.0.0/$mask" + fi + + # Read gateway (always 4 bytes) + if [ $# -lt 4 ]; then + return 0 + fi + gateway="$1.$2.$3.$4" + shift 4 + + # Build and emit the route command + if [ $multicast -eq 1 ] || [ "$gateway" = "0.0.0.0" ]; then + temp_result="$destination dev $interface" + else + temp_result="$destination via $gateway dev $interface" + fi + + echo "/sbin/ip route replace $temp_result" + done +} + +case $reason in + PREINIT) + echo "dhcp: PREINIT $netif up" + linkup "$netif" + ;; + + PREINIT6) + echo "dhcp: PREINIT6 $netif up" + linkup "$netif" + wait_for_ipv6_dad_link "$netif" + ;; + + BOUND) + echo "dhcp: BOUND setting up $netif" + unset layer2 + if [ -f /sys/class/net/"$netif"/device/layer2 ]; then + read -r layer2 < /sys/class/net/"$netif"/device/layer2 + fi + if [ "$layer2" != "0" ]; then + if command -v arping2 > /dev/null; then + if arping2 -q -C 1 -c 2 -I "$netif" -0 "$new_ip_address"; then + warn "Duplicate address detected for $new_ip_address while doing dhcp. retrying" + exit 1 + fi + else + if ! arping -f -q -D -c 2 -I "$netif" "$new_ip_address"; then + warn "Duplicate address detected for $new_ip_address while doing dhcp. retrying" + exit 1 + fi + fi + fi + unset layer2 + setup_interface + set | while read -r line || [ -n "$line" ]; do + [ "${line#new_}" = "$line" ] && continue + echo "$line" + done > /tmp/dhclient."$netif".dhcpopts + + { + echo '. /lib/net-lib.sh' + echo "setup_net $netif" + if [ -n "$new_classless_static_routes" ]; then + OLDIFS="$IFS" + IFS=".$IFS" + parse_option_121 "$new_classless_static_routes" + IFS="$OLDIFS" + fi + echo "source_hook initqueue/online $netif" + [ -e /tmp/net."$netif".manualup ] || echo "/sbin/netroot $netif" + echo "rm -f -- $hookdir/initqueue/setup_net_$netif.sh" + } > "$hookdir"/initqueue/setup_net_"$netif".sh + + echo "[ -f /tmp/net.$netif.did-setup ]" > "$hookdir"/initqueue/finished/dhclient-"$netif".sh + : > /tmp/net."$netif".up + if [ -e /sys/class/net/"${netif}"/address ]; then + : > "/tmp/net.$(cat /sys/class/net/"${netif}"/address).up" + fi + + ;; + + RENEW | REBIND) + unset lease_time + [ -n "$new_dhcp_lease_time" ] && lease_time=$new_dhcp_lease_time + [ -n "$new_max_life" ] && lease_time=$new_max_life + preferred_lft=$lease_time + [ -n "$new_preferred_life" ] && preferred_lft=$new_preferred_life + ip -4 addr change "${new_ip_address}"/"${new_subnet_mask}" broadcast "${new_broadcast_address}" dev "${interface}" \ + ${lease_time:+valid_lft $lease_time} ${preferred_lft:+preferred_lft ${preferred_lft}} \ + > /dev/null 2>&1 + ;; + + BOUND6) + echo "dhcp: BOUND6 setting up $netif" + setup_interface6 + + set | while read -r line || [ -n "$line" ]; do + [ "${line#new_}" = "$line" ] && continue + echo "$line" + done > /tmp/dhclient."$netif".dhcpopts + + { + echo '. /lib/net-lib.sh' + echo "setup_net $netif" + echo "source_hook initqueue/online $netif" + [ -e /tmp/net."$netif".manualup ] || echo "/sbin/netroot $netif" + echo "rm -f -- $hookdir/initqueue/setup_net_$netif.sh" + } > "$hookdir"/initqueue/setup_net_"$netif".sh + + echo "[ -f /tmp/net.$netif.did-setup ]" > "$hookdir"/initqueue/finished/dhclient-"$netif".sh + : > /tmp/net."$netif".up + if [ -e /sys/class/net/"${netif}"/address ]; then + : > "/tmp/net.$(cat /sys/class/net/"${netif}"/address).up" + fi + ;; + + RENEW6 | REBIND6) + unset lease_time + [ -n "$new_dhcp_lease_time" ] && lease_time=$new_dhcp_lease_time + [ -n "$new_max_life" ] && lease_time=$new_max_life + preferred_lft=$lease_time + [ -n "$new_preferred_life" ] && preferred_lft=$new_preferred_life + ip -6 addr change "${new_ip6_address}"/"${new_ip6_prefixlen}" dev "${interface}" scope global \ + ${lease_time:+valid_lft $lease_time} ${preferred_lft:+preferred_lft ${preferred_lft}} \ + > /dev/null 2>&1 + ;; + + *) echo "dhcp: $reason" ;; +esac + +exit 0 diff --git a/contrib/remote-ssh-build.sh b/contrib/remote-ssh-build.sh index 7e3aae61..e7990ac3 100755 --- a/contrib/remote-ssh-build.sh +++ b/contrib/remote-ssh-build.sh @@ -41,14 +41,28 @@ ## USAGE -# To unlock the pool remotely, log in via ssh, then start zfsbootmenu, enter the -# key and continue as usual. +# When you connect via SSH, ZFSBootMenu will launch automatically. +# Enter the encryption key if needed and continue as usual. # ``` -# ssh root@host -p 222 -# zfsbootmenu +# ssh root@host -p 22 +# # ZFSBootMenu starts automatically # ``` +## SSH CONNECTION TIMEOUT + +# By default, ZBM with SSH support waits indefinitely for a user to connect. +# Set SSH_TIMEOUT to a number of seconds to enable auto-boot if no SSH login +# occurs within that time. This is useful for unattended reboots with a +# "rescue window" for remote access. + +# ``` +# SSH_TIMEOUT=60 ./remote-ssh-build.sh +# ``` + +# With the above, if no SSH login occurs within 60 seconds, boot proceeds. + + ## SCRIPT ARGUMENTS # This script forwards arguments to the zbm-builder.sh helper script, but @@ -102,7 +116,20 @@ RS_AUTH="${RS_KEYDIR}/authorized_keys" if [ ! -f "${RS_AUTH}" ]; then if [ -r "${RS_AUTH_SRC}" ]; then echo "Cannot find ${RS_AUTH}, copying from ${RS_AUTH_SRC}" - cp -v "${RS_AUTH_SRC}" "${RS_AUTH}" + # Prepend command="/bin/zfsbootmenu" to each key line so zfsbootmenu + # automatically starts when a user connects via SSH + while IFS= read -r line || [ -n "${line}" ]; do + # Skip empty lines and comments + if [ -z "${line}" ] || [[ "${line}" == \#* ]]; then + echo "${line}" >> "${RS_AUTH}" + # Skip lines that already have command= prefix + elif [[ "${line}" == command=* ]]; then + echo "${line}" >> "${RS_AUTH}" + else + echo "command=\"/bin/zfsbootmenu\" ${line}" >> "${RS_AUTH}" + fi + done < "${RS_AUTH_SRC}" + echo "Configured authorized_keys to auto-launch zfsbootmenu on SSH login" else echo "ERROR: Cannot find ${RS_AUTH}, and ${RS_AUTH_SRC} is not available, please provide it manually" exit 1 @@ -110,15 +137,58 @@ if [ ! -f "${RS_AUTH}" ]; then fi -## PREPARE SETTINGS +## PREPARE NETWORK SETTINGS + +# Use DHCP for network configuration. The rfc3442-fix.sh hook ensures that +# classless static routes (RFC 3442 / option 121) are parsed correctly, +# which is required for providers like Hetzner that use /32 networks. +# +# Environment variables for network configuration: +# +# NET_MAC=aa:bb:cc:dd:ee:ff - Use specific interface by MAC address +# (Creates: ifname=zbmnet0: ip=zbmnet0:dhcp) +# +# NET_IFACE=enp0s1 - Use specific interface by name +# (Creates: ip=enp0s1:dhcp) +# +# NET_IFACE="enp0s1 enp0s2" - Use multiple interfaces +# (Creates: ip=enp0s1:dhcp ip=enp0s2:dhcp) +# +# If neither is set, defaults to ip=dhcp (all interfaces with DHCP) mkdir -p "${BUILD_DIR}/cmdline.d" RS_DNC="${BUILD_DIR}/cmdline.d/dracut-network.conf" if [ ! -f "${RS_DNC}" ]; then - # ip=dhcp tries to bring up all interfaces - # ip=single-dhcp stops after bringing up the first + # Build network arguments based on configuration # See https://www.man7.org/linux/man-pages/man7/dracut.cmdline.7.html - echo "ip=single-dhcp rd.neednet=1" > "${RS_DNC}" + RS_NET_ARGS="" + + if [ -n "${NET_MAC}" ]; then + # MAC-based interface identification (works regardless of NIC naming) + # ifname assigns predictable name "zbmnet0" to the interface with this MAC + RS_NET_ARGS="ifname=zbmnet0:${NET_MAC} ip=zbmnet0:dhcp" + echo "Network: Using MAC ${NET_MAC} (as zbmnet0)" + elif [ -n "${NET_IFACE}" ]; then + # Specific interface(s) by name + for iface in ${NET_IFACE}; do + RS_NET_ARGS="${RS_NET_ARGS} ip=${iface}:dhcp" + done + echo "Network: Using interface(s): ${NET_IFACE}" + else + # Default: DHCP on all interfaces + RS_NET_ARGS="ip=dhcp" + echo "Network: DHCP on all interfaces" + fi + + RS_NET_ARGS="${RS_NET_ARGS} rd.neednet=1" + + # Add SSH timeout (default: 30 seconds) + # If no SSH login occurs within this time, boot proceeds automatically + # Set SSH_TIMEOUT=0 to disable timeout and wait indefinitely + RS_NET_ARGS="${RS_NET_ARGS} zbm.ssh_timeout=${SSH_TIMEOUT:-30}" + echo "SSH timeout: ${SSH_TIMEOUT:-30} seconds" + + echo "${RS_NET_ARGS}" > "${RS_DNC}" fi # Generated config file, not user customizable @@ -129,11 +199,37 @@ cat > "${RS_DDC}" <<-EOF add_dracutmodules+=" crypt-ssh " install_optional_items+=" /etc/cmdline.d/dracut-network.conf " dropbear_acl=/build/dropbear/authorized_keys + dropbear_port="22" EOF for keytype in "${RS_SSH_KEYTYPES[@]}"; do echo "dropbear_${keytype}_key=/build/dropbear/ssh_host_${keytype}_key" >> "${RS_DDC}" done +# Install patched dhclient-script with RFC 3442 fix +# Dracut's dhclient-script has a buggy parse_option_121() that doesn't validate +# arguments, causing errors on networks like Hetzner. This installs a patched version. +# We use a dracut module with prefix 30 to load BEFORE 35network-legacy. +RS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "${RS_SCRIPT_DIR}/network-hooks/dhclient-script.patched" ]; then + # Copy patched script to sbin directory (mounted at /sbin/dhclient-script in container) + mkdir -p "${BUILD_DIR}/sbin" + cp "${RS_SCRIPT_DIR}/network-hooks/dhclient-script.patched" "${BUILD_DIR}/sbin/dhclient-script" + chmod 755 "${BUILD_DIR}/sbin/dhclient-script" + + # Copy the dracut module (prefix 30 loads before 35network-legacy) + mkdir -p "${BUILD_DIR}/dracut-modules/30rfc3442fix" + cp "${RS_SCRIPT_DIR}/dracut-modules/30rfc3442fix/module-setup.sh" \ + "${BUILD_DIR}/dracut-modules/30rfc3442fix/" + + # Add dracut config to load our module + RS_NETFIX="${BUILD_DIR}/dracut.conf.d/network-fix.conf" + cat > "${RS_NETFIX}" <<-'EOF' + # Load rfc3442fix module (installs patched dhclient-script before network-legacy) + add_dracutmodules+=" rfc3442fix " +EOF + echo "Patched dhclient-script installed" +fi + ## ZBM BUILDING # Separate arguemnts into those for the helper and those for the container @@ -156,6 +252,12 @@ for _arg in "$@"; do fi done -"${ZBM_BUILDER}" "${HELPER_ARGS[@]}" -b "${BUILD_DIR}" \ +# Build using local ZFSBootMenu source tree (includes SSH timeout feature) +ZBM_SOURCE_DIR="$(cd "$(dirname "${ZBM_BUILDER}")" && pwd)" + +"${ZBM_BUILDER}" "${HELPER_ARGS[@]}" -b "${BUILD_DIR}" -l "${ZBM_SOURCE_DIR}" \ -O -v -O "${BUILD_DIR}/cmdline.d:/etc/cmdline.d:ro" \ + -O -v -O "${BUILD_DIR}/sbin/dhclient-script:/sbin/dhclient-script:ro" \ + -O -v -O "${BUILD_DIR}/dracut-modules/30rfc3442fix:/usr/lib/dracut/modules.d/30rfc3442fix:ro" \ -- "${BUILDER_ARGS[@]}" -p dracut-crypt-ssh -p dropbear + diff --git a/contrib/zbm-repack.sh b/contrib/zbm-repack.sh new file mode 100755 index 00000000..d3a9e1e5 --- /dev/null +++ b/contrib/zbm-repack.sh @@ -0,0 +1,695 @@ +#!/bin/bash +# zbm-repack.sh - Repackage ZFSBootMenu image with SSH keys +# +# This script takes an existing ZFSBootMenu initramfs image and repackages it +# with SSH keys for remote access. Use remote-ssh-build.sh for building new +# images with SSH support - this script is for repacking existing images. +# +# Usage: sudo ./zbm-repack.sh [options] +# +# Options: +# -i, --input Input ZFSBootMenu initramfs/EFI (required) +# -o, --output Output file (default: input with .repack suffix) +# -k, --ssh-key SSH authorized_keys file (default: auto-detect) +# -H, --host-keys Directory with SSH host keys (default: /etc/ssh or /etc/dropbear) +# -h, --help Show this help message +# +# The script will: +# 1. Extract the initramfs from the image +# 2. Copy/convert SSH host keys for dropbear +# 3. Add user's SSH public key for authentication +# 4. Repack the image +# +# Requirements: +# - cpio (for initramfs extraction/repacking) +# - zstd or gzip (depending on initramfs compression) +# - objcopy (for EFI bundle manipulation, usually in binutils) +# +# For SSH host key conversion (at least one of): +# - openssl + xxd (embedded converter - usually pre-installed) +# - Pre-generated dropbear keys (use -H /path/to/dropbear-keys) + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ===== Embedded SSH to Dropbear Key Converter ===== +# Converts OpenSSH private keys to dropbear format using OpenSSL and bash +# Supports: RSA, ECDSA (nistp256/384/521), Ed25519 + +# Write a length-prefixed string in dropbear format (big-endian length + data) +_dropbear_write_string() { + local data="$1" + local len=${#data} + printf "\\x$(printf '%02x' $((len >> 24 & 0xff)))" + printf "\\x$(printf '%02x' $((len >> 16 & 0xff)))" + printf "\\x$(printf '%02x' $((len >> 8 & 0xff)))" + printf "\\x$(printf '%02x' $((len & 0xff)))" + printf '%s' "$data" +} + +# Write a length-prefixed binary blob from hex +_dropbear_write_binary() { + local hex="$1" + hex=$(echo "$hex" | tr -d ' :\n') + local len=$((${#hex} / 2)) + printf "\\x$(printf '%02x' $((len >> 24 & 0xff)))" + printf "\\x$(printf '%02x' $((len >> 16 & 0xff)))" + printf "\\x$(printf '%02x' $((len >> 8 & 0xff)))" + printf "\\x$(printf '%02x' $((len & 0xff)))" + echo -n "$hex" | xxd -r -p +} + +# Write a multi-precision integer (with leading zero if high bit set) +_dropbear_write_mpint() { + local hex="$1" + hex=$(echo "$hex" | tr -d ' :\n' | tr 'A-F' 'a-f') + # Pad to even length + if [[ $((${#hex} % 2)) -eq 1 ]]; then + hex="0$hex" + fi + # Remove leading zero bytes but keep at least one byte + while [[ ${#hex} -gt 2 && "${hex:0:2}" == "00" ]]; do + hex="${hex:2}" + done + # Add leading zero if high bit is set (to indicate positive number) + local first_byte=$((16#${hex:0:2})) + if [[ $((first_byte & 0x80)) -ne 0 ]]; then + hex="00$hex" + fi + _dropbear_write_binary "$hex" +} + +# Convert RSA key to dropbear format +_convert_rsa_to_dropbear() { + local input="$1" + local output="$2" + local tempdir=$(mktemp -d) + trap "rm -rf $tempdir" RETURN + + if grep -q "OPENSSH PRIVATE KEY" "$input"; then + cp "$input" "$tempdir/key" + chmod 600 "$tempdir/key" + ssh-keygen -p -m PEM -N "" -f "$tempdir/key" >/dev/null 2>&1 + input="$tempdir/key" + fi + + local text=$(openssl rsa -in "$input" -text -noout 2>/dev/null) + [[ -z "$text" ]] && return 1 + + local n=$(echo "$text" | awk '/^modulus:$/,/^publicExponent:/' | grep -v '^modulus:' | grep -v '^publicExponent:' | tr -d ' \n:') + local e=$(echo "$text" | grep "^publicExponent:" | sed 's/.*0x\([0-9a-fA-F]*\).*/\1/') + local d=$(echo "$text" | awk '/^privateExponent:$/,/^prime1:/' | grep -v '^privateExponent:' | grep -v '^prime1:' | tr -d ' \n:') + local p=$(echo "$text" | awk '/^prime1:$/,/^prime2:/' | grep -v '^prime1:' | grep -v '^prime2:' | tr -d ' \n:') + local q=$(echo "$text" | awk '/^prime2:$/,/^exponent1:/' | grep -v '^prime2:' | grep -v '^exponent1:' | tr -d ' \n:') + + [[ -z "$n" || -z "$e" || -z "$d" || -z "$p" || -z "$q" ]] && return 1 + + { _dropbear_write_string "ssh-rsa"; _dropbear_write_mpint "$e"; _dropbear_write_mpint "$n"; _dropbear_write_mpint "$d"; _dropbear_write_mpint "$p"; _dropbear_write_mpint "$q"; } > "$output" +} + +# Convert ECDSA key to dropbear format +_convert_ecdsa_to_dropbear() { + local input="$1" + local output="$2" + local tempdir=$(mktemp -d) + trap "rm -rf $tempdir" RETURN + + if grep -q "OPENSSH PRIVATE KEY" "$input"; then + cp "$input" "$tempdir/key" + chmod 600 "$tempdir/key" + ssh-keygen -p -m PEM -N "" -f "$tempdir/key" >/dev/null 2>&1 + input="$tempdir/key" + fi + + local text=$(openssl ec -in "$input" -text -noout 2>/dev/null) + local curve_oid=$(echo "$text" | grep "ASN1 OID:" | awk '{print $3}') + + local curve_name curve_size + case "$curve_oid" in + prime256v1|secp256r1) curve_name="nistp256"; curve_size=32 ;; + secp384r1) curve_name="nistp384"; curve_size=48 ;; + secp521r1) curve_name="nistp521"; curve_size=66 ;; + *) return 1 ;; + esac + + local key_type="ecdsa-sha2-$curve_name" + local pub=$(echo "$text" | sed -n '/^pub:$/,/^ASN1/p' | grep -v '^pub:' | grep -v '^ASN1' | tr -d ' \n:') + local priv=$(echo "$text" | sed -n '/^priv:$/,/^pub:/p' | grep -v '^priv:' | grep -v '^pub:' | tr -d ' \n:') + + while [[ ${#priv} -lt $((curve_size * 2)) ]]; do + priv="00$priv" + done + + { _dropbear_write_string "$key_type"; _dropbear_write_string "$curve_name"; _dropbear_write_binary "$pub"; _dropbear_write_binary "$priv"; } > "$output" +} + +# Convert Ed25519 key to dropbear format +_convert_ed25519_to_dropbear() { + local input="$1" + local output="$2" + + grep -q "OPENSSH PRIVATE KEY" "$input" 2>/dev/null || return 1 + + local b64=$(grep -v '^-' "$input" | tr -d '\n') + local raw_hex=$(echo "$b64" | base64 -d | xxd -p | tr -d '\n') + local magic="6f70656e7373682d6b65792d763100" + + [[ "${raw_hex:0:${#magic}}" != "$magic" ]] && return 1 + + local pos=${#magic} + _read_len() { printf '%d' "0x${raw_hex:$1:8}"; } + + # Skip ciphername, kdfname, kdfoptions + local len=$(_read_len $pos); pos=$((pos + 8 + len * 2)) + len=$(_read_len $pos); pos=$((pos + 8 + len * 2)) + len=$(_read_len $pos); pos=$((pos + 8 + len * 2)) + pos=$((pos + 8)) # Skip number of keys + len=$(_read_len $pos); pos=$((pos + 8 + len * 2)) # Skip public key blob + len=$(_read_len $pos); pos=$((pos + 8)) # Private section + pos=$((pos + 16)) # Skip checkints + + len=$(_read_len $pos); pos=$((pos + 8 + len * 2)) # Skip keytype + len=$(_read_len $pos); pos=$((pos + 8)) + local pubkey_hex="${raw_hex:$pos:$((len * 2))}"; pos=$((pos + len * 2)) + len=$(_read_len $pos); pos=$((pos + 8)) + local seed_hex="${raw_hex:$pos:64}" + + { _dropbear_write_string "ssh-ed25519"; _dropbear_write_binary "$pubkey_hex"; _dropbear_write_binary "$seed_hex"; } > "$output" +} + +# Detect SSH key type +_detect_ssh_key_type() { + local input="$1" + if grep -q "RSA PRIVATE KEY" "$input" 2>/dev/null; then + echo "rsa" + elif grep -q "EC PRIVATE KEY" "$input" 2>/dev/null; then + echo "ecdsa" + elif grep -q "OPENSSH PRIVATE KEY" "$input" 2>/dev/null; then + local content=$(cat "$input") + if echo "$content" | base64 -d 2>/dev/null | grep -q "ssh-ed25519"; then + echo "ed25519" + elif echo "$content" | base64 -d 2>/dev/null | grep -q "ssh-rsa"; then + echo "rsa" + elif echo "$content" | base64 -d 2>/dev/null | grep -q "ecdsa-sha2"; then + echo "ecdsa" + else + local keytype=$(ssh-keygen -l -f "$input" 2>/dev/null | awk '{print $NF}' | tr -d '()') + case "$keytype" in + RSA) echo "rsa" ;; ECDSA) echo "ecdsa" ;; ED25519) echo "ed25519" ;; *) echo "unknown" ;; + esac + fi + else + echo "unknown" + fi +} + +# Convert OpenSSH key to dropbear format (main entry point) +convert_ssh_to_dropbear() { + local input="$1" + local output="$2" + + [[ ! -f "$input" ]] && return 1 + command -v openssl >/dev/null 2>&1 || return 1 + command -v xxd >/dev/null 2>&1 || return 1 + + local keytype=$(_detect_ssh_key_type "$input") + case "$keytype" in + rsa) _convert_rsa_to_dropbear "$input" "$output" ;; + ecdsa) _convert_ecdsa_to_dropbear "$input" "$output" ;; + ed25519) _convert_ed25519_to_dropbear "$input" "$output" ;; + *) return 1 ;; + esac +} +# ===== End Embedded SSH to Dropbear Key Converter ===== + +usage() { + cat << EOF +Usage: $(basename "$0") [options] + +Repackage ZFSBootMenu image with SSH keys for remote access. + +Options: + -i, --input Input ZFSBootMenu initramfs or EFI bundle (required) + -o, --output Output file (default: input with .repack suffix) + -k, --ssh-key SSH authorized_keys file (default: auto-detect from ~/.ssh/) + -H, --host-keys Directory with SSH host keys (default: /etc/ssh or /etc/dropbear) + -h, --help Show this help message + +Examples: + # Repack with auto-detected SSH keys + sudo ./zbm-repack.sh -i /boot/efi/EFI/zbm/vmlinuz.EFI + + # Repack with specific SSH key + sudo ./zbm-repack.sh -i vmlinuz.EFI -k ~/.ssh/authorized_keys + + # Use pre-generated dropbear keys (no conversion needed) + sudo ./zbm-repack.sh -i vmlinuz.EFI -H /path/to/dropbear-keys + +SSH Host Keys: + The script converts OpenSSH keys to dropbear format using the embedded + converter (requires openssl + xxd, usually pre-installed). + + Alternatively, pre-generate dropbear keys directly (no conversion needed): + mkdir -p /etc/dropbear-zbm + dropbearkey -t ed25519 -f /etc/dropbear-zbm/dropbear_ed25519_host_key + dropbearkey -t ecdsa -f /etc/dropbear-zbm/dropbear_ecdsa_host_key + dropbearkey -t rsa -s 4096 -f /etc/dropbear-zbm/dropbear_rsa_host_key + sudo ./zbm-repack.sh -i vmlinuz.EFI -H /etc/dropbear-zbm +EOF + exit 0 +} + +# Parse arguments +INPUT_FILE="" +OUTPUT_FILE="" +SSH_KEY_FILE="" +HOST_KEYS_DIR="" + +while [[ $# -gt 0 ]]; do + case $1 in + -i|--input) + INPUT_FILE="$2" + shift 2 + ;; + -o|--output) + OUTPUT_FILE="$2" + shift 2 + ;; + -k|--ssh-key) + SSH_KEY_FILE="$2" + shift 2 + ;; + -H|--host-keys) + HOST_KEYS_DIR="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + print_error "Unknown option: $1" + usage + ;; + esac +done + +# Check root +if [ "$EUID" -ne 0 ]; then + print_error "This script must be run as root" + exit 1 +fi + +# Validate input +if [ -z "$INPUT_FILE" ]; then + print_error "Input file is required. Use -i " + usage +fi + +if [ ! -f "$INPUT_FILE" ]; then + print_error "Input file not found: $INPUT_FILE" + exit 1 +fi + +# Set output file +if [ -z "$OUTPUT_FILE" ]; then + OUTPUT_FILE="${INPUT_FILE%.EFI}.repack.EFI" + OUTPUT_FILE="${OUTPUT_FILE%.img}.repack.img" + if [ "$OUTPUT_FILE" = "$INPUT_FILE" ]; then + OUTPUT_FILE="${INPUT_FILE}.repack" + fi +fi + +print_info "ZFSBootMenu SSH Repack Tool" +print_info "===========================" +print_info "Input: $INPUT_FILE" +print_info "Output: $OUTPUT_FILE" + +# Create temp directory +TEMP_DIR=$(mktemp -d) +INITRAMFS_DIR="$TEMP_DIR/initramfs" +mkdir -p "$INITRAMFS_DIR" + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +# Detect image type and extract +print_info "Detecting image type..." + +IS_EFI=false +IS_BZIMAGE=false +KERNEL_FILE="" + +# Check if it's an EFI bundle (has PE header or specific signature) +if file "$INPUT_FILE" | grep -qE "PE32\+|EFI"; then + IS_EFI=true + print_info "Detected EFI bundle" + + # Extract initramfs from EFI bundle using objcopy + if ! command -v objcopy >/dev/null 2>&1; then + print_error "objcopy not found. Install binutils package." + exit 1 + fi + + # EFI bundles have the initramfs in .initrd section + objcopy -O binary -j .initrd "$INPUT_FILE" "$TEMP_DIR/initramfs.img" 2>/dev/null || { + print_error "Failed to extract initramfs from EFI bundle" + exit 1 + } + + # Also extract kernel for later repacking + objcopy -O binary -j .linux "$INPUT_FILE" "$TEMP_DIR/vmlinuz" 2>/dev/null || { + print_error "Failed to extract kernel from EFI bundle" + exit 1 + } + + # Extract cmdline if present + objcopy -O binary -j .cmdline "$INPUT_FILE" "$TEMP_DIR/cmdline.txt" 2>/dev/null || true + + KERNEL_FILE="$TEMP_DIR/vmlinuz" + INITRAMFS_FILE="$TEMP_DIR/initramfs.img" +elif file "$INPUT_FILE" | grep -q "Linux kernel.*bzImage"; then + IS_BZIMAGE=true + print_info "Detected bzImage with embedded initramfs" + print_warn "bzImage repacking is experimental" + + # Extract embedded initramfs from bzImage + if command -v binwalk >/dev/null 2>&1; then + print_info "Using binwalk to extract initramfs..." + binwalk -e -C "$TEMP_DIR" "$INPUT_FILE" 2>/dev/null || true + + # Find the extracted cpio/initramfs + EXTRACTED_INITRAMFS=$(find "$TEMP_DIR" -name "*.cpio*" -o -name "initramfs*" 2>/dev/null | head -1) + if [ -z "$EXTRACTED_INITRAMFS" ]; then + EXTRACTED_INITRAMFS=$(find "$TEMP_DIR" -type f -size +1M 2>/dev/null | head -1) + fi + + if [ -n "$EXTRACTED_INITRAMFS" ] && [ -f "$EXTRACTED_INITRAMFS" ]; then + cp "$EXTRACTED_INITRAMFS" "$TEMP_DIR/initramfs.img" + INITRAMFS_FILE="$TEMP_DIR/initramfs.img" + else + print_error "Could not find initramfs in binwalk extraction" + exit 1 + fi + else + # Manual extraction: search for compressed cpio signatures + print_info "Searching for embedded initramfs..." + + OFFSET=$(grep -abo $'\x28\xB5\x2F\xFD' "$INPUT_FILE" 2>/dev/null | head -1 | cut -d: -f1) + if [ -n "$OFFSET" ]; then + print_info "Found zstd-compressed initramfs at offset $OFFSET" + dd if="$INPUT_FILE" bs=1 skip="$OFFSET" of="$TEMP_DIR/initramfs.img" 2>/dev/null + else + OFFSET=$(grep -abo $'\x1F\x8B' "$INPUT_FILE" 2>/dev/null | tail -1 | cut -d: -f1) + if [ -n "$OFFSET" ]; then + print_info "Found gzip-compressed initramfs at offset $OFFSET" + dd if="$INPUT_FILE" bs=1 skip="$OFFSET" of="$TEMP_DIR/initramfs.img" 2>/dev/null + else + print_error "Could not locate embedded initramfs" + print_error "Install 'binwalk' for better extraction support: apt install binwalk" + exit 1 + fi + fi + INITRAMFS_FILE="$TEMP_DIR/initramfs.img" + fi + + # Save original for kernel data + cp "$INPUT_FILE" "$TEMP_DIR/original.bzImage" + KERNEL_FILE="$TEMP_DIR/original.bzImage" +else + # Assume it's a plain initramfs + print_info "Detected plain initramfs" + INITRAMFS_FILE="$INPUT_FILE" +fi + +# Detect compression and extract +print_info "Extracting initramfs..." + +cd "$INITRAMFS_DIR" + +# Try different decompression methods +if zstdcat "$INITRAMFS_FILE" 2>/dev/null | cpio -idm 2>/dev/null; then + COMPRESS_CMD="zstd -19" + print_info "Detected zstd compression" +elif gzip -dc "$INITRAMFS_FILE" 2>/dev/null | cpio -idm 2>/dev/null; then + COMPRESS_CMD="gzip -9" + print_info "Detected gzip compression" +elif xz -dc "$INITRAMFS_FILE" 2>/dev/null | cpio -idm 2>/dev/null; then + COMPRESS_CMD="xz -9" + print_info "Detected xz compression" +elif lz4 -dc "$INITRAMFS_FILE" 2>/dev/null | cpio -idm 2>/dev/null; then + COMPRESS_CMD="lz4 -9" + print_info "Detected lz4 compression" +elif cpio -idm < "$INITRAMFS_FILE" 2>/dev/null; then + COMPRESS_CMD="cat" + print_info "Detected uncompressed cpio" +else + print_error "Failed to extract initramfs. Unknown format." + exit 1 +fi + +cd - >/dev/null + +# Verify extraction +if [ ! -d "$INITRAMFS_DIR/usr" ] && [ ! -d "$INITRAMFS_DIR/bin" ]; then + print_error "Extraction seems to have failed - no standard directories found" + exit 1 +fi + +print_info "Initramfs extracted successfully" + +# ===== SSH Host Keys ===== +print_info "Configuring SSH host keys..." + +# The dropbear directory in initramfs where keys should go +DROPBEAR_DIR="$INITRAMFS_DIR/etc/dropbear" +mkdir -p "$DROPBEAR_DIR" + +# Find host keys directory on the running system +if [ -z "$HOST_KEYS_DIR" ]; then + for dir in /etc/dropbear /etc/ssh; do + if [ -d "$dir" ] && ls "$dir"/*key* >/dev/null 2>&1; then + HOST_KEYS_DIR="$dir" + break + fi + done +fi + +if [ -z "$HOST_KEYS_DIR" ]; then + print_warn "No SSH host keys directory found on host" + print_warn "Keeping original keys from the image" +else + print_info "Using host keys from: $HOST_KEYS_DIR" + + # Check if host has dropbear keys (already in correct format) + if [ -f "$HOST_KEYS_DIR/dropbear_ed25519_host_key" ] || \ + [ -f "$HOST_KEYS_DIR/dropbear_ecdsa_host_key" ] || \ + [ -f "$HOST_KEYS_DIR/dropbear_rsa_host_key" ]; then + print_info "Found dropbear-format host keys, copying directly..." + for keytype in ed25519 ecdsa rsa; do + src_key="$HOST_KEYS_DIR/dropbear_${keytype}_host_key" + dst_key="$DROPBEAR_DIR/dropbear_${keytype}_host_key" + if [ -f "$src_key" ]; then + cp "$src_key" "$dst_key" + chmod 600 "$dst_key" + print_info "Copied $keytype host key" + fi + done + else + # Convert OpenSSH keys to dropbear format using embedded converter + if command -v openssl >/dev/null 2>&1 && command -v xxd >/dev/null 2>&1; then + print_info "Converting OpenSSH keys to dropbear format..." + converted=0 + for keytype in ed25519 ecdsa rsa; do + openssh_key="$HOST_KEYS_DIR/ssh_host_${keytype}_key" + dropbear_key="$DROPBEAR_DIR/dropbear_${keytype}_host_key" + + if [ -f "$openssh_key" ]; then + if convert_ssh_to_dropbear "$openssh_key" "$dropbear_key" 2>/dev/null; then + chmod 600 "$dropbear_key" + print_info "Converted $keytype host key" + converted=$((converted + 1)) + else + print_warn "Failed to convert $keytype host key" + fi + fi + done + if [ $converted -eq 0 ]; then + print_warn "No keys were converted, keeping original keys" + fi + else + print_warn "No key conversion method available" + print_warn "Required: openssl and xxd (usually pre-installed)" + print_warn "Keeping original keys from the image" + fi + fi +fi + +# ===== SSH Authorized Keys ===== +print_info "Configuring SSH authorized keys..." + +# Auto-detect SSH key file +if [ -z "$SSH_KEY_FILE" ]; then + for keyfile in /root/.ssh/authorized_keys ~/.ssh/authorized_keys; do + if [ -f "$keyfile" ]; then + SSH_KEY_FILE="$keyfile" + break + fi + done +fi + +# Create authorized_keys in initramfs +AUTH_KEYS_DIR="$INITRAMFS_DIR/root/.ssh" +mkdir -p "$AUTH_KEYS_DIR" +chmod 700 "$AUTH_KEYS_DIR" + +if [ -n "$SSH_KEY_FILE" ] && [ -f "$SSH_KEY_FILE" ]; then + # Prepend command="/bin/zfsbootmenu" to each key so ZBM auto-launches on SSH login + key_count=0 + while IFS= read -r line || [ -n "$line" ]; do + # Skip empty lines and comments + if [ -z "$line" ] || [[ "$line" == \#* ]]; then + echo "$line" >> "$AUTH_KEYS_DIR/authorized_keys" + # Skip lines that already have command= prefix + elif [[ "$line" == command=* ]]; then + echo "$line" >> "$AUTH_KEYS_DIR/authorized_keys" + key_count=$((key_count + 1)) + else + echo "command=\"/bin/zfsbootmenu\" $line" >> "$AUTH_KEYS_DIR/authorized_keys" + key_count=$((key_count + 1)) + fi + done < "$SSH_KEY_FILE" + chmod 600 "$AUTH_KEYS_DIR/authorized_keys" + print_info "Copied $key_count SSH public key(s) from $SSH_KEY_FILE" + print_info "Keys configured to auto-launch ZFSBootMenu on SSH login" +else + print_warn "No SSH authorized_keys file found!" + print_warn "You may not be able to SSH into ZFSBootMenu" +fi + +# ===== Repack initramfs ===== +print_info "Repacking initramfs..." + +cd "$INITRAMFS_DIR" + +# Create cpio archive and compress +find . | cpio -H newc -o 2>/dev/null > "$TEMP_DIR/initramfs.cpio" + +case "$COMPRESS_CMD" in + "zstd -19") + zstd -19 -f -q "$TEMP_DIR/initramfs.cpio" -o "$TEMP_DIR/initramfs.new" + ;; + "gzip -9") + gzip -9 -c "$TEMP_DIR/initramfs.cpio" > "$TEMP_DIR/initramfs.new" + ;; + "xz -9") + xz -9 -c "$TEMP_DIR/initramfs.cpio" > "$TEMP_DIR/initramfs.new" + ;; + "lz4 -9") + lz4 -9 -c "$TEMP_DIR/initramfs.cpio" > "$TEMP_DIR/initramfs.new" + ;; + *) + cp "$TEMP_DIR/initramfs.cpio" "$TEMP_DIR/initramfs.new" + ;; +esac +rm -f "$TEMP_DIR/initramfs.cpio" +cd - >/dev/null + +INITRAMFS_NEW="$TEMP_DIR/initramfs.new" + +# ===== Create output ===== +if [ "$IS_EFI" = true ]; then + print_info "Creating EFI bundle..." + + # Get the EFI stub + EFI_STUB="" + for stub in /usr/lib/systemd/boot/efi/linuxx64.efi.stub \ + /usr/lib/gummiboot/linuxx64.efi.stub \ + /usr/share/systemd/bootctl/linuxx64.efi.stub; do + if [ -f "$stub" ]; then + EFI_STUB="$stub" + break + fi + done + + if [ -z "$EFI_STUB" ]; then + print_error "EFI stub not found. Install systemd-boot or equivalent." + print_info "Saving as plain initramfs instead..." + cp "$INITRAMFS_NEW" "$OUTPUT_FILE" + else + # Read cmdline + CMDLINE="" + if [ -f "$TEMP_DIR/cmdline.txt" ]; then + CMDLINE=$(cat "$TEMP_DIR/cmdline.txt" | tr -d '\0') + fi + + # Create EFI bundle + objcopy \ + --add-section .osrel=/etc/os-release --change-section-vma .osrel=0x20000 \ + --add-section .cmdline=<(echo -n "$CMDLINE") --change-section-vma .cmdline=0x30000 \ + --add-section .linux="$KERNEL_FILE" --change-section-vma .linux=0x2000000 \ + --add-section .initrd="$INITRAMFS_NEW" --change-section-vma .initrd=0x3000000 \ + "$EFI_STUB" "$OUTPUT_FILE" 2>/dev/null || { + print_warn "Failed to create EFI bundle, saving as plain initramfs" + cp "$INITRAMFS_NEW" "${OUTPUT_FILE%.EFI}.img" + OUTPUT_FILE="${OUTPUT_FILE%.EFI}.img" + } + fi +elif [ "$IS_BZIMAGE" = true ]; then + print_warn "bzImage repacking is not fully supported" + print_info "Saving modified initramfs separately..." + + INITRAMFS_OUTPUT="${OUTPUT_FILE%.cpio}.initramfs" + cp "$INITRAMFS_NEW" "$INITRAMFS_OUTPUT" + + print_info "Modified initramfs saved to: $INITRAMFS_OUTPUT" + print_info "" + print_warn "To use with bzImage, you have two options:" + print_info " 1. Load as separate initrd in your bootloader:" + print_info " kernel /vmlinuz-bootmenu" + print_info " initrd $INITRAMFS_OUTPUT" + print_info "" + print_info " 2. Rebuild ZBM with the new config and use the EFI bundle" + + OUTPUT_FILE="$INITRAMFS_OUTPUT" +else + cp "$INITRAMFS_NEW" "$OUTPUT_FILE" +fi + +# Set permissions +chmod 644 "$OUTPUT_FILE" + +# Summary +echo "" +print_info "=== Repack Complete ===" +print_info "Output: $OUTPUT_FILE" +print_info "Size: $(ls -lh "$OUTPUT_FILE" | awk '{print $5}')" +echo "" + +if [ -f "$AUTH_KEYS_DIR/authorized_keys" ]; then + print_info "SSH keys installed: $(wc -l < "$AUTH_KEYS_DIR/authorized_keys") key(s)" +fi + +echo "" +print_info "To use this image:" +if [ "$IS_EFI" = true ]; then + echo " 1. Copy to your ESP: cp $OUTPUT_FILE /boot/efi/EFI/zbm/" + echo " 2. Update your boot configuration if needed" +elif [ "$IS_BZIMAGE" = true ]; then + echo " 1. Keep the original bzImage kernel" + echo " 2. Add the modified initramfs as a separate initrd" + echo " 3. Configure bootloader to load both" +else + echo " 1. Copy to /boot or your boot location" + echo " 2. Update your bootloader configuration" +fi diff --git a/zfsbootmenu/libexec/zfsbootmenu-init b/zfsbootmenu/libexec/zfsbootmenu-init index 3159cd0e..6c9db419 100755 --- a/zfsbootmenu/libexec/zfsbootmenu-init +++ b/zfsbootmenu/libexec/zfsbootmenu-init @@ -56,6 +56,143 @@ esac unset ZFSBOOTMENU_CONSOLE +# SSH connection timeout: wait for SSH login before auto-boot +# If zbm_ssh_timeout is positive, wait that many seconds for an SSH login +# If someone logs in via SSH, the lock file will be created and we skip auto-boot +# shellcheck disable=SC2154 +if [ "${zbm_ssh_timeout:-0}" -gt 0 ]; then + echo "[SSH] Waiting for network..." + + # Wait for network to come up (max 10 seconds) + _net_wait=0 + while [ "${_net_wait}" -lt 10 ]; do + if ip -4 addr show scope global 2>/dev/null | grep -q 'inet '; then + _ip_addr=$( ip -4 addr show scope global 2>/dev/null | grep 'inet ' | head -1 | awk '{print $2}' ) + echo "[SSH] Network up: ${_ip_addr}" + break + fi + echo "[SSH] Waiting for IP... (${_net_wait}/10)" + sleep 1 + _net_wait=$(( _net_wait + 1 )) + done + + if [ "${_net_wait}" -ge 10 ]; then + echo "[SSH] WARN: Network not ready after 10s" + fi + unset _net_wait _ip_addr + + # Check if dropbear is running (SSH server) + if pgrep -x dropbear >/dev/null 2>&1; then + # Get port from config (using sed instead of cut for busybox compatibility) + _db_port=$( sed -n "s/.*dropbear_port='\([^']*\)'.*/\1/p" /etc/crypt-ssh.conf 2>/dev/null ) + echo "[SSH] Dropbear running on port ${_db_port:-222}" + + # Show all interfaces with IPs (to verify multi-NIC) + echo "[SSH] Interfaces with IPs:" + ip -4 addr show scope global 2>/dev/null | grep -E 'inet |^[0-9]+:' | while read line; do + echo "[SSH] $line" + done + + # Check if dropbear is actually listening (try multiple methods) + if command -v ss >/dev/null 2>&1; then + _listen=$( ss -tlnp 2>/dev/null | grep ":${_db_port:-222}" ) + echo "[SSH] Listening: ${_listen:-NOT LISTENING!}" + elif command -v netstat >/dev/null 2>&1; then + _listen=$( netstat -tlnp 2>/dev/null | grep ":${_db_port:-222}" ) + echo "[SSH] Listening: ${_listen:-NOT LISTENING!}" + elif [ -f /proc/net/tcp ]; then + # Fallback: check /proc/net/tcp directly (port in hex, :0016 = 22, :00DE = 222) + _hex_port=$( printf '%04X' "${_db_port:-222}" ) + if grep -q ":${_hex_port} " /proc/net/tcp 2>/dev/null; then + echo "[SSH] Listening: yes (verified via /proc/net/tcp)" + else + echo "[SSH] Listening: NOT FOUND in /proc/net/tcp!" + fi + else + echo "[SSH] Cannot verify listening (no ss/netstat/proc)" + fi + + echo "[SSH] Waiting ${zbm_ssh_timeout}s for SSH connection..." + zinfo "Waiting ${zbm_ssh_timeout}s for SSH connection..." + + # Clean up any stale ssh_connected marker from previous boot + rm -f "${BASE}/ssh_connected" 2>/dev/null || true + + _ssh_waited=0 + _ssh_connected=0 + while [ "${_ssh_waited}" -lt "${zbm_ssh_timeout}" ]; do + # Check if someone has SSH'd in - multiple detection methods: + + # Method 1: Check for active lock file (created when zfsbootmenu runs) + if [ -e "${BASE}/active" ]; then + echo "[SSH] ZBM active file detected!" + zinfo "SSH session detected via active file, cancelling auto-boot timeout" + _ssh_connected=1 + break + fi + + # Method 2: Check for ssh_connected marker + if [ -e "${BASE}/ssh_connected" ]; then + echo "[SSH] SSH connected marker found!" + _ssh_connected=1 + break + fi + + # Method 3: Count dropbear processes (more than 1 means someone connected) + # When a connection comes in, dropbear forks a child process + _db_count=$( pgrep -c -x dropbear 2>/dev/null || echo 0 ) + if [ "${_db_count}" -gt 1 ]; then + echo "[SSH] Login detected! (${_db_count} dropbear processes)" + zinfo "SSH login detected, cancelling auto-boot timeout" + # Create a marker to indicate SSH user has control + : > "${BASE}/ssh_connected" + _ssh_connected=1 + break + fi + + sleep 1 + _ssh_waited=$(( _ssh_waited + 1 )) + + # Show countdown every 5 seconds + if [ $(( _ssh_waited % 5 )) -eq 0 ]; then + echo "[SSH] Waiting... ${_ssh_waited}/${zbm_ssh_timeout}s" + fi + done + + # If SSH user connected, wait until they disconnect (don't run ZBM on console) + if [ "${_ssh_connected}" -eq 1 ]; then + echo "[SSH] User connected via SSH, console waiting..." + echo "[SSH] Console will resume when SSH session ends" + # Wait until the SSH session ends (check both files and dropbear process count) + while true; do + # If active file exists, ZBM is running via SSH + [ -e "${BASE}/active" ] && { sleep 2; continue; } + # If ssh_connected marker exists and more than 1 dropbear, still connected + if [ -e "${BASE}/ssh_connected" ]; then + _db_count=$( pgrep -c -x dropbear 2>/dev/null || echo 0 ) + if [ "${_db_count}" -gt 1 ]; then + sleep 2 + continue + fi + # SSH disconnected, clean up marker + rm -f "${BASE}/ssh_connected" 2>/dev/null || true + fi + # Both files gone, SSH user disconnected + break + done + echo "[SSH] SSH session ended, console resuming control" + elif [ "${_ssh_waited}" -ge "${zbm_ssh_timeout}" ]; then + echo "[SSH] Timeout expired, no SSH connection" + zinfo "SSH timeout expired (${zbm_ssh_timeout}s), stopping dropbear and proceeding with auto-boot" + # Stop dropbear to prevent late SSH connections from interfering with auto-boot + pkill -x dropbear 2>/dev/null || true + fi + unset _ssh_waited _db_count _ssh_connected + else + zinfo "SSH timeout configured but dropbear not running, skipping" + fi +fi + # If BOOTFS is not empty display the fast boot menu # shellcheck disable=SC2154 if [ "${menu_timeout}" -ge 0 ] && [ -n "${BOOTFS}" ]; then diff --git a/zfsbootmenu/pre-init/zfsbootmenu-parse-commandline.sh b/zfsbootmenu/pre-init/zfsbootmenu-parse-commandline.sh index e0342854..d6c8d2b0 100755 --- a/zfsbootmenu/pre-init/zfsbootmenu-parse-commandline.sh +++ b/zfsbootmenu/pre-init/zfsbootmenu-parse-commandline.sh @@ -127,6 +127,23 @@ else zinfo "defaulting menu timeout to ${menu_timeout}" fi +# zbm.ssh_timeout= sets how long to wait for SSH login before auto-boot +# Only relevant when dropbear/SSH is enabled in the image +# shellcheck disable=SC2034 +zbm_ssh_timeout=0 +if ssh_timeout=$( get_zbm_arg zbm.ssh_timeout ) ; then + if [ "${ssh_timeout}" -ge 0 ] >/dev/null 2>&1; then + zbm_ssh_timeout="${ssh_timeout}" + if [ "${zbm_ssh_timeout}" -gt 0 ]; then + zinfo "SSH timeout set to ${zbm_ssh_timeout} seconds" + else + zinfo "SSH timeout disabled" + fi + else + zwarn "invalid SSH timeout: '${ssh_timeout}', disabling SSH timeout" + fi +fi + if zbm_retry_delay=$( get_zbm_arg zbm.retry_delay zbm.import_delay ) && [ "${zbm_retry_delay:-0}" -gt 0 ] 2>/dev/null ; then # Again, this validates that zbm_retry_delay is numeric in addition to logging zinfo "import/waitfor retry delay is ${zbm_retry_delay} seconds" diff --git a/zfsbootmenu/pre-init/zfsbootmenu-preinit.sh b/zfsbootmenu/pre-init/zfsbootmenu-preinit.sh index c7386d6f..2c442b15 100755 --- a/zfsbootmenu/pre-init/zfsbootmenu-preinit.sh +++ b/zfsbootmenu/pre-init/zfsbootmenu-preinit.sh @@ -36,6 +36,7 @@ export zbm_retry_delay='${zbm_retry_delay}' export zbm_hook_root='${zbm_hook_root}' export zbm_wait_for_devices='${zbm_wait_for_devices}' export control_term='${control_term}' +export zbm_ssh_timeout='${zbm_ssh_timeout}' # END additions by zfsbootmenu-preinit.sh EOF