diff --git a/Dockerfile b/Dockerfile index d5f726c..a18ce55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ RUN <&2; } warn () { printf "%b%s%b" "\E[1;31m❯ " "Warning: ${1:-}" "\E[0m\n" >&2; } -is_enabled() { - case "${1:-}" in - Y|y|YES|yes|TRUE|true|1|ON|on) return 0 ;; +strip() { + local value="${1:-}" + + # Remove surrounding whitespace + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + # Remove leading/trailing single/double quotes + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + + # Remove surrounding whitespace again + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf '%s' "$value" +} + +enabled() { + case "$(strip "${1:-}")" in + Y|y|YES|Yes|yes|TRUE|True|true|1|ON|On|on) return 0 ;; + *) return 1 ;; + esac +} + +disabled() { + case "$(strip "${1:-}")" in + N|n|NO|No|no|FALSE|False|false|0|OFF|Off|off) return 0 ;; *) return 1 ;; esac } require_cmd() { + command -v "$1" >/dev/null 2>&1 || { error "Required command not found: $1" exit 21 @@ -30,6 +58,7 @@ require_cmd() { } require_file() { + [ -f "$1" ] || { error "Required file not found: $1" exit 22 @@ -39,9 +68,10 @@ require_file() { } continue_or_exit() { + local code="$1" - if is_enabled "$DEBUG"; then + if enabled "$DEBUG"; then warn "DEBUG is enabled (DEBUG=${DEBUG}), continuing despite previous error." return 0 fi @@ -50,6 +80,7 @@ continue_or_exit() { } check_privileged() { + local cap_bnd local last_cap local max_cap @@ -69,7 +100,8 @@ check_privileged() { } check_fuse() { - if ! is_enabled "$REQUIRE_FUSE"; then + + if disabled "$REQUIRE_FUSE"; then return 0 fi @@ -90,7 +122,7 @@ check_kvm() { local kvm_err="" local flags="" - if ! is_enabled "$REQUIRE_KVM"; then + if disabled "$REQUIRE_KVM"; then return 0 fi @@ -119,6 +151,7 @@ check_kvm() { } check_cgroups() { + if [ ! -d /sys/fs/cgroup ]; then error "/sys/fs/cgroup is missing. systemd inside the container will not work correctly." continue_or_exit 25 @@ -136,6 +169,7 @@ check_cgroups() { } check_systemd_command() { + if [ "$#" -eq 0 ]; then error "No command specified. This image should normally be started with systemd as PID 1." exit 26 @@ -154,6 +188,7 @@ check_systemd_command() { } remount_shm() { + if [ ! -d /dev/shm ]; then warn "/dev/shm does not exist." return 0 @@ -184,6 +219,7 @@ set_timezone() { } check_localtime() { + if [ ! -e /etc/localtime ] && [ ! -L /etc/localtime ]; then return 1 fi @@ -200,6 +236,7 @@ check_localtime() { } prepare_directory() { + local path="$1" local owner="$2" local mode="${3:-}" @@ -215,6 +252,7 @@ prepare_directory() { } cleanup_stale_runtime_files() { + rm -f \ /run/pveproxy/pveproxy.pid \ /run/pvedaemon/pvedaemon.pid \ diff --git a/src/network.sh b/src/network.sh index 1b39974..c1b7298 100644 --- a/src/network.sh +++ b/src/network.sh @@ -5,31 +5,27 @@ set -Eeuo pipefail : "${DEV:=""}" : "${MTU:=""}" +: "${MAC:=""}" : "${TAP:="tap0"}" : "${NETWORK:="Y"}" : "${BRIDGE:="vmbr0"}" : "${MASK:="255.255.255.0"}" +# Sanitize variables +DEV=$(strip "$DEV") +MTU=$(strip "$MTU") +TAP=$(strip "$TAP") +MAC=$(strip "$MAC") +MASK=$(strip "$MASK") +BRIDGE=$(strip "$BRIDGE") +NETWORK=$(strip "$NETWORK") + ADD_ERR="Please add the following setting to your container:" # ###################################### # Generic helpers # ###################################### -enabled() { - case "$(strip "${1:-}")" in - Y|y|YES|Yes|yes|TRUE|True|true|1|ON|On|on) return 0 ;; - *) return 1 ;; - esac -} - -disabled() { - case "$(strip "${1:-}")" in - N|n|NO|No|no|FALSE|False|false|0|OFF|Off|off) return 0 ;; - *) return 1 ;; - esac -} - isNAT() { case "${NETWORK,,}" in @@ -40,6 +36,38 @@ isNAT() { esac } +maskToCIDR() { + + local mask="$1" + local prefix="" + + prefix=$(ipcalc -p 0.0.0.0 "$mask" | awk -F= '/^PREFIX=/ { print $2 }') + + if [[ ! "$prefix" =~ ^[0-9]+$ ]] || (( prefix < 1 || prefix > 30 )); then + error "Invalid MASK: '$mask'" + return 1 + fi + + echo "$prefix" + return 0 +} + +networkCIDR() { + + local ip="$1" + local network="" + + network=$(ipcalc -n "$ip" "$MASK" | awk -F= '/^NETWORK=/ { print $2 }') + + if [ -z "$network" ]; then + error "Failed to calculate network address from IP '$ip' and netmask '$MASK'." + return 1 + fi + + echo "$network/$PREFIX" + return 0 +} + getMTU() { local dev="$1" @@ -85,6 +113,83 @@ setMTU() { return 0 } +gatewayMAC() { + + local mac="$1" + + echo "$mac" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/' +} + +detectInterface() { + + if [ -n "$DEV" ]; then + return 0 + fi + + # Give Kubernetes priority over the default interface + [ -d "/sys/class/net/net0" ] && DEV="net0" + [ -d "/sys/class/net/net1" ] && DEV="net1" + [ -d "/sys/class/net/net2" ] && DEV="net2" + [ -d "/sys/class/net/net3" ] && DEV="net3" + + # Automatically detect the default network interface + [ -z "$DEV" ] && DEV=$(awk '$2 == 00000000 { print $1; exit }' /proc/net/route) + [ -z "$DEV" ] && DEV="eth0" + + return 0 +} + +detectAddresses() { + + GATEWAY=$(ip route list dev "$DEV" | awk ' /^default/ {print $3}' | head -n 1) + { UPLINK=$(ip address show dev "$DEV" | grep inet | awk '/inet / { print $2 }' | cut -f1 -d/ | head -n 1); } 2>/dev/null || : + + IP6="" + + if [ -f /proc/net/if_inet6 ] && [[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" != "1" ]]; then + { IP6=$(ip -6 addr show dev "$DEV" scope global up); rc=$?; } 2>/dev/null || : + (( rc != 0 )) && IP6="" + [ -n "$IP6" ] && IP6=$(echo "$IP6" | sed -e's/^.*inet6 \([^ ]*\)\/.*$/\1/;t;d' | head -n 1) + fi + + return 0 +} + +detectAdapter() { + + local result="" + + NIC="" + BUS="" + + result=$(ethtool -i "$DEV" 2>/dev/null || :) + + NIC=$(grep -m 1 -i 'driver:' <<< "$result" | awk '{print $2}') + BUS=$(grep -m 1 -i 'bus-info:' <<< "$result" | awk '{print $2}') + + return 0 +} + +containerID() { + + local id="" + + id=$(hostname -s 2>/dev/null || true) + + if [ -z "$id" ] && [ -s /etc/machine-id ]; then + id=$(< /etc/machine-id) + fi + + if [ -z "$id" ] && [ -s /proc/sys/kernel/random/boot_id ]; then + id=$(< /proc/sys/kernel/random/boot_id) + fi + + [ -z "$id" ] && id="unknown" + + echo "$id" + return 0 +} + disableIPv6() { local dev="$1" @@ -98,6 +203,30 @@ disableIPv6() { return 0 } +subnetBase() { + + local ip="$1" + local third="" + local second="" + local base="" + local subnet="" + + third=$(cut -d. -f3 <<< "$ip") + + for second in {30..254}; do + base="172.$second.$third" + subnet="$base.0/$PREFIX" + + if ! ip route show "$subnet" 2>/dev/null | grep -q .; then + echo "$base" + return 0 + fi + done + + error "No available VM subnet found in 172.30.$third.0/$PREFIX through 172.254.$third.0/$PREFIX." + return 1 +} + # ###################################### # DNS / interface helpers # ###################################### @@ -105,12 +234,9 @@ disableIPv6() { configureDNS() { local fa="$1" - local ip="$2" - local mask="$3" - local gateway="$4" - local base="${ip%.*}" - local ip_last="${ip##*.}" - local gw_last="${gateway##*.}" + local mask="$2" + local gateway="$3" + local base="${gateway%.*}" local file="/etc/dnsmasq.d/$fa.conf" local mtu_option="" local filter_dns="" @@ -124,27 +250,6 @@ configureDNS() { filter_dns="filter-AAAA" fi - # Reserve both the bridge gateway address and the translated container address. - # The translated address is intentionally excluded from DHCP so it can be used - # later as a stable container/host identity inside the VM subnet. - - # Determine the sorted positions - local low high - if (( ip_last < gw_last )); then - low=$ip_last - high=$gw_last - else - low=$gw_last - high=$ip_last - fi - - # Build dhcp-range lines - local ranges="" - (( low > 1 )) && ranges+="dhcp-range=set:${fa},${base}.1,${base}.$((low - 1))"$'\n' - (( high - low > 1 )) && ranges+="dhcp-range=set:${fa},${base}.$((low + 1)),${base}.$((high - 1))"$'\n' - (( high < 254 )) && ranges+="dhcp-range=set:${fa},${base}.$((high + 1)),${base}.254"$'\n' - ranges="${ranges%$'\n'}" # strip trailing newline - if ! sed 's/^ //' > "$file" < 4 )); then - # Hack for guest VMs complaining about "bad udp checksums in 5 packets" - iptables -t mangle -A POSTROUTING \ - -s "$subnet" \ - -p udp \ - --dport bootpc \ - -m comment --comment "$rule_tag" \ - -j CHECKSUM --checksum-fill > /dev/null 2>&1 || true - fi - # Clamp TCP MSS to avoid subtle MTU blackholes when the outer path has a smaller MTU. iptables -t mangle -A FORWARD \ -s "$subnet" \ @@ -386,26 +480,18 @@ configureNAT() { fi fi - local ip base - base=$(cut -d. -f3,4 <<< "$IP") + local base gateway subnet - if [[ "$IP" != "172.30."* ]]; then - ip="172.30.$base" - else - ip="172.31.$base" + base=$(subnetBase "$UPLINK") || return 1 + gateway="$base.1" + subnet=$(networkCIDR "$gateway") || return 1 + + if ip route show "$subnet" 2>/dev/null | grep -q .; then + error "VM subnet $subnet conflicts with an existing route inside the container." + return 1 fi - local last="${ip##*.}" - - if [[ ! "$last" =~ ^[0-9]+$ ]] || (( last < 2 || last > 254 )); then - ip="${ip%.*}.4" - fi - - local gateway="${ip%.*}.1" - local subnet="${ip%.*}.0/24" - local broadcast="${ip%.*}.255" - - createBridge "$gateway" "$broadcast" || return 1 + createBridge "$gateway" || return 1 createTap "$tuntap" || return 1 # Use the lowest effective VM-LAN MTU, without mutating the parent/uplink MTU. @@ -416,7 +502,7 @@ configureNAT() { configureTables "$subnet" || return 1 setInterfaces "$BRIDGE" "$TAP" "$gateway" || return 1 - configureDNS "$BRIDGE" "$ip" "$MASK" "$gateway" || return 1 + configureDNS "$BRIDGE" "$MASK" "$gateway" || return 1 return 0 } @@ -426,6 +512,7 @@ configureNAT() { # ###################################### clearTables() { + local table="" line rules local rule_tag="remove" local re="--comment[[:space:]]+\"?$rule_tag\"?([[:space:]]|\$)" @@ -478,49 +565,51 @@ closeBridge() { # Detection # ###################################### -getInfo() { - - if [ -z "$DEV" ]; then - # Give Kubernetes priority over the default interface - [ -d "/sys/class/net/net0" ] && DEV="net0" - [ -d "/sys/class/net/net1" ] && DEV="net1" - [ -d "/sys/class/net/net2" ] && DEV="net2" - [ -d "/sys/class/net/net3" ] && DEV="net3" - # Automatically detect the default network interface - [ -z "$DEV" ] && DEV=$(awk '$2 == 00000000 { print $1; exit }' /proc/net/route) - [ -z "$DEV" ] && DEV="eth0" - fi +validateInterface() { if [ ! -d "/sys/class/net/$DEV" ]; then error "Network interface '$DEV' does not exist inside the container!" - error "$ADD_ERR -e \"DEV=NAME\" to specify another interface name." && exit 26 + error "$ADD_ERR -e \"DEV=NAME\" to specify another interface name." + exit 26 fi - GATEWAY=$(ip route list dev "$DEV" | awk ' /^default/ {print $3}' | head -n 1) - { IP=$(ip address show dev "$DEV" | grep inet | awk '/inet / { print $2 }' | cut -f1 -d/ | head -n 1); } 2>/dev/null || : - [ -z "$IP" ] && error "Could not determine container IPv4 address!" && exit 26 + return 0 +} - IP6="" - # shellcheck disable=SC2143 - if [ -f /proc/net/if_inet6 ] && [[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" != "1" ]] && [ -n "$(ifconfig -a | grep inet6)" ]; then - { IP6=$(ip -6 addr show dev "$DEV" scope global up); rc=$?; } 2>/dev/null || : - (( rc != 0 )) && IP6="" - [ -n "$IP6" ] && IP6=$(echo "$IP6" | sed -e's/^.*inet6 \([^ ]*\)\/.*$/\1/;t;d' | head -n 1) +validateMask() { + + PREFIX=$(maskToCIDR "$MASK") || exit 28 + + if [[ "$PREFIX" != "24" ]]; then + error "MASK values other than 255.255.255.0 are not supported by this network layout." + exit 28 fi - local nic="" bus="" result="" - result=$(ethtool -i "$DEV" 2>/dev/null || :) + return 0 +} - nic=$(grep -m 1 -i 'driver:' <<< "$result" | awk '{print $2}') - bus=$(grep -m 1 -i 'bus-info:' <<< "$result" | awk '{print $2}') +validateAddresses() { - if [[ -n "$bus" && "${bus,,}" != "n/a" && "${bus,,}" != "tap" ]]; then - enabled "$DEBUG" && info "Detected NIC: ${nic:-unknown} BUS: $bus" + [ -z "$UPLINK" ] && error "Could not determine container IPv4 address!" && exit 26 + + return 0 +} + +validateAdapter() { + + if [[ -n "$BUS" && "${BUS,,}" != "n/a" && "${BUS,,}" != "tap" ]]; then + enabled "$DEBUG" && info "Detected NIC: ${NIC:-unknown} BUS: $BUS" error "This container does not support host mode networking!" exit 29 fi - local mac mtu="" mtu_custom="N" + return 0 +} + +configureMTU() { + + local mtu="" + local mtu_custom="N" if [ -f "/sys/class/net/$DEV/mtu" ]; then mtu=$(< "/sys/class/net/$DEV/mtu") @@ -538,22 +627,81 @@ getInfo() { LAN_MTU="1500" fi - # Generate MAC address based on Docker container ID in hostname - HOST="$(hostname -s)" - mac=$(echo "$HOST" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/') - GATEWAY_MAC=$(echo "${mac^^}" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/') + return 0 +} - if enabled "$DEBUG"; then - line="Host: $HOST IP: $IP Gateway: $GATEWAY Interface: $DEV MTU: $mtu" - [[ "$MTU" != "0" && "$MTU" != "$mtu" ]] && line+=" ($MTU)" - info "$line" - if [ -f /etc/resolv.conf ]; then - nameservers=$(grep '^nameserver ' /etc/resolv.conf | sed 's/^nameserver //' | paste -sd ',' | sed 's/,/, /g') - [ -n "$nameservers" ] && info "Nameservers: $nameservers" - fi - echo +configureMAC() { + + local container="" + + container=$(containerID) + + if [ -z "$MAC" ]; then + # Generate a MAC address based on a stable container identifier when possible. + MAC=$(echo "$container" | md5sum | sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/') fi + MAC="${MAC,,}" + MAC="${MAC//-/:}" + + if [[ ${#MAC} == 12 ]]; then + local m="$MAC" + MAC="${m:0:2}:${m:2:2}:${m:4:2}:${m:6:2}:${m:8:2}:${m:10:2}" + fi + + if [[ ${#MAC} != 17 ]]; then + error "Invalid MAC address: '$MAC', should be 12 or 17 digits long!" + exit 28 + fi + + # Keep the guest-facing gateway MAC stable across runs, otherwise Windows guests + # may detect a new network every boot. + GATEWAY_MAC=$(gatewayMAC "$MAC") + + return 0 +} + +printNetworkDebug() { + + local line="" + local host="" + local nameservers="" + + enabled "$DEBUG" || return 0 + + host=$(hostname -s 2>/dev/null || true) + [ -z "$host" ] && host="unknown" + + line="Host: $host IP: $UPLINK Gateway: $GATEWAY Interface: $DEV MAC: $MAC MTU: $MTU Mask: $MASK/$PREFIX" + info "$line" + + if [ -f /etc/resolv.conf ]; then + nameservers=$(grep '^nameserver ' /etc/resolv.conf | sed 's/^nameserver //' | paste -sd ',' | sed 's/,/, /g') + [ -n "$nameservers" ] && info "Nameservers: $nameservers" + fi + + echo + return 0 +} + +prepareNetwork() { + + detectInterface + validateInterface + + validateMask + + detectAddresses + validateAddresses + + detectAdapter + validateAdapter + + configureMTU + configureMAC + + printNetworkDebug + return 0 } @@ -574,10 +722,15 @@ blockLicense disabled "$NETWORK" && return 0 +if ! isNAT; then + error "Unrecognized NETWORK value: \"$NETWORK\"" + exit 48 +fi + msg="Initializing network..." enabled "$DEBUG" && info "$msg" -getInfo +prepareNetwork closeBridge # Configure NAT networking