#!/bin/bash

# create-network-tshoot-livecd — Build a distro-independent LIVE ISO for boot-only
# cabling diagnostics.
#
# This tool runs on a plain Debian/Ubuntu BUILD HOST (NOT on a PVE node) and
# uses live-build (lb) to produce a hybrid (UEFI+BIOS) live ISO.  It has no
# dependency on Proxmox VE, Ceph, libguestfs or the qemu guest agent — it is a
# self-contained, re-runnable image builder.
#
# What the resulting ISO does when booted on any server:
#
#   1. Identifies the physical host via DMI serial number and sets the hostname
#      from a CSV inventory (serial,hostname,bond_members,ip).  The hostname is
#      ALWAYS set on a serial match, even when no IP is supplied.
#   2. Brings up every physical NIC and starts lldpd for neighbour discovery.
#   3. (Optional) Configures a bonded VLAN management interface using the
#      per-host IP from the CSV and shared network parameters (mask, gateway,
#      DNS) passed as CLI arguments.
#   4. Auto-runs the canonical  nic-xray --all --output csv  as the
#      authoritative machine-readable artifact and renders a human per-NIC
#      LLDP table.  Both are echoed to VGA (tty1) AND every auto-discovered
#      serial console (iDRAC-SOL / BMC) and saved to a tmpfs report.
#   5. Drops to an interactive root shell (VGA + serial) with nic-xray,
#      lldpcli, tcpdump and friends on PATH.
#
# READ-ONLY / BOOT-ONLY: a normal boot leaves every block device byte-for-byte
# untouched.  GPT auto-activation and swap auto-on are suppressed on the kernel
# cmdline (systemd.gpt_auto=0 rd.systemd.gpt_auto=0 noswap) so nothing is ever
# written to the target disks.  The image writes only to tmpfs (/run).
#
# nic-xray (and its deps) come from the upstream OBS repo
# home:ciriarte:network-tools, pulled from the exact-match tree for the chosen
# base:  Debian_13 (default) / Debian_12 / Ubuntu_24.04 / Ubuntu_22.04.  The
# repo key is fetched over TLS and installed via the signed-by keyring pattern
# (never gpgcheck-off).
#
# Prerequisites (on the build host):
#   - live-build (provides  lb )        →  apt install live-build
#   - xorriso                           →  apt install xorriso
#   - curl and gpg                      →  apt install curl gpg
#   - root privileges for the actual  lb build  step
#
# See --help for full usage.

VERSION="1.1.2"

set -euo pipefail

# --- Colours (disabled when stdout is not a terminal) -------------------------

if [[ -t 1 ]]; then
    # ANSI-C quoting ($'...') embeds real ESC bytes so the sequences render in
    # both `echo -e` helpers and plain `cat` heredocs (e.g. usage()).
    C_RED=$'\033[0;31m'
    C_GREEN=$'\033[0;32m'
    C_YELLOW=$'\033[0;33m'
    C_CYAN=$'\033[0;36m'
    C_BOLD=$'\033[1m'
    C_RESET=$'\033[0m'
else
    C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_BOLD='' C_RESET=''
fi

# --- Helpers ------------------------------------------------------------------

msg()  { echo -e "${C_BOLD}${C_CYAN}::${C_RESET} $*"; }
ok()   { echo -e "   ${C_GREEN}[+]${C_RESET} $*"; }
warn() { echo -e "   ${C_YELLOW}[!]${C_RESET} $*"; }
err()  { echo -e "   ${C_RED}[-]${C_RESET} $*" >&2; }
die()  { err "$@"; exit 1; }

# Convert a dotted-decimal netmask (255.255.255.0) or bare prefix (24) to
# CIDR notation (/24).  Input already in /NN form is returned unchanged.
normalize_mask() {
    local mask="$1"
    [[ "$mask" =~ ^/[0-9]+$ ]] && { echo "$mask"; return; }
    if [[ "$mask" =~ ^[0-9]+$ ]] && (( mask >= 0 && mask <= 32 )); then
        echo "/$mask"; return
    fi
    # Dotted decimal → count set bits
    local IFS='.' bits=0
    local -a octets=($mask)
    for o in "${octets[@]}"; do
        case "$o" in
            255) (( bits += 8 )) ;; 254) (( bits += 7 )) ;; 252) (( bits += 6 )) ;;
            248) (( bits += 5 )) ;; 240) (( bits += 4 )) ;; 224) (( bits += 3 )) ;;
            192) (( bits += 2 )) ;; 128) (( bits += 1 )) ;; 0)   ;;
            *)   die "Invalid netmask: $mask" ;;
        esac
    done
    echo "/$bits"
}

# --- Defaults -----------------------------------------------------------------

CSV_FILE=""                  # serial,hostname,bond_members,ip CSV  (required)
OUTPUT_DIR=""                # where to write the ISO (default: $PWD)
BASE="debian"                # debian | ubuntu
DIST=""                      # base version (default: 13 debian / 24.04 ubuntu)
MIRROR=""                    # build-time apt mirror override (default: distro archive)
LLDP_WAIT=45                 # seconds to poll for LLDP neighbours after link-up
INCLUDE_SWITCH_XRAY=0        # 1 = also install the sibling switch-xray package
SERIAL_UNIT=0                # serial console unit for kernel + bootloader (ttyS<N>)
KEEP_BUILD=0                 # 1 = keep the live-build config tree on exit

# Target-host network (embedded in the ISO, applied on boot when an IP exists)
BOND_MODE="802.3ad"          # bonding mode
VLAN_ID=""                   # management VLAN ID
NETMASK=""                   # prefix length or dotted mask (e.g. /24)
GATEWAY=""                   # default gateway
DNS=""                       # comma-separated nameservers
PROXY=""                     # HTTP/HTTPS proxy URL

# Internal state (set during execution)
BUILD_DIR=""                 # live-build config tree (temp)
CODENAME=""                  # debian/ubuntu suite codename (trixie, noble, ...)
LB_MODE=""                   # live-build --mode  (debian | ubuntu)
OBS_TREE=""                  # OBS exact-match tree (Debian_13, Ubuntu_24.04, ...)
MIRROR_SECURITY=""           # security mirror URL (set per base in resolve_base)

# The OBS repository base URL (upstream, publishes nic-xray as Architecture: all)
OBS_BASE="https://download.opensuse.org/repositories/home:/ciriarte:/network-tools"

# --- Cleanup ------------------------------------------------------------------

cleanup() {
    local rc=$?
    if [[ -n "$BUILD_DIR" && -d "$BUILD_DIR" ]]; then
        if (( KEEP_BUILD )); then
            warn "Build tree retained (--keep-build): $BUILD_DIR"
        else
            rm -rf "$BUILD_DIR"
        fi
    fi
    return $rc
}
trap cleanup EXIT

# --- Base / OBS tree resolution -----------------------------------------------

