From 75d17eb87ce5bed24816c2e909b94e21e6abe995 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 17 Mar 2026 00:59:27 +0000 Subject: [PATCH] fix: HTTP Content-Length, firewall zones, UEFI boot improvements - Fix Content-Length using byte count instead of character count (em dash in iPXE scripts caused mismatch, breaking iPXE chain) - Use firewall zone-aware commands matching interface zone - Add UEFI HTTP Boot support (arch 16/20) alongside PXE TFTP - Add pxe-service directives for proper proxy DHCP responses - Use bind-dynamic instead of bind-interfaces for bridge compat - Add tftp-no-blocksize for UEFI firmware compatibility - Use local ipxe packages instead of downloading from internet - Add custom UEFI PXE loader stub (pxeloader.c) for chainloading - Enable HTTP request logging for debugging boot issues Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion.sh | 173 +++++++++++++++++++++++++++++++++++++++++++--------- pxeloader.c | 136 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 29 deletions(-) create mode 100644 pxeloader.c diff --git a/bastion.sh b/bastion.sh index 36959cd..2acdfe1 100755 --- a/bastion.sh +++ b/bastion.sh @@ -117,14 +117,20 @@ esac command -v python3 >/dev/null || die "python3 not found" command -v curl >/dev/null || die "curl not found" -if ! command -v dnsmasq >/dev/null; then - log "Installing dnsmasq..." +INSTALL_PKGS=() +command -v dnsmasq >/dev/null || INSTALL_PKGS+=(dnsmasq) +[[ -f /usr/share/ipxe/undionly.kpxe ]] || INSTALL_PKGS+=(ipxe-bootimgs-x86) +[[ -f /usr/share/ipxe/arm64-efi/snponly.efi ]] || INSTALL_PKGS+=(ipxe-bootimgs-aarch64) +[[ -f /usr/include/efi/efi.h ]] || INSTALL_PKGS+=(gnu-efi-devel) + +if [[ ${#INSTALL_PKGS[@]} -gt 0 ]]; then + log "Installing ${INSTALL_PKGS[*]}..." if command -v dnf >/dev/null; then - dnf install -y dnsmasq + dnf install -y "${INSTALL_PKGS[@]}" elif command -v apt-get >/dev/null; then - apt-get install -y dnsmasq + apt-get install -y "${INSTALL_PKGS[@]}" else - die "Cannot install dnsmasq — install it manually" + die "Cannot install packages — install manually: ${INSTALL_PKGS[*]}" fi fi @@ -132,6 +138,7 @@ fi IFACE="${IFACE:-$(ip route | awk '/default/ {print $5; exit}')}" SERVER_IP="$(ip -4 addr show "$IFACE" | awk '/inet / {split($2,a,"/"); print a[1]; exit}')" NETWORK="$(echo "$SERVER_IP" | awk -F. '{print $1"."$2"."$3".0"}')" +GATEWAY="$(ip route | awk '/default/ {print $3; exit}')" [[ -n "$SERVER_IP" ]] || die "Cannot detect IP on interface $IFACE" log "Interface: ${BOLD}$IFACE${NC} IP: ${BOLD}$SERVER_IP${NC} Network: ${BOLD}$NETWORK${NC}" @@ -177,10 +184,10 @@ cleanup() { if $FW_OPENED && command -v firewall-cmd >/dev/null; then log "Removing firewall rules..." - firewall-cmd --quiet --remove-service=dhcp 2>/dev/null || true - firewall-cmd --quiet --remove-service=tftp 2>/dev/null || true - firewall-cmd --quiet --remove-port=${HTTP_PORT}/tcp 2>/dev/null || true - firewall-cmd --quiet --remove-port=4011/udp 2>/dev/null || true + firewall-cmd --quiet ${FW_ZONE_FLAG:-} --remove-service=dhcp 2>/dev/null || true + firewall-cmd --quiet ${FW_ZONE_FLAG:-} --remove-service=tftp 2>/dev/null || true + firewall-cmd --quiet ${FW_ZONE_FLAG:-} --remove-port=${HTTP_PORT}/tcp 2>/dev/null || true + firewall-cmd --quiet ${FW_ZONE_FLAG:-} --remove-port=4011/udp 2>/dev/null || true fi log "State preserved in $STATEFILE" @@ -188,7 +195,7 @@ cleanup() { } trap cleanup EXIT INT TERM -# ──── Download artifacts (cached) ───────────────────────────────── +# ──── Prepare boot artifacts ───────────────────────────────────── download() { local url="$1" dest="$2" label="$3" if [[ -f "$dest" ]]; then @@ -196,17 +203,83 @@ download() { return fi log " ${label} — downloading..." - curl -# -L -o "$dest" "$url" || die "Failed to download $label from $url" + curl -# -L -f -o "$dest" "$url" || die "Failed to download $label from $url" +} + +copy_if_missing() { + local src="$1" dest="$2" label="$3" + if [[ -f "$dest" ]]; then + log " ${label} — cached" + return + fi + [[ -f "$src" ]] || die "${label}: source not found at $src" + cp "$src" "$dest" + log " ${label} — copied from $src" +} + +build_pxeloader() { + local src="$1" dest="$2" label="$3" + + if [[ -f "$dest" ]]; then + log " ${label} — cached ($(stat -c%s "$dest") bytes)" + return + fi + + log " ${label} — building UEFI PXE loader stub..." + + local builddir="$BASTION_DIR/pxeloader-build" + mkdir -p "$builddir" + + local gnuefi_lib gnuefi_inc + gnuefi_lib="/usr/lib" + gnuefi_inc="/usr/include/efi" + + # Compile + gcc -I"$gnuefi_inc" -I"$gnuefi_inc/x86_64" -I"$gnuefi_inc/protocol" \ + -DGNU_EFI_USE_MS_ABI -fPIC -fshort-wchar -ffreestanding \ + -fno-stack-protector -mno-red-zone -maccumulate-outgoing-args \ + -Wall -Os -c -o "$builddir/pxeloader.o" "$src" || die "PXE loader compile failed" + + # Link + ld -nostdlib -znocombreloc -shared -Bsymbolic \ + -T "$gnuefi_lib/elf_x86_64_efi.lds" \ + "$gnuefi_lib/crt0-efi-x86_64.o" \ + "$builddir/pxeloader.o" \ + -o "$builddir/pxeloader.so" \ + -lgnuefi -lefi -L"$gnuefi_lib" || die "PXE loader link failed" + + # Convert to PE/COFF EFI binary + objcopy -j .text -j .sdata -j .data -j .dynamic -j .rodata -j .dynsym \ + -j .rel -j .rela -j .rel.* -j .rela.* -j .rel* -j .rela* \ + -j .reloc --target efi-app-x86_64 \ + "$builddir/pxeloader.so" "$dest" || die "PXE loader objcopy failed" + + local size + size="$(stat -c%s "$dest")" + log " ${label} — built (${size} bytes / $((size/1024)) KB)" } FEDORA_MIRROR="https://download.fedoraproject.org/pub/fedora/linux/releases/${FEDORA_VERSION}/Everything/${ARCH}/os" -log "Fetching boot artifacts (Fedora ${FEDORA_VERSION} ${ARCH})..." -download "https://boot.ipxe.org/undionly.kpxe" "$TFTPDIR/undionly.kpxe" "iPXE BIOS" -download "https://boot.ipxe.org/ipxe.efi" "$TFTPDIR/ipxe.efi" "iPXE UEFI x86_64" -download "https://boot.ipxe.org/arm64-efi/snponly.efi" "$TFTPDIR/ipxe-arm64.efi" "iPXE UEFI arm64" -download "${FEDORA_MIRROR}/images/pxeboot/vmlinuz" "$HTTPDIR/vmlinuz" "Fedora kernel" -download "${FEDORA_MIRROR}/images/pxeboot/initrd.img" "$HTTPDIR/initrd.img" "Fedora initrd" +log "Preparing boot artifacts (Fedora ${FEDORA_VERSION} ${ARCH})..." +copy_if_missing "/usr/share/ipxe/undionly.kpxe" "$TFTPDIR/undionly.kpxe" "iPXE BIOS" + +# UEFI x86_64: two-stage PXE boot +# Stage 1: tiny PXE loader stub (<20KB) fits in constrained TFTP buffers +# Stage 2: full iPXE binary downloaded via UEFI PXE protocol (no size limit) +PXELOADER_SRC="$(cd "$(dirname "$0")" && pwd)/pxeloader.c" +[[ -f "$PXELOADER_SRC" ]] || PXELOADER_SRC="$(dirname "${BASH_SOURCE[0]}")/pxeloader.c" +build_pxeloader "$PXELOADER_SRC" "$TFTPDIR/ipxe.efi" "PXE loader stub (stage 1)" +copy_if_missing "/usr/share/ipxe/ipxe-snponly-x86_64.efi" "$TFTPDIR/ipxe-real.efi" "iPXE UEFI x86_64 (stage 2)" + +copy_if_missing "/usr/share/ipxe/arm64-efi/snponly.efi" "$TFTPDIR/ipxe-arm64.efi" "iPXE UEFI arm64" +download "${FEDORA_MIRROR}/images/pxeboot/vmlinuz" "$HTTPDIR/vmlinuz" "Fedora kernel" +download "${FEDORA_MIRROR}/images/pxeboot/initrd.img" "$HTTPDIR/initrd.img" "Fedora initrd" + +# Symlink iPXE binaries into HTTP dir (UEFI HTTP Boot downloads via HTTP, not TFTP) +for f in "$TFTPDIR"/*.efi; do + ln -sf "$f" "$HTTPDIR/$(basename "$f")" 2>/dev/null || true +done # ──── Generate discovery kickstart ──────────────────────────────── # Boots Fedora installer env, collects hardware info, POSTs to bastion, powers off. @@ -492,18 +565,23 @@ def print_install_started(mac, hostname): # ── HTTP Handler ────────────────────────────────────────────────── class BastionHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def __init__(self, *args, **kwargs): super().__init__(*args, directory=HTTP_DIR, **kwargs) def log_message(self, format, *args): - """Suppress default HTTP access logs — we have our own output.""" - pass + """Log HTTP requests to help debug boot issues.""" + print(f" HTTP: {self.client_address[0]} {self.command} {self.path}", flush=True) def send_text(self, code, text, content_type="text/plain"): + data = text.encode() self.send_response(code) self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(text.encode()) + self.wfile.write(data) def send_json(self, code, data): self.send_text(code, json.dumps(data, indent=2), "application/json") @@ -548,7 +626,7 @@ echo Collecting hardware info... echo ============================================= echo -kernel http://{SERVER_IP}:{HTTP_PORT}/vmlinuz inst.ks=http://{SERVER_IP}:{HTTP_PORT}/discover.ks inst.text +kernel http://{SERVER_IP}:{HTTP_PORT}/vmlinuz inst.ks=http://{SERVER_IP}:{HTTP_PORT}/discover.ks inst.stage2={FEDORA_MIRROR} inst.text initrd http://{SERVER_IP}:{HTTP_PORT}/initrd.img boot """ @@ -574,6 +652,19 @@ boot self.send_json(200, load_state()) return + # ── iPXE EFI binaries (for UEFI HTTP Boot) ── + if parsed.path in ("/ipxe.efi", "/ipxe-real.efi", "/ipxe-arm64.efi"): + tftp_dir = os.path.join(os.path.dirname(HTTP_DIR), "tftp") + fpath = os.path.join(tftp_dir, parsed.path.lstrip("/")) + if os.path.isfile(fpath): + self.send_response(200) + self.send_header("Content-Type", "application/efi") + self.send_header("Content-Length", str(os.path.getsize(fpath))) + self.end_headers() + with open(fpath, "rb") as f: + self.wfile.write(f.read()) + return + # ── Static files (vmlinuz, initrd, discover.ks, etc.) ── super().do_GET() @@ -671,12 +762,14 @@ port=0 # Listen on the right interface interface=${IFACE} -bind-interfaces +bind-dynamic $(if [[ "$DHCP_MODE" == "full" ]]; then cat << FULL_DHCP # Full DHCP mode — bastion is the only DHCP server on this network dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},255.255.255.0,12h +dhcp-option=3,${GATEWAY} +dhcp-option=6,${GATEWAY} FULL_DHCP else cat << PROXY_DHCP @@ -688,17 +781,29 @@ fi) # TFTP for initial PXE boot enable-tftp tftp-root=${TFTPDIR} +tftp-no-blocksize -# Detect client architecture +# Detect client architecture — PXE (TFTP) clients dhcp-match=set:bios,option:client-arch,0 dhcp-match=set:efi-x86_64,option:client-arch,7 dhcp-match=set:efi-x86_64,option:client-arch,9 dhcp-match=set:efi-arm64,option:client-arch,11 +# Detect client architecture — UEFI HTTP Boot clients (no TFTP size limit) +dhcp-match=set:httpboot-x86_64,option:client-arch,16 +dhcp-match=set:httpboot-arm64,option:client-arch,20 + # Detect iPXE clients (already chainloaded) dhcp-userclass=set:ipxe,iPXE -# First PXE boot → serve iPXE binary via TFTP +# UEFI HTTP Boot → serve full iPXE EFI via HTTP (no TFTP size limit) +dhcp-boot=tag:httpboot-x86_64,http://${SERVER_IP}:${HTTP_PORT}/ipxe-real.efi +dhcp-boot=tag:httpboot-arm64,http://${SERVER_IP}:${HTTP_PORT}/ipxe-arm64.efi +# Echo vendor class back to HTTP Boot clients (required by UEFI HTTP Boot spec) +dhcp-option-force=tag:httpboot-x86_64,60,HTTPClient +dhcp-option-force=tag:httpboot-arm64,60,HTTPClient + +# First PXE boot → serve iPXE binary via TFTP (BIOS and UEFI fallback) dhcp-boot=tag:bios,tag:!ipxe,undionly.kpxe dhcp-boot=tag:efi-x86_64,tag:!ipxe,ipxe.efi dhcp-boot=tag:efi-arm64,tag:!ipxe,ipxe-arm64.efi @@ -706,17 +811,27 @@ dhcp-boot=tag:efi-arm64,tag:!ipxe,ipxe-arm64.efi # iPXE clients → chain to boot script via HTTP dhcp-boot=tag:ipxe,http://${SERVER_IP}:${HTTP_PORT}/boot.ipxe +# PXE service directives (needed for proxy DHCP to respond properly) +pxe-service=tag:!ipxe,x86PC,"PXE Boot",undionly.kpxe +pxe-service=tag:!ipxe,X86-64_EFI,"PXE Boot",ipxe.efi +pxe-service=tag:!ipxe,BC_EFI,"PXE Boot",ipxe.efi +pxe-service=tag:!ipxe,ARM64_EFI,"PXE Boot",ipxe-arm64.efi + # Verbose logging log-dhcp DNSMASQ # ──── Open firewall ────────────────────────────────────────────── if command -v firewall-cmd >/dev/null && firewall-cmd --state >/dev/null 2>&1; then - log "Opening firewall ports (DHCP, TFTP, HTTP:${HTTP_PORT})..." - firewall-cmd --quiet --add-service=dhcp - firewall-cmd --quiet --add-service=tftp - firewall-cmd --quiet --add-port=${HTTP_PORT}/tcp - firewall-cmd --quiet --add-port=4011/udp 2>/dev/null || true + # Detect the zone for our interface (may differ from default zone) + FW_ZONE="$(firewall-cmd --get-zone-of-interface="${IFACE}" 2>/dev/null || echo "")" + FW_ZONE_FLAG="" + [[ -n "$FW_ZONE" ]] && FW_ZONE_FLAG="--zone=${FW_ZONE}" + log "Opening firewall ports (DHCP, TFTP, HTTP:${HTTP_PORT})${FW_ZONE:+ in zone ${FW_ZONE}}..." + firewall-cmd --quiet ${FW_ZONE_FLAG} --add-service=dhcp + firewall-cmd --quiet ${FW_ZONE_FLAG} --add-service=tftp + firewall-cmd --quiet ${FW_ZONE_FLAG} --add-port=${HTTP_PORT}/tcp + firewall-cmd --quiet ${FW_ZONE_FLAG} --add-port=4011/udp 2>/dev/null || true FW_OPENED=true fi diff --git a/pxeloader.c b/pxeloader.c new file mode 100644 index 0000000..520c9c1 --- /dev/null +++ b/pxeloader.c @@ -0,0 +1,136 @@ +/* + * Tiny UEFI PXE Loader — downloads and starts a larger EFI binary via TFTP. + * Bypasses PXE ROM TFTP buffer limits and firmware LoadImage restrictions. + * All debug output removed to minimize binary size (~20KB target). + */ +#include +#include +#include + +#define PAYLOAD "ipxe-real.efi" + +static EFI_GUID PxeGuid = EFI_PXE_BASE_CODE_PROTOCOL_GUID; +static EFI_GUID LipGuid = LOADED_IMAGE_PROTOCOL; + +EFI_STATUS EFIAPI +efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) +{ + EFI_STATUS s; + EFI_PXE_BASE_CODE_PROTOCOL *Pxe = NULL; + EFI_HANDLE *H = NULL; + UINTN HC = 0; + EFI_IP_ADDRESS Srv; + + InitializeLib(ImageHandle, SystemTable); + + /* Find active PXE protocol */ + s = uefi_call_wrapper(BS->LocateHandleBuffer, 5, + ByProtocol, &PxeGuid, NULL, &HC, &H); + if (EFI_ERROR(s)) return s; + + EFI_HANDLE PxeHandle = NULL; + for (UINTN i = 0; i < HC; i++) { + uefi_call_wrapper(BS->HandleProtocol, 3, H[i], &PxeGuid, (VOID**)&Pxe); + if (Pxe && Pxe->Mode->Started) { PxeHandle = H[i]; break; } + Pxe = NULL; + } + if (!Pxe) return EFI_NOT_FOUND; + + /* Get TFTP server IP from proxy DHCP */ + ZeroMem(&Srv, sizeof(Srv)); + if (Pxe->Mode->PxeReplyReceived) + CopyMem(&Srv.v4, &Pxe->Mode->PxeReply.Dhcpv4.BootpSiAddr, 4); + if (!Srv.v4.Addr[0] && Pxe->Mode->ProxyOfferReceived) + CopyMem(&Srv.v4, &Pxe->Mode->ProxyOffer.Dhcpv4.BootpSiAddr, 4); + if (!Srv.v4.Addr[0]) + CopyMem(&Srv.v4, &Pxe->Mode->DhcpAck.Dhcpv4.BootpSiAddr, 4); + + /* Get file size */ + UINT64 fsz = 0; + s = uefi_call_wrapper(Pxe->Mtftp, 10, Pxe, + EFI_PXE_BASE_CODE_TFTP_GET_FILE_SIZE, + NULL, FALSE, &fsz, NULL, &Srv, (UINT8*)PAYLOAD, NULL, FALSE); + if (EFI_ERROR(s)) return s; + + /* Download */ + UINTN pg = ((UINTN)fsz + 4095) / 4096; + EFI_PHYSICAL_ADDRESS ba; + s = uefi_call_wrapper(BS->AllocatePages, 4, AllocateAnyPages, EfiBootServicesData, pg, &ba); + if (EFI_ERROR(s)) return s; + VOID *buf = (VOID*)(UINTN)ba; + + s = uefi_call_wrapper(Pxe->Mtftp, 10, Pxe, + EFI_PXE_BASE_CODE_TFTP_READ_FILE, + buf, FALSE, &fsz, NULL, &Srv, (UINT8*)PAYLOAD, NULL, FALSE); + if (EFI_ERROR(s)) return s; + + /* Stop PXE so iPXE can claim the SNP protocol on this handle */ + uefi_call_wrapper(Pxe->Stop, 1, Pxe); + + /* Parse PE32+ */ + UINT8 *pe = buf; + UINT32 po = *(UINT32*)(pe + 0x3c); + UINT8 *op = pe + po + 0x18; + UINT32 erva = *(UINT32*)(op + 0x10); + UINT64 ibase = *(UINT64*)(op + 0x18); + UINT32 isz = *(UINT32*)(op + 0x38); + UINT32 hsz = *(UINT32*)(op + 0x3c); + UINT16 ns = *(UINT16*)(pe + po + 0x06); + UINT16 ohs = *(UINT16*)(pe + po + 0x14); + UINT32 ndd = *(UINT32*)(op + 0x6c); + UINT32 rrva = 0, rsz2 = 0; + if (ndd > 5) { rrva = *(UINT32*)(op+0x70+40); rsz2 = *(UINT32*)(op+0x70+44); } + + /* Load image into executable memory */ + UINTN ip = (isz + 4095) / 4096; + EFI_PHYSICAL_ADDRESS ia; + s = uefi_call_wrapper(BS->AllocatePages, 4, AllocateAnyPages, EfiBootServicesCode, ip, &ia); + if (EFI_ERROR(s)) return s; + UINT8 *img = (UINT8*)(UINTN)ia; + ZeroMem(img, isz); + CopyMem(img, pe, hsz); + + UINT8 *st = pe + po + 0x18 + ohs; + for (UINT16 i = 0; i < ns; i++) { + UINT8 *sec = st + i*40; + UINT32 va=*(UINT32*)(sec+0x0c), rs=*(UINT32*)(sec+0x10); + UINT32 ro=*(UINT32*)(sec+0x14), vs=*(UINT32*)(sec+0x08); + UINT32 cs = rs < vs ? rs : vs; + if (rs && ro+cs <= (UINT32)fsz) CopyMem(img+va, pe+ro, cs); + } + + /* Relocate */ + INT64 d = (INT64)((UINT64)(UINTN)img - ibase); + if (d && rrva && rsz2) { + UINT8 *r = img+rrva, *re = r+rsz2; + while (r < re) { + UINT32 prv=*(UINT32*)r, bsz=*(UINT32*)(r+4); + if (!bsz) break; + UINT16 *e = (UINT16*)(r+8); + for (UINTN j = 0; j < (bsz-8)/2; j++) + if ((e[j]>>12)==10) *(UINT64*)(img+prv+(e[j]&0xFFF)) += d; + r += bsz; + } + } + + /* Install EFI_LOADED_IMAGE_PROTOCOL */ + EFI_LOADED_IMAGE lip; + ZeroMem(&lip, sizeof(lip)); + lip.Revision = EFI_LOADED_IMAGE_PROTOCOL_REVISION; + lip.ParentHandle = ImageHandle; + lip.DeviceHandle = PxeHandle; + lip.SystemTable = SystemTable; + lip.ImageBase = img; + lip.ImageSize = isz; + lip.ImageCodeType = EfiBootServicesCode; + lip.ImageDataType = EfiBootServicesData; + + EFI_HANDLE nh = NULL; + s = uefi_call_wrapper(BS->InstallProtocolInterface, 4, + &nh, &LipGuid, EFI_NATIVE_INTERFACE, &lip); + if (EFI_ERROR(s)) return s; + + /* Start iPXE */ + typedef EFI_STATUS (EFIAPI *ENTRY)(EFI_HANDLE, EFI_SYSTEM_TABLE*); + return ((ENTRY)(img + erva))(nh, SystemTable); +}