# Map --base + --dist to the live-build suite codename, the lb --mode, and the
# exact OBS tree name.  Fails closed on any unknown combination.
resolve_base() {
    case "$BASE" in
        debian)
            LB_MODE="debian"
            [[ -z "$DIST" ]] && DIST="13"
            case "$DIST" in
                13) CODENAME="trixie"  ;;
                12) CODENAME="bookworm" ;;
                *)  die "Unsupported Debian version: $DIST (known: 12, 13)" ;;
            esac
            OBS_TREE="Debian_${DIST}"
            [[ -z "$MIRROR" ]] && MIRROR="http://deb.debian.org/debian"
            MIRROR_SECURITY="http://security.debian.org/debian-security"
            ;;
        ubuntu)
            LB_MODE="ubuntu"
            [[ -z "$DIST" ]] && DIST="24.04"
            case "$DIST" in
                24.04) CODENAME="noble" ;;
                22.04) CODENAME="jammy" ;;
                *)     die "Unsupported Ubuntu version: $DIST (known: 22.04, 24.04)" ;;
            esac
            OBS_TREE="Ubuntu_${DIST}"
            [[ -z "$MIRROR" ]] && MIRROR="http://archive.ubuntu.com/ubuntu"
            MIRROR_SECURITY="http://security.ubuntu.com/ubuntu"
            ;;
        *)
            die "Unsupported --base: $BASE (use 'debian' or 'ubuntu')"
            ;;
    esac
}

# --- CSV validation (mirrors the pve-tools tshoot-image tool) -------------------------

validate_csv() {
    local file="$1"
    [[ -f "$file" ]] || die "CSV file not found: $file"

    local ipv4_re='^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'
    local iface_re='^[a-zA-Z][a-zA-Z0-9._-]*(:[a-zA-Z][a-zA-Z0-9._-]*)*$'
    local line line_no=0 data_lines=0
    while IFS= read -r line || [[ -n "$line" ]]; do
        (( line_no++ )) || true
        [[ "$line" =~ ^[[:space:]]*# ]] && continue
        [[ -z "${line// /}" ]]           && continue
        (( data_lines++ )) || true

        local nf
        nf=$(awk -F, '{print NF}' <<< "$line")
        (( nf >= 4 )) || die "CSV line ${line_no}: expected 'serial,hostname,bond_members,ip', got: $line"

        # Validate bond members (3rd field — colon-separated interface names).
        # Optional: an empty value is allowed (hostname-only host, no bond).
        local members
        members=$(awk -F, '{gsub(/[[:space:]]/,"",$3); print $3}' <<< "$line")
        [[ -z "$members" || "$members" =~ $iface_re ]] || \
            die "CSV line ${line_no}: invalid bond members '$members' (use iface1:iface2 format)"

        # Validate IP address (4th field).
        # Optional: an empty value is allowed — hostname is always set, IP is not.
        local ip
        ip=$(awk -F, '{gsub(/[[:space:]]/,"",$4); print $4}' <<< "$line")
        [[ -z "$ip" || "$ip" =~ $ipv4_re ]] || \
            die "CSV line ${line_no}: invalid IP address '$ip'"
    done < "$file"

    (( data_lines > 0 )) || die "CSV file contains no data lines: $file"
    ok "CSV validated: ${data_lines} host mapping(s)"
}

# Validate target-host network params — either all provided or none.
validate_target_network() {
    if [[ -z "$NETMASK" && -z "$GATEWAY" && -z "$DNS" ]]; then
        return 0
    fi
    local missing=""
    [[ -z "$NETMASK" ]] && missing+="--netmask "
    [[ -z "$GATEWAY" ]] && missing+="--gateway "
    [[ -z "$DNS" ]]     && missing+="--dns "
    [[ -n "$missing" ]] && die "Target network: if any of --netmask/--gateway/--dns are set, all three are required. Missing: ${missing}"
    NETMASK=$(normalize_mask "$NETMASK")
    ok "Target network parameters validated"
}

# --- Preflight ----------------------------------------------------------------

# Verify build-host tooling, assert this is NOT a PVE node, and TLS-probe the
# exact OBS tree Release.key so the build fails closed if the repo is missing.
preflight() {
    msg "Preflight checks (base=${BASE}, dist=${DIST}, tree=${OBS_TREE})..."

    # This tool builds on a PLAIN Debian/Ubuntu host — never on a PVE node.
    if [[ -d /etc/pve ]] || command -v pvesh &>/dev/null || command -v qm &>/dev/null; then
        die "This appears to be a Proxmox VE node. create-network-tshoot-livecd runs on a plain Debian/Ubuntu BUILD host, not on a PVE node."
    fi

    # live-build (lb) is the ISO toolchain.
    command -v lb &>/dev/null || \
        die "live-build not found — install it with: apt install live-build"
    ok "live-build: $(command -v lb)"

    # xorriso builds the hybrid ISO image.
    command -v xorriso &>/dev/null || \
        die "xorriso not found — install it with: apt install xorriso"
    ok "xorriso: $(command -v xorriso)"

    # curl + gpg are needed to fetch and dearmor the OBS repo key over TLS.
    command -v curl &>/dev/null || \
        die "curl not found — install it with: apt install curl"
    command -v gpg &>/dev/null || \
        die "gpg not found — install it with: apt install gpg"
    ok "curl + gpg present"

    # TLS-probe the exact OBS tree key URL and fail closed if unreachable.
    local key_url="${OBS_BASE}/${OBS_TREE}/Release.key"
    msg "Probing OBS tree over TLS: ${key_url}"
    if curl -fsSL --connect-timeout 15 --max-time 60 -o /dev/null "$key_url"; then
        ok "OBS tree reachable — nic-xray will be pulled from ${OBS_TREE}"
    else
        die "OBS tree unreachable or missing: ${key_url}
   Published trees: Debian_12, Debian_13, Ubuntu_22.04, Ubuntu_24.04.
   Check --base/--dist and network reachability to download.opensuse.org."
    fi
}

# --- live-build config tree generation ----------------------------------------

# Emit the complete lb config tree under $BUILD_DIR/config:
#   package-lists, OBS archive (signed-by keyring), chroot runtime files,
#   systemd unit, autologin drop-ins, embedded CSV + network params, hooks.
build_config() {
    BUILD_DIR=$(mktemp -d /tmp/network-tshoot-livecd-XXXXXX)
    msg "Generating live-build config tree in ${BUILD_DIR}..."

    local cfg="${BUILD_DIR}/config"
    mkdir -p \
        "${cfg}/package-lists" \
        "${cfg}/archives" \
        "${cfg}/hooks/normal" \
        "${cfg}/includes.chroot/usr/local/sbin" \
        "${cfg}/includes.chroot/etc/tshoot" \
        "${cfg}/includes.chroot/etc/apt/keyrings" \
        "${cfg}/includes.chroot/etc/apt/sources.list.d" \
        "${cfg}/includes.chroot/etc/lldpd.d" \
        "${cfg}/includes.chroot/etc/systemd/system/getty@tty1.service.d" \
        "${cfg}/includes.chroot/etc/systemd/system/serial-getty@.service.d" \
        "${cfg}/includes.chroot/etc/profile.d"

    # ---- package list --------------------------------------------------------
    {
        echo "# Cabling-diagnostics live image — generated by create-network-tshoot-livecd"
        echo "lldpd"
        echo "tcpdump"
        echo "dmidecode"
        echo "ethtool"
        echo "pciutils"
        echo "iproute2"
        echo "iputils-ping"
        # Bonding + VLAN are done via iproute2 (ip link add type bond/vlan) and
        # the in-kernel bonding/8021q modules, so the obsolete vlan/ifenslave
        # packages are intentionally NOT installed (they are also gone from
        # modern Ubuntu/Debian archives).
        echo "ca-certificates"
        echo "nic-xray"
        (( INCLUDE_SWITCH_XRAY )) && echo "switch-xray"
    } > "${cfg}/package-lists/tshoot.list.chroot"
    ok "Package list written"

    # ---- OBS apt repo (signed-by keyring pattern, fetched over TLS) ----------
    generate_obs_archive "$cfg"

    # ---- embedded operator CSV ----------------------------------------------
    cp "$CSV_FILE" "${cfg}/includes.chroot/etc/tshoot/hosts.csv"

    # ---- shared network params (same shape as the reference tool) -----------
    generate_target_network_conf > "${cfg}/includes.chroot/etc/tshoot/target-network.conf"

    # ---- diag runtime config (build-time tunables) --------------------------
    {
        echo "# Runtime tunables — generated by create-network-tshoot-livecd"
        echo "LLDP_WAIT=${LLDP_WAIT}"
    } > "${cfg}/includes.chroot/etc/tshoot/diag.conf"

    # ---- lldpd: enable on all interfaces, snappier TX for diagnostics -------
    {
        echo "# lldpd config — generated by create-network-tshoot-livecd"
        echo "configure lldp tx-interval 5"
        echo "configure system interface pattern *"
    } > "${cfg}/includes.chroot/etc/lldpd.d/10-tshoot.conf"

    # ---- runtime scripts -----------------------------------------------------
    generate_consoles_script > "${cfg}/includes.chroot/usr/local/sbin/tshoot-consoles.sh"
    generate_persona_script  > "${cfg}/includes.chroot/usr/local/sbin/tshoot-persona.sh"
    generate_diag_script     > "${cfg}/includes.chroot/usr/local/sbin/tshoot-diag.sh"
    chmod 0755 \
        "${cfg}/includes.chroot/usr/local/sbin/tshoot-consoles.sh" \
        "${cfg}/includes.chroot/usr/local/sbin/tshoot-persona.sh" \
        "${cfg}/includes.chroot/usr/local/sbin/tshoot-diag.sh"

    # ---- systemd unit --------------------------------------------------------
    generate_diag_unit > "${cfg}/includes.chroot/etc/systemd/system/tshoot-diag.service"

    # ---- autologin drop-ins (VGA tty1 + every serial UART) ------------------
    generate_getty_override  > "${cfg}/includes.chroot/etc/systemd/system/getty@tty1.service.d/autologin.conf"
    generate_serial_override > "${cfg}/includes.chroot/etc/systemd/system/serial-getty@.service.d/autologin.conf"

    # ---- login banner --------------------------------------------------------
    generate_banner > "${cfg}/includes.chroot/etc/profile.d/zz-tshoot-banner.sh"

    # ---- chroot hook: enable diag, hold lldpd, expose `tshoot-diag` ----------
    generate_chroot_hook > "${cfg}/hooks/normal/0100-tshoot.hook.chroot"
    chmod 0755 "${cfg}/hooks/normal/0100-tshoot.hook.chroot"

    generate_binary_hook > "${cfg}/hooks/normal/0110-tshoot-bootloader-serial.hook.binary"
    chmod 0755 "${cfg}/hooks/normal/0110-tshoot-bootloader-serial.hook.binary"

    ok "Config tree ready"

    # ---- lb config: hybrid UEFI+BIOS ISO with baked kernel cmdline ----------
    run_lb_config
}

# Fetch the OBS Release.key over TLS and wire up the apt repo, keeping the
# BUILD-time trust and the RUNTIME source separate.  Fail closed if the key
# cannot be fetched.
#
# Why two paths:
#   * live-build applies config/archives/*.{list,key}.chroot during the chroot
#     stage BEFORE the package list installs nic-xray, so the repo is trusted
#     while apt installs.  That list must NOT use signed-by (its keyring path
#     doesn't exist yet), and the archives key is a dearmored BINARY keyring
#     (a .gpg-suffixed trusted.gpg.d file must be binary, not ASCII-armored),
#     fetched over http since the minimal chroot has no ca-certificates yet.
#   * config/includes.chroot is applied AFTER the package stage, so the
#     modern signed-by source + dearmored keyring it drops are for the BOOTED
#     operator system only (post-boot `apt-get update`), not the build.
generate_obs_archive() {
    local cfg="$1"
    local key_url="${OBS_BASE}/${OBS_TREE}/Release.key"
    local repo_url="${OBS_BASE}/${OBS_TREE}/"

    local repo_url_http="${repo_url/https:/http:}"

    msg "Fetching OBS repo key over TLS (${OBS_TREE})..."
    local armored
    armored=$(mktemp) || die "mktemp failed"
    curl -fsSL --connect-timeout 15 --max-time 60 -o "$armored" "$key_url" \
        || die "Failed to fetch OBS key from ${key_url} (fail-closed — never installed with gpgcheck off)"
    [[ -s "$armored" ]] || { rm -f "$armored"; die "OBS key fetched empty from ${key_url}"; }

    # Wire the OBS repo through live-build's archives mechanism for BOTH stages
    # with ONE consistent trust config: a dearmored key in trusted.gpg.d and a
    # source WITHOUT signed-by.
    #   *.chroot → trusted during the build so apt can install nic-xray
    #   *.binary → the SAME source persists into the image so the booted
    #              operator can `apt-get update` nic-xray later
    # A separate includes.chroot source with an explicit signed-by would make apt
    # see the same repo twice with different Signed-By values ("Conflicting
    # values set for option Signed-By"), which Ubuntu's apt rejects outright.
    # Both stages use the same name (nic-xray) so at most one file exists at a
    # time. The key MUST be dearmored (binary) — an ASCII-armored payload under a
    # .gpg suffix is rejected as "unsupported filetype". http:// avoids the
    # missing ca-certificates in the minimal chroot; integrity comes from the key.
    local stage
    for stage in chroot binary; do
        gpg --dearmor -o "${cfg}/archives/nic-xray.key.${stage}" < "$armored" \
            || { rm -f "$armored"; die "Failed to dearmor OBS key (${stage})"; }
        chmod 0644 "${cfg}/archives/nic-xray.key.${stage}"
        cat > "${cfg}/archives/nic-xray.list.${stage}" <<EOF
deb ${repo_url_http} /
EOF
    done

    rm -f "$armored"
    ok "OBS repo wired via live-build archives (chroot + binary, dearmored key, no signed-by)"
}

# Write target-network.conf to stdout (shared params for all hosts).
generate_target_network_conf() {
    echo "# Target-host network — generated by create-network-tshoot-livecd"
    echo "# Shared parameters; the per-host IP comes from hosts.csv."
    [[ -n "$BOND_MODE" ]] && echo "BOND_MODE=$BOND_MODE"
    [[ -n "$VLAN_ID" ]]   && echo "VLAN_ID=$VLAN_ID"
    [[ -n "$NETMASK" ]]   && echo "NETMASK=$NETMASK"
    [[ -n "$GATEWAY" ]]   && echo "GATEWAY=$GATEWAY"
    [[ -n "$DNS" ]]       && echo "DNS_SERVERS=$DNS"
    [[ -n "$PROXY" ]]     && echo "PROXY=$PROXY"
    return 0
}

# Invoke  lb config  to produce a hybrid (UEFI+BIOS) ISO with the read-only /
# NIC-naming / dual-console kernel cmdline baked into the bootloader.
run_lb_config() {
    msg "Running lb config (${LB_MODE} ${CODENAME}, iso-hybrid, amd64)..."

    # Kernel cmdline baked into the bootloader (bootappend-live):
    #   - Read-only enforcement: stop systemd-gpt-auto-generator from
    #     swapon/mounting foreign partitions on the target disk.
    #   - NIC-naming parity with modern PVE so CSV bond_members resolve.
    #   - VGA + serial console: tty0 plus the operator-selected ttyS<unit>.
    #     The serial unit (--serial-unit, default 0) drives the kernel console,
    #     the bootloader menu (0110 binary hook), and is where kernel/boot text
    #     lands. Interactive logins on ANY real UART are still spawned at runtime
    #     by tshoot-consoles.sh regardless of this unit.
    local cmdline="boot=live components username=root hostname=network-tshoot"
    cmdline+=" systemd.gpt_auto=0 rd.systemd.gpt_auto=0 noswap"
    cmdline+=" net.ifnames=1 biosdevname=0"
    cmdline+=" console=tty0 console=ttyS${SERIAL_UNIT},115200"

    # NOTE: we deliberately do NOT pass --debian-installer. Its default across
    # every live-build version means "no installer" (which is what we want), but
    # the accepted VALUE for an explicit disable changed between versions
    # (older live-build wants 'false', modern wants 'none') — passing either
    # couples the tool to one live-build generation. Omitting it is portable.
    local -a lb_args=(
        config
        --mode "$LB_MODE"
        --distribution "$CODENAME"
        --architectures amd64
        --binary-images iso-hybrid
        --bootappend-live "$cmdline"
    )

    # Set the apt mirror EXPLICITLY. Debian's live-build leaves every mirror
    # variable empty for --mode ubuntu (LB_MIRROR_* and LB_PARENT_MIRROR_* all
    # default to ""), which produces a malformed sources.list ("deb  jammy main
    # universe" — no URL) and aborts the chroot stage. Passing both the mirror
    # and parent-mirror options covers live-build's internal parent/child logic
    # for every base.
    lb_args+=(
        --mirror-bootstrap "$MIRROR"
        --mirror-chroot "$MIRROR"
        --mirror-chroot-security "$MIRROR_SECURITY"
        --mirror-binary "$MIRROR"
        --mirror-binary-security "$MIRROR_SECURITY"
        --parent-mirror-bootstrap "$MIRROR"
        --parent-mirror-chroot "$MIRROR"
        --parent-mirror-chroot-security "$MIRROR_SECURITY"
        --parent-mirror-binary "$MIRROR"
        --parent-mirror-binary-security "$MIRROR_SECURITY"
    )
    # Enable the archive areas that carry our packages:
    #   Debian — free firmware lives in its own area since bookworm.
    #   Ubuntu — lldpd (and other diag tools) live in universe.
    if [[ "$LB_MODE" == "debian" ]]; then
        lb_args+=( --archive-areas "main contrib non-free non-free-firmware" )
    else
        lb_args+=( --archive-areas "main universe" )
    fi

    # Kernel meta-package flavour differs by distro: Debian is linux-image-amd64,
    # Ubuntu is linux-image-generic. Debian's live-build keeps the Debian flavour
    # even in --mode ubuntu ("Package 'linux-image-amd64' has no installation
    # candidate"), so set it explicitly per base.
    if [[ "$LB_MODE" == "ubuntu" ]]; then
        lb_args+=( --linux-flavours generic )
    else
        lb_args+=( --linux-flavours amd64 )
    fi

    # Honor an ambient http_proxy/https_proxy for build hosts without direct
    # Internet. curl (OBS key) and debootstrap read it from the environment
    # automatically; the in-chroot apt needs it forwarded explicitly. Run the
    # tool under `sudo -E` so the proxy survives into root's environment.
    local _proxy="${http_proxy:-${https_proxy:-${HTTP_PROXY:-${HTTPS_PROXY:-}}}}"
    [[ -n "$_proxy" ]] && lb_args+=( --apt-http-proxy "$_proxy" )

    ( cd "$BUILD_DIR" && lb "${lb_args[@]}" ) \
        || die "lb config failed — see output above"
    ok "lb config complete"
}

# --- Runtime script generators (land inside the live image) -------------------

# /usr/local/sbin/tshoot-consoles.sh — discover the console device set.
# Prints one bare device name per line: tty1 (VGA) plus each serial UART that
# has a real I/O base in /proc/tty/driver/serial (uart:unknown stubs skipped).
generate_consoles_script() {
    cat <<'CONSOLES_EOF'
#!/bin/bash
# tshoot-consoles.sh — print the console device set (bare names, one per line).
#
# Always emits tty1 (VGA).  Then, for each serial line in
# /proc/tty/driver/serial that has a real UART type (not "uart:unknown") AND a
# non-zero I/O port base, emits ttyS<N>.  Note: a populated-but-unconnected
# 16550A still passes this test — treat these as "detected", not
# "guaranteed-connected".
#
# Callers prepend /dev/ for a device path; agetty takes the bare name.

echo "tty1"

serinfo="/proc/tty/driver/serial"
[[ -r "$serinfo" ]] || exit 0

while read -r line; do
    # Lines look like: "0: uart:16550A port:000003F8 irq:4 tx:0 rx:0"
    [[ "$line" =~ ^([0-9]+):[[:space:]]*uart:([^[:space:]]+) ]] || continue
    idx="${BASH_REMATCH[1]}"
    utype="${BASH_REMATCH[2]}"
    [[ "$utype" == "unknown" ]] && continue

    # Require a real I/O base (port:XXXXXXXX, non-zero).
    port=""
    [[ "$line" =~ port:([0-9A-Fa-f]+) ]] && port="${BASH_REMATCH[1]}"
    [[ -z "$port" ]] && continue
    [[ "$port" =~ ^0+$ ]] && continue

    [[ -e "/dev/ttyS${idx}" ]] || continue
    echo "ttyS${idx}"
done < "$serinfo"
CONSOLES_EOF
}

# /usr/local/sbin/tshoot-persona.sh — host identity + optional mgmt network.
# Behaviour ported verbatim from the pve-tools tshoot-image persona logic, with a CSV
# precedence step that prefers a CSV found on boot media over the embedded one.
generate_persona_script() {
    cat <<'PERSONA_EOF'
#!/bin/bash
# tshoot-persona.sh — Host identity + optional management network.
#
# Step 1: Identify the host via DMI serial → set hostname (ALWAYS on a match)
#         and read per-host bond members + IP from a CSV inventory.
# Step 2: Enable ALL physical NICs (unconditional — required for LLDP).
#         (Link-up is repeated by tshoot-diag.sh so lldpd starts last.)
# Step 3: Optional management network (bond + VLAN + IP/gw/dns) — only when
#         an IP is present for this host and shared params are supplied.
#
# CSV precedence: a hosts.csv on the boot media (read-only mount, copied to
# tmpfs) wins over the embedded /etc/tshoot/hosts.csv, so inventory edits do
# not require an image rebuild.  No block device is ever written to.

echo ""
echo "==== Troubleshooting persona setup ===="
echo ""

mkdir -p /run/tshoot

# --------------------------------------------------------------------------- #
# 0.  Resolve the CSV (boot media preferred, embedded fallback)               #
# --------------------------------------------------------------------------- #
# READ-ONLY INVARIANT: this must never write to any block device.  A plain
# `mount -o ro` still WRITES to dirty filesystems (ext3/4 replays its journal,
# xfs recovers its log) — and the hosts we diagnose are crash/force-powered-off
# boxes with dirty logs.  So:
#   * scan ONLY the operator's own media — a filesystem labelled TSHOOT, or a
#     removable/hotplug device — NEVER the fixed target disks.
#   * mount filesystem-type-aware and non-replaying (ext* -> noload,
#     xfs -> norecovery) so no recovery write can happen.
# A normal boot with no operator USB therefore touches NO disk.

# Mount $1 read-only + non-replaying at $2 based on its FSTYPE.  Returns 0 on
# a successful mount.
_mount_ro_safe() {
    local dev="$1" mnt="$2" fstype
    fstype=$(lsblk -no FSTYPE "$dev" 2>/dev/null | head -n1)
    case "$fstype" in
        ext2|ext3|ext4) mount -o ro,noload      "$dev" "$mnt" 2>/dev/null ;;
        xfs)            mount -o ro,norecovery   "$dev" "$mnt" 2>/dev/null ;;
        *)              mount -o ro              "$dev" "$mnt" 2>/dev/null ;;
    esac
}

# Copy the first matching CSV candidate under $1 to tmpfs; echo its path.
_grab_csv() {
    local mnt="$1" cand
    for cand in tshoot-hosts.csv hosts.csv tshoot/hosts.csv; do
        if [[ -f "$mnt/$cand" ]]; then
            cp "$mnt/$cand" /run/tshoot/hosts.csv
            echo /run/tshoot/hosts.csv; return 0
        fi
    done
    return 1
}

find_boot_csv() {
    local mnt dev csv
    mnt=$(mktemp -d /run/tshoot/csvmnt-XXXXXX)

    # 1. An explicitly labelled operator filesystem wins.
    for dev in /dev/disk/by-label/TSHOOT /dev/disk/by-label/tshoot; do
        [[ -e "$dev" ]] || continue
        if _mount_ro_safe "$dev" "$mnt"; then
            csv=$(_grab_csv "$mnt" || true)
            umount "$mnt" 2>/dev/null
            [[ -n "$csv" ]] && { rmdir "$mnt" 2>/dev/null; echo "$csv"; return 0; }
        fi
    done

    # 2. Otherwise ONLY removable/hotplug media (RM=1 or HOTPLUG=1) — never a
    #    fixed target disk.  Mounts are journal-safe (see _mount_ro_safe).
    while read -r dev; do
        [[ -b "$dev" ]] || continue
        _mount_ro_safe "$dev" "$mnt" || continue
        csv=$(_grab_csv "$mnt" || true)
        umount "$mnt" 2>/dev/null
        [[ -n "$csv" ]] && { rmdir "$mnt" 2>/dev/null; echo "$csv"; return 0; }
    done < <(lsblk -rno NAME,RM,HOTPLUG,TYPE 2>/dev/null \
        | awk '($4=="part"||$4=="disk") && ($2=="1"||$3=="1"){print "/dev/"$1}')

    rmdir "$mnt" 2>/dev/null
    return 1
}

CSV="$(find_boot_csv 2>/dev/null || true)"
if [[ -n "$CSV" && -f "$CSV" ]]; then
    echo "[tshoot] CSV       : $CSV  (from boot media)"
else
    CSV="/etc/tshoot/hosts.csv"
    echo "[tshoot] CSV       : $CSV  (embedded)"
fi

# --------------------------------------------------------------------------- #
# 1.  Identity from DMI serial number (hostname + IP from CSV)                #
# --------------------------------------------------------------------------- #
SERIAL=$(dmidecode -s system-serial-number 2>/dev/null | tr -d '[:space:]')

HOST_NAME=""
HOST_BOND_MEMBERS=""   # colon-separated in CSV
HOST_IP=""

if [[ -n "$SERIAL" && "$SERIAL" != "None" && -f "$CSV" ]]; then
    HOST_NAME=$(awk -F, -v s="$SERIAL" \
        '/^[[:space:]]*#/{next} /^[[:space:]]*$/{next}
         {gsub(/[[:space:]]/,"",$1); gsub(/[[:space:]]/,"",$2)}
         $1==s{print $2; exit}' "$CSV")
    HOST_BOND_MEMBERS=$(awk -F, -v s="$SERIAL" \
        '/^[[:space:]]*#/{next} /^[[:space:]]*$/{next}
         {gsub(/[[:space:]]/,"",$1); gsub(/[[:space:]]/,"",$3)}
         $1==s{print $3; exit}' "$CSV")
    HOST_IP=$(awk -F, -v s="$SERIAL" \
        '/^[[:space:]]*#/{next} /^[[:space:]]*$/{next}
         {gsub(/[[:space:]]/,"",$1); gsub(/[[:space:]]/,"",$4)}
         $1==s{print $4; exit}' "$CSV")

    if [[ -n "$HOST_NAME" ]]; then
        hostname "$HOST_NAME" 2>/dev/null || true
        echo "$HOST_NAME" > /etc/hostname 2>/dev/null || true
        echo "[tshoot] Hostname  : $HOST_NAME  (serial $SERIAL)"
        [[ -n "$HOST_BOND_MEMBERS" ]] && echo "[tshoot] Bond NICs : $HOST_BOND_MEMBERS"
        [[ -n "$HOST_IP" ]]           && echo "[tshoot] Host IP   : $HOST_IP"
    else
        echo "[tshoot] WARNING — no mapping for serial '$SERIAL'"
        echo "[tshoot] Known mappings:"
        awk -F, '/^[[:space:]]*#/{next} /^[[:space:]]*$/{next} {
            gsub(/[[:space:]]/,"",$1); gsub(/[[:space:]]/,"",$2)
            gsub(/[[:space:]]/,"",$3); gsub(/[[:space:]]/,"",$4)
            printf "            %-16s  %-20s  %-14s  %s\n", $1, $2, $3, $4
        }' "$CSV"
    fi
else
    [[ -z "$SERIAL" || "$SERIAL" == "None" ]] && \
        echo "[tshoot] WARNING — could not read DMI serial number"
    [[ ! -f "$CSV" ]] && \
        echo "[tshoot] WARNING — $CSV not found"
fi

# Fallback hostname when no host was identified
if [[ -z "$HOST_NAME" ]]; then
    hostname "kml" 2>/dev/null || true
    echo "kml" > /etc/hostname 2>/dev/null || true
    echo "[tshoot] Hostname  : kml  (fallback — host not identified)"
fi

# --------------------------------------------------------------------------- #
# 2.  Enable every physical NIC  (unconditional — needed for LLDP)            #
# --------------------------------------------------------------------------- #
echo "[tshoot] Enabling network interfaces..."
DETECTED_NICS=""
for dev_link in /sys/class/net/*/device; do
    [[ -e "$dev_link" ]] || continue
    iface=$(basename "$(dirname "$dev_link")")
    DETECTED_NICS+="${iface} "
    ip link set "$iface" up 2>/dev/null \
        && echo "[tshoot]   $iface UP" \
        || echo "[tshoot]   $iface FAILED"
done

# --------------------------------------------------------------------------- #
# 3.  Optional management network (bond + VLAN + per-host IP)                 #
# --------------------------------------------------------------------------- #
NETCFG="/etc/tshoot/target-network.conf"
if [[ -f "$NETCFG" && -n "$HOST_IP" ]]; then
    echo "[tshoot] Configuring management network..."

    # shellcheck source=/dev/null
    source "$NETCFG"

    _mgmt_iface=""

    # ---- Bond (per-host members from CSV, colon-separated) ----
    if [[ -n "$HOST_BOND_MEMBERS" ]]; then
        modprobe bonding 2>/dev/null || true
        _mode="${BOND_MODE:-802.3ad}"
        case "$_mode" in
            802.3ad|4)       _km=4 ;; active-backup|1) _km=1 ;;
            balance-rr|0)    _km=0 ;; balance-xor|2)   _km=2 ;;
            broadcast|3)     _km=3 ;; balance-tlb|5)   _km=5 ;;
            balance-alb|6)   _km=6 ;; *)               _km=4 ;;
        esac

        ip link add bond0 type bond mode "$_km" 2>/dev/null || true

        IFS=':' read -ra _members <<< "$HOST_BOND_MEMBERS"
        for m in "${_members[@]}"; do
            m=${m// /}
            if [[ ! -e "/sys/class/net/$m" ]]; then
                echo "[tshoot]   WARNING — bond member '$m' not present"
                echo "[tshoot]   detected NICs: ${DETECTED_NICS:-none}"
                continue
            fi
            ip link set "$m" down  2>/dev/null
            ip link set "$m" master bond0 2>/dev/null \
                && echo "[tshoot]   $m -> bond0" \
                || { echo "[tshoot]   WARNING — $m could not join bond0"; \
                     echo "[tshoot]   detected NICs: ${DETECTED_NICS:-none}"; }
            ip link set "$m" up 2>/dev/null
        done
        ip link set bond0 up 2>/dev/null
        _mgmt_iface="bond0"
        echo "[tshoot] bond0 created  (mode $_mode)"
    fi

    # ---- VLAN (optional, on top of bond0 or first physical NIC) ----
    if [[ -n "${VLAN_ID:-}" ]]; then
        modprobe 8021q 2>/dev/null || true
        _parent="${_mgmt_iface:-eth0}"
        _vif="${_parent}.${VLAN_ID}"
        ip link add link "$_parent" name "$_vif" type vlan id "$VLAN_ID" 2>/dev/null || true
        ip link set "$_vif" up 2>/dev/null
        _mgmt_iface="$_vif"
        echo "[tshoot] VLAN      $_vif"
    fi

    # Fall back to first physical NIC if no bond/VLAN
    if [[ -z "$_mgmt_iface" ]]; then
        _mgmt_iface=$(ip -o link show | awk -F': ' '/state UP/ && !/lo/{print $2; exit}')
        [[ -z "$_mgmt_iface" ]] && _mgmt_iface="eth0"
    fi

    # ---- Per-host IP (from CSV) + shared netmask ----
    ip addr add "${HOST_IP}${NETMASK}" dev "$_mgmt_iface" 2>/dev/null || true
    echo "[tshoot] IP        ${HOST_IP}${NETMASK} on $_mgmt_iface"

    # ---- Default gateway ----
    [[ -n "${GATEWAY:-}" ]] && {
        ip route add default via "$GATEWAY" 2>/dev/null || true
        echo "[tshoot] gateway   $GATEWAY"
    }

    # ---- DNS ----
    [[ -n "${DNS_SERVERS:-}" ]] && {
        : > /etc/resolv.conf
        IFS=',' read -ra _ns <<< "$DNS_SERVERS"
        for n in "${_ns[@]}"; do echo "nameserver ${n// /}" >> /etc/resolv.conf; done
        echo "[tshoot] DNS       $DNS_SERVERS"
    }

    # ---- HTTP(S) proxy ----
    [[ -n "${PROXY:-}" ]] && {
        export http_proxy="$PROXY" https_proxy="$PROXY"
        export HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY"
        echo "[tshoot] proxy     $PROXY"
    }
elif [[ -f "$NETCFG" && -z "$HOST_IP" ]]; then
    echo "[tshoot] No IP for this host (serial: ${SERIAL:-unknown}) — hostname only, no mgmt network."
fi

echo ""
echo "==== Persona setup complete ===="
echo ""
PERSONA_EOF
}

# /usr/local/sbin/tshoot-diag.sh — the autostart diagnostic run.
# Ordering matters: persona → link-up → lldpd → settle → nic-xray → table.
generate_diag_script() {
    cat <<'DIAG_EOF'
#!/bin/bash
# tshoot-diag.sh — Cabling diagnostics run (also runnable interactively).
#
# ORDER MATTERS (bond enslavement bounces member links and resets the switch
# LLDP TX cycle, so lldpd must start AFTER the last link change):
#   1. tshoot-persona.sh   — hostname (+ optional bond/VLAN/IP)
#   2. link-up every physical NIC
#   3. start lldpd (only now)
#   4. poll lldpcli show neighbors until non-empty or LLDP_WAIT cap
#   5. nic-xray --all --output csv   — the authoritative artifact
#   6. render a human per-NIC table (iface/state/MAC/speed/LLDP)
#   7. write CSV + table to /run/tshoot/cabling-report.txt and echo to the
#      SAME console set (VGA tty1 + each detected serial UART)
#
# Writes only to tmpfs (/run) — no block device is ever touched.

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

LLDP_WAIT=45
[[ -f /etc/tshoot/diag.conf ]] && . /etc/tshoot/diag.conf

mkdir -p /run/tshoot
REPORT="/run/tshoot/cabling-report.txt"

# Resolve the console device set once (bare names: tty1 ttyS0 ...).
mapfile -t CONSOLES < <(/usr/local/sbin/tshoot-consoles.sh 2>/dev/null)
[[ ${#CONSOLES[@]} -eq 0 ]] && CONSOLES=(tty1)

# Echo a file to every console in the set.  Serial writes are made
# non-blocking (clocal) and time-boxed so a flow-controlled port with no DTE
# attached can never hang the diagnostics run.
broadcast() {
    local src="$1" con dev
    for con in "${CONSOLES[@]}"; do
        dev="/dev/${con}"
        [[ -e "$dev" ]] || continue
        if [[ "$con" == ttyS* ]]; then
            stty -F "$dev" clocal -crtscts -ixon -ixoff 115200 2>/dev/null || true
            timeout 5 dd of="$dev" bs=4096 2>/dev/null < "$src" || true
        else
            cat "$src" > "$dev" 2>/dev/null || true
        fi
    done
}

# ---- 1. persona (hostname + optional mgmt network) -----------------------
persona_log="/run/tshoot/persona.log"
/usr/local/sbin/tshoot-persona.sh 2>&1 | tee "$persona_log" || true

# ---- 2. bring every physical NIC link-up (record the settle epoch) -------
for dev_link in /sys/class/net/*/device; do
    [[ -e "$dev_link" ]] || continue
    iface=$(basename "$(dirname "$dev_link")")
    ip link set "$iface" up 2>/dev/null || true
done
LINK_EPOCH=$(date +%s)

# ---- 3. start lldpd (only now, after the last link change) ---------------
if command -v lldpd &>/dev/null; then
    systemctl restart lldpd 2>/dev/null || lldpd -c -s -e 2>/dev/null || true
fi

# ---- 4. settle: poll neighbours until non-empty or LLDP_WAIT cap ---------
neighbors=""
while true; do
    lldpcli update 2>/dev/null || true
    neighbors=$(lldpcli -f keyvalue show neighbors 2>/dev/null || true)
    [[ -n "$neighbors" ]] && break
    now=$(date +%s)
    (( now - LINK_EPOCH >= LLDP_WAIT )) && break
    sleep 3
done

# ---- 5. canonical authoritative artifact ---------------------------------
xray_csv="/run/tshoot/nic-xray.csv"
if command -v nic-xray &>/dev/null; then
    nic-xray --all --output csv > "$xray_csv" 2>/dev/null || \
        echo "nic-xray failed to produce CSV" > "$xray_csv"
else
    echo "nic-xray not found in image" > "$xray_csv"
fi

# ---- 6. human per-NIC table (iface/state/MAC/speed/LLDP) -----------------
# Pull the (refreshed) neighbour table once as key=value for parsing.
neighbors=$(lldpcli -f keyvalue show neighbors 2>/dev/null || true)

lldp_field() {  # $1=iface  $2=suffix (e.g. chassis.name)
    awk -F= -v pfx="lldp.$1.$2" '$1==pfx{print $2; exit}' <<< "$neighbors"
}

{
    echo "iface       state  mac                speed        switch                port                 vlan"
    echo "----------  -----  -----------------  -----------  --------------------  -------------------  ----"
    for dev_link in /sys/class/net/*/device; do
        [[ -e "$dev_link" ]] || continue
        iface=$(basename "$(dirname "$dev_link")")
        state=$(cat "/sys/class/net/$iface/operstate" 2>/dev/null || echo "?")
        mac=$(cat "/sys/class/net/$iface/address" 2>/dev/null || echo "?")
        speed=$(ethtool "$iface" 2>/dev/null | awk -F': ' '/Speed:/{print $2; exit}')
        [[ -z "$speed" ]] && speed="-"
        sw=$(lldp_field "$iface" "chassis.name");         [[ -z "$sw" ]] && sw="-"
        port=$(lldp_field "$iface" "port.descr")
        [[ -z "$port" ]] && port=$(lldp_field "$iface" "port.ifname")
        [[ -z "$port" ]] && port="-"
        vlan=$(lldp_field "$iface" "vlan.vlan-id");        [[ -z "$vlan" ]] && vlan="-"
        printf "%-10s  %-5s  %-17s  %-11s  %-20s  %-19s  %s\n" \
            "$iface" "$state" "$mac" "$speed" "$sw" "$port" "$vlan"
    done
} > /run/tshoot/nic-table.txt

# ---- 7. assemble the report and broadcast to every console ---------------
{
    echo "================ network-tshoot-livecd — cabling report ================"
    echo "host    : $(hostname 2>/dev/null)"
    echo "date    : $(date '+%Y-%m-%d %H:%M:%S')"
    echo "consoles: ${CONSOLES[*]}"
    echo "lldp    : waited up to ${LLDP_WAIT}s for neighbours after link-up"
    echo ""
    echo "---- nic-xray --all --output csv (authoritative) ----"
    cat "$xray_csv"
    echo ""
    echo "---- per-NIC table (iface / state / MAC / speed / LLDP) ----"
    cat /run/tshoot/nic-table.txt
    echo "===================================================================="
} > "$REPORT"

broadcast "$REPORT"
DIAG_EOF
}

# /etc/systemd/system/tshoot-diag.service
generate_diag_unit() {
    cat <<'UNIT_EOF'
[Unit]
Description=network-tshoot-livecd cabling diagnostics
After=network-pre.target systemd-udev-settle.service
Wants=network-pre.target
ConditionPathExists=/usr/local/sbin/tshoot-diag.sh

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/tshoot-diag.sh
StandardOutput=journal+console
StandardError=journal+console
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target
UNIT_EOF
}

# getty@tty1 autologin drop-in (VGA console).
generate_getty_override() {
    cat <<'GETTY_EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear %I $TERM
GETTY_EOF
}

# serial-getty@ autologin drop-in (every serial UART enabled by console=).
generate_serial_override() {
    cat <<'SGETTY_EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --keep-baud 115200,57600,38400,9600 --noclear %I $TERM
SGETTY_EOF
}

# /etc/profile.d/zz-tshoot-banner.sh — operator banner on every login.
generate_banner() {
    cat <<'BANNER_EOF'
# network-tshoot-livecd operator banner
if [[ -t 0 ]]; then
    echo ""
    echo "  ============ network-tshoot-livecd — cabling diagnostics ============"
    echo "  Auto-run report : /run/tshoot/cabling-report.txt"
    echo "  Re-run anytime  : tshoot-diag"
    echo "  On PATH         : nic-xray  lldpcli  tcpdump  ethtool  dmidecode"
    echo "  This is a LIVE, read-only image — disks are never written."
    echo "  ================================================================"
    echo ""
fi
BANNER_EOF
}

# config/hooks/normal/0100-tshoot.hook.chroot — finalise the image.
generate_chroot_hook() {
    cat <<'HOOK_EOF'
#!/bin/sh
# Finalise the tshoot live image inside the chroot.
set -e

# Enable the diagnostics unit.
systemctl enable tshoot-diag.service 2>/dev/null || true

# Hold lldpd's auto-start so tshoot-diag.sh controls the ordering
# (link-up → bond/VLAN → lldpd → settle).
systemctl disable lldpd 2>/dev/null || true

# Make the runtime scripts executable and expose `tshoot-diag` on PATH.
chmod 0755 /usr/local/sbin/tshoot-*.sh 2>/dev/null || true
ln -sf tshoot-diag.sh /usr/local/sbin/tshoot-diag 2>/dev/null || true
HOOK_EOF
}

# config/hooks/normal/0110-tshoot-bootloader-serial.hook.binary — mirror the
# bootloader menu to the serial console (ttyS<SERIAL_UNIT>) in addition to VGA.
# Runs in the binary stage after the isolinux/grub configs are generated and
# before the ISO is packed. The kernel already gets console=ttyS<unit> from
# --bootappend-live; this makes the *menu* (which cannot auto-discover) visible
# on the same serial port.
generate_binary_hook() {
    echo "#!/bin/sh"
    echo "# Bootloader serial console — generated by create-network-tshoot-livecd"
    echo "UNIT=${SERIAL_UNIT}"
    echo "SPEED=115200"
    cat <<'HOOK_EOF'
set -e

# IMPORTANT: live-build runs .hook.binary with the CWD already inside binary/
# (it does `cd binary` first), so all paths below are relative to binary/ — no
# "binary/" prefix. binary_grub_cfg and binary_syslinux have already generated
# the configs at this point.

# --- isolinux / syslinux (BIOS) ------------------------------------------------
# The SERIAL directive redirects the syslinux boot menu to the serial port
# (in addition to VGA). vesamenu.c32 renders a text menu on serial when SERIAL
# is active, so no module swap is needed.
if [ -d isolinux ]; then
    for cfg in isolinux/*.cfg; do
        [ -f "$cfg" ] || continue
        grep -q '^SERIAL ' "$cfg" 2>/dev/null || sed -i "1i SERIAL ${UNIT} ${SPEED} 0" "$cfg"
    done
fi

# --- GRUB (UEFI + BIOS) --------------------------------------------------------
# Drop the graphical gfxterm output (config.cfg) and send the menu to the text
# console + serial on both. Prepend the serial init to grub.cfg so it runs
# before it sources config.cfg.
for gcfg in boot/grub/grub.cfg boot/grub/config.cfg; do
    [ -f "$gcfg" ] && sed -i '/terminal_output[[:space:]]\{1,\}gfxterm/d' "$gcfg"
done
GRUBCFG=boot/grub/grub.cfg
if [ -f "$GRUBCFG" ] && ! grep -q '^serial ' "$GRUBCFG"; then
    tmp="${GRUBCFG}.tshoot"
    {
        echo "insmod serial"
        echo "serial --unit=${UNIT} --speed=${SPEED}"
        echo "terminal_input console serial"
        echo "terminal_output console serial"
        cat "$GRUBCFG"
    } > "$tmp"
    mv "$tmp" "$GRUBCFG"
fi
HOOK_EOF
}

# --- Build --------------------------------------------------------------------

# Run  lb build  and place the resulting hybrid ISO in the output directory.
# The root requirement is enforced early in main() before any work is done.
do_build() {
    # Timestamp includes HH:MM:SS so same-day rebuilds don't overwrite.
    local iso_name="network-tshoot-livecd-$(date +%Y%m%d-%H%M%S).iso"
    local out="${OUTPUT_DIR%/}/${iso_name}"

    msg "Building live image (this is long — lb build)..."
    local log="${BUILD_DIR}/lb-build.log"
    if ( cd "$BUILD_DIR" && lb build ) 2>&1 | tee "$log"; then
        local hybrid="${BUILD_DIR}/live-image-amd64.hybrid.iso"
        [[ -f "$hybrid" ]] || die "lb build reported success but ${hybrid} is missing"
        mkdir -p "$OUTPUT_DIR"
        cp "$hybrid" "$out"
        ok "ISO written: $out"
    else
        err "lb build FAILED — last log lines:"
        tail -n 30 "$log" >&2 || true
        (( KEEP_BUILD )) && warn "Config tree kept for debugging: $BUILD_DIR"
        die "Image build failed"
    fi
}

# --- Usage / help -------------------------------------------------------------

usage() {
    cat <<EOF
Usage: $(basename "$0") -c CSV [OPTIONS]

Build a distro-independent LIVE ISO for boot-only cabling diagnostics.  Runs
on a plain Debian/Ubuntu BUILD host (NOT a PVE node) and uses live-build to
produce a hybrid (UEFI+BIOS) ISO.  On boot the image identifies the host by
DMI serial, brings up every NIC, runs  nic-xray --all --output csv , prints a
per-NIC LLDP table to VGA + serial console, and drops to a root shell.  It
never writes to any disk.

${C_BOLD}Required:${C_RESET}
  -c, --csv FILE           Host inventory CSV (serial,hostname,bond_members,ip)

${C_BOLD}Base image:${C_RESET}
      --base NAME          debian | ubuntu                  [debian]
      --dist VER           Base version (13/12 debian; 24.04/22.04 ubuntu)
      --mirror URL         apt mirror for the build (default: the distro archive
                           — deb.debian.org / archive.ubuntu.com)
                                                            [13 / 24.04]

${C_BOLD}Target-host network${C_RESET} (mgmt iface configured only when an IP is in the CSV):
      --bond-mode MODE     Bond mode                        [802.3ad]
      --vlan-id ID         Management VLAN ID
      --netmask MASK       Network mask (e.g. /24 or 255.255.255.0)
      --gateway GW         Default gateway
      --dns SERVERS        Comma-separated DNS servers
      --proxy URL          HTTP/HTTPS proxy

  If any of --netmask/--gateway/--dns is set, all three are required.

${C_BOLD}Diagnostics / image:${C_RESET}
      --lldp-wait SEC      Max seconds to poll for LLDP neighbours   [45]
      --serial-unit N      Serial console unit for kernel + bootloader menu
                           (ttyS<N>; e.g. 0=COM1, 1=COM2)             [0]
      --include-switch-xray  Also install the sibling switch-xray package
      --keep-build         Keep the live-build config tree on exit

${C_BOLD}General:${C_RESET}
  -o, --output DIR         Output directory for the ISO      [\$PWD]
  -h, --help               Show this help
  -v, --version            Print version

${C_BOLD}CSV format${C_RESET} (one host per line, # for comments):
  # serial,hostname,bond_members,ip
  SVR001,web-server-01,eth0:eth1,10.0.0.11
  SVR002,db-server-01,eno1:eno2,10.0.0.12

  A hosts.csv on the boot media (labelled TSHOOT, or a top-level hosts.csv /
  tshoot-hosts.csv on any partition) overrides the embedded copy at boot —
  edit inventory without rebuilding.

${C_BOLD}Prerequisites (build host):${C_RESET}
  apt install live-build xorriso curl gpg     (lb build needs root)

${C_BOLD}Packaging:${C_RESET}
  nic-xray + deps come from the OBS repo home:ciriarte:network-tools, exact
  tree Debian_13 / Debian_12 / Ubuntu_24.04 / Ubuntu_22.04, via a signed-by
  keyring fetched over TLS (never gpgcheck-off).

${C_BOLD}Examples:${C_RESET}
  # Debian 13 (default) live ISO into the current directory
  $(basename "$0") -c hosts.csv

  # Ubuntu 24.04 base, into a specific directory
  $(basename "$0") -c hosts.csv --base ubuntu -o /var/tmp/iso/

  # With a bonded VLAN management network
  $(basename "$0") -c hosts.csv \\
      --vlan-id 100 --netmask /24 --gateway 10.0.0.1 --dns 8.8.8.8

  # Include switch-xray and keep the build tree for inspection
  $(basename "$0") -c hosts.csv --include-switch-xray --keep-build
EOF
}

# --- Main ---------------------------------------------------------------------

main() {
    # ---- argument parsing ----------------------------------------------------
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -c|--csv)              CSV_FILE="$2";        shift 2 ;;
            -o|--output)           OUTPUT_DIR="$2";      shift 2 ;;
            --base)                BASE="$2";            shift 2 ;;
            --dist)                DIST="$2";            shift 2 ;;
            --mirror)              MIRROR="$2";          shift 2 ;;
            --lldp-wait)           LLDP_WAIT="$2";       shift 2 ;;
            --serial-unit)         SERIAL_UNIT="$2";     shift 2 ;;
            --include-switch-xray) INCLUDE_SWITCH_XRAY=1; shift ;;
            --keep-build)          KEEP_BUILD=1;         shift   ;;
            # --- target-host network ---
            --bond-mode)           BOND_MODE="$2";       shift 2 ;;
            --vlan-id)             VLAN_ID="$2";         shift 2 ;;
            --netmask)             NETMASK="$2";         shift 2 ;;
            --gateway)             GATEWAY="$2";         shift 2 ;;
            --dns)                 DNS="$2";             shift 2 ;;
            --proxy)               PROXY="$2";           shift 2 ;;
            # --- info ---
            -h|--help)             usage;                exit 0  ;;
            -v|--version)          echo "create-network-tshoot-livecd $VERSION"; exit 0 ;;
            -*)                    die "Unknown option: $1 (see --help)" ;;
            *)                     die "Unexpected argument: $1 (see --help)" ;;
        esac
    done

    # ---- required arguments / defaults ---------------------------------------
    [[ -n "$CSV_FILE" ]] || die "Missing required option: --csv FILE"
    [[ -z "$OUTPUT_DIR" ]] && OUTPUT_DIR="$PWD"
    [[ "$LLDP_WAIT" =~ ^[0-9]+$ ]] || die "--lldp-wait must be an integer (seconds)"
    [[ "$SERIAL_UNIT" =~ ^[0-9]+$ ]] || die "--serial-unit must be a non-negative integer (ttyS<N>)"

    # ---- resolve base + validate inputs --------------------------------------
    resolve_base
    validate_csv "$CSV_FILE"
    validate_target_network

    # A VLAN with no shared netmask means the per-host IP would be applied as a
    # bare /32; warn rather than silently mis-configure.
    [[ -n "$VLAN_ID" && -z "$NETMASK" ]] && \
        warn "--vlan-id set without --netmask/--gateway/--dns — the VLAN interface will come up without a management IP"

    # lb build needs root — fail fast, BEFORE the OBS probe or generating the
    # config tree, so the user isn't told to inspect a tree the exit trap then
    # removes.  (Comes after cheap input validation so a bad CSV is reported
    # first.)
    [[ $EUID -eq 0 ]] || die "lb build requires root — re-run with sudo."


    # ---- preflight (tooling, not-PVE, OBS reachability) ----------------------
    preflight

    # ---- generate the live-build config tree ---------------------------------
    build_config

    # ---- build + extract the ISO ---------------------------------------------
    do_build

    msg "Done."
}

main "$@"
