338 lines
13 KiB
Bash
Executable File
338 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Lab PXE Bastion — ephemeral PXE server for bare-metal provisioning
|
|
#
|
|
# Turns this machine into a temporary PXE boot server. Target machines
|
|
# on the same network can PXE boot and get Fedora installed automatically.
|
|
#
|
|
# Usage:
|
|
# sudo bash bastion.sh # interactive, auto-detect everything
|
|
# sudo TARGET_HOSTNAME=puppet SSH_PUBKEY=~/.ssh/id_ed25519.pub bash bastion.sh
|
|
#
|
|
# Requirements: Fedora/RHEL host with dnsmasq, python3, curl
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
set -euo pipefail
|
|
|
|
# ──── Defaults (override via environment) ──────────────────────────
|
|
FEDORA_VERSION="${FEDORA_VERSION:-41}"
|
|
ARCH="${ARCH:-x86_64}"
|
|
HTTP_PORT="${HTTP_PORT:-8080}"
|
|
TARGET_HOSTNAME="${TARGET_HOSTNAME:-lab-node}"
|
|
TARGET_DISK="${TARGET_DISK:-}" # empty = anaconda auto-picks
|
|
SSH_PUBKEY="${SSH_PUBKEY:-}" # path to .pub file, auto-detected
|
|
TIMEZONE="${TIMEZONE:-Europe/London}"
|
|
LOCALE="${LOCALE:-en_GB.UTF-8}"
|
|
BASTION_DIR="${BASTION_DIR:-/tmp/lab-bastion}"
|
|
|
|
# ──── Colors ───────────────────────────────────────────────────────
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
|
|
|
log() { echo -e "${GREEN}[bastion]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[bastion]${NC} $*"; }
|
|
err() { echo -e "${RED}[bastion]${NC} $*" >&2; }
|
|
die() { err "$@"; exit 1; }
|
|
|
|
# ──── Preflight ────────────────────────────────────────────────────
|
|
[[ $EUID -eq 0 ]] || die "Must run as root (need DHCP/TFTP ports). Use: sudo bash bastion.sh"
|
|
|
|
command -v python3 >/dev/null || die "python3 not found"
|
|
command -v curl >/dev/null || die "curl not found"
|
|
|
|
# Install dnsmasq if missing
|
|
if ! command -v dnsmasq >/dev/null; then
|
|
log "Installing dnsmasq..."
|
|
if command -v dnf >/dev/null; then
|
|
dnf install -y dnsmasq
|
|
elif command -v apt-get >/dev/null; then
|
|
apt-get install -y dnsmasq
|
|
else
|
|
die "Cannot install dnsmasq — install it manually"
|
|
fi
|
|
fi
|
|
|
|
# ──── Auto-detect network ─────────────────────────────────────────
|
|
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"}')"
|
|
|
|
[[ -n "$SERVER_IP" ]] || die "Cannot detect IP on interface $IFACE"
|
|
log "Interface: ${BOLD}$IFACE${NC} IP: ${BOLD}$SERVER_IP${NC} Network: ${BOLD}$NETWORK${NC}"
|
|
|
|
# ──── Auto-detect SSH pubkey ───────────────────────────────────────
|
|
if [[ -z "$SSH_PUBKEY" ]]; then
|
|
# When run via sudo, check the real user's home
|
|
REAL_HOME="${HOME}"
|
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
|
REAL_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
|
fi
|
|
for keyfile in "$REAL_HOME/.ssh/id_ed25519.pub" "$REAL_HOME/.ssh/id_rsa.pub" "$REAL_HOME/.ssh/id_ecdsa.pub"; do
|
|
if [[ -f "$keyfile" ]]; then
|
|
SSH_PUBKEY="$keyfile"
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ -n "$SSH_PUBKEY" && -f "$SSH_PUBKEY" ]]; then
|
|
SSH_KEY_CONTENT="$(cat "$SSH_PUBKEY")"
|
|
log "SSH key: ${BOLD}$SSH_PUBKEY${NC}"
|
|
else
|
|
warn "No SSH public key found. Root password will be set to 'changeme'."
|
|
warn "Set SSH_PUBKEY=/path/to/key.pub to use key-based auth instead."
|
|
SSH_KEY_CONTENT=""
|
|
fi
|
|
|
|
# ──── Prepare directories ─────────────────────────────────────────
|
|
TFTPDIR="$BASTION_DIR/tftp"
|
|
HTTPDIR="$BASTION_DIR/http"
|
|
mkdir -p "$TFTPDIR" "$HTTPDIR"
|
|
|
|
# ──── Cleanup handler ─────────────────────────────────────────────
|
|
DNSMASQ_PID=""
|
|
HTTP_PID=""
|
|
FW_OPENED=false
|
|
|
|
cleanup() {
|
|
echo ""
|
|
log "Shutting down..."
|
|
[[ -n "$DNSMASQ_PID" ]] && kill "$DNSMASQ_PID" 2>/dev/null && log "Stopped dnsmasq"
|
|
[[ -n "$HTTP_PID" ]] && kill "$HTTP_PID" 2>/dev/null && log "Stopped HTTP server"
|
|
|
|
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-service=proxy-dhcp 2>/dev/null || true
|
|
fi
|
|
|
|
log "Done. Bastion artifacts remain in $BASTION_DIR"
|
|
log "Re-run this script to reprovision. Remove with: rm -rf $BASTION_DIR"
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# ──── Download artifacts (cached) ─────────────────────────────────
|
|
download() {
|
|
local url="$1" dest="$2" label="$3"
|
|
if [[ -f "$dest" ]]; then
|
|
log " ${label} — cached"
|
|
return
|
|
fi
|
|
log " ${label} — downloading..."
|
|
curl -# -L -o "$dest" "$url" || die "Failed to download $label from $url"
|
|
}
|
|
|
|
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"
|
|
download "${FEDORA_MIRROR}/images/pxeboot/vmlinuz" "$HTTPDIR/vmlinuz" "Fedora kernel"
|
|
download "${FEDORA_MIRROR}/images/pxeboot/initrd.img" "$HTTPDIR/initrd.img" "Fedora initrd"
|
|
|
|
# ──── Generate kickstart ──────────────────────────────────────────
|
|
log "Generating kickstart for ${BOLD}${TARGET_HOSTNAME}${NC}..."
|
|
|
|
# Disk config
|
|
if [[ -n "$TARGET_DISK" ]]; then
|
|
DISK_CMDS="ignoredisk --only-use=${TARGET_DISK}
|
|
clearpart --all --initlabel --drives=${TARGET_DISK}
|
|
autopart --type=plain"
|
|
else
|
|
DISK_CMDS="clearpart --all --initlabel
|
|
autopart --type=plain"
|
|
fi
|
|
|
|
# Auth config
|
|
if [[ -n "$SSH_KEY_CONTENT" ]]; then
|
|
AUTH_CMDS="rootpw --lock
|
|
sshkey --username=root \"${SSH_KEY_CONTENT}\""
|
|
else
|
|
AUTH_CMDS='rootpw --plaintext changeme'
|
|
fi
|
|
|
|
cat > "$HTTPDIR/ks.cfg" << KICKSTART
|
|
# Lab Bastion — Fedora ${FEDORA_VERSION} kickstart
|
|
# Generated: $(date -Iseconds)
|
|
# Target: ${TARGET_HOSTNAME}
|
|
|
|
# Install mode
|
|
text
|
|
reboot
|
|
|
|
# Locale
|
|
lang ${LOCALE}
|
|
keyboard uk
|
|
timezone ${TIMEZONE} --utc
|
|
|
|
# Network
|
|
network --bootproto=dhcp --activate --hostname=${TARGET_HOSTNAME}
|
|
|
|
# Auth
|
|
${AUTH_CMDS}
|
|
|
|
# Disk
|
|
${DISK_CMDS}
|
|
|
|
# Bootloader
|
|
bootloader --append="console=tty0 console=ttyS0,115200n8"
|
|
|
|
# Install source
|
|
url --mirrorlist=https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-\$releasever&arch=\$basearch
|
|
|
|
# Packages — minimal server + essentials
|
|
%packages
|
|
@core
|
|
@server-product
|
|
openssh-server
|
|
vim-enhanced
|
|
tmux
|
|
git
|
|
curl
|
|
python3
|
|
dnf-plugins-core
|
|
%end
|
|
|
|
# Post-install
|
|
%post --log=/root/bastion-post-install.log
|
|
#!/bin/bash
|
|
set -x
|
|
|
|
# Ensure SSH is enabled
|
|
systemctl enable --now sshd
|
|
|
|
# Allow root SSH with key (password auth disabled)
|
|
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
|
|
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
|
|
|
# Set hostname
|
|
hostnamectl set-hostname ${TARGET_HOSTNAME}
|
|
|
|
# Leave a breadcrumb
|
|
echo "Provisioned by lab-bastion on $(date -Iseconds)" > /etc/lab-provisioned
|
|
|
|
# Placeholder: puppet enrollment will go here later
|
|
# puppet is not installed yet — this IS the puppet server
|
|
echo "# Lab bootstrap node — puppet server setup pending" > /root/README
|
|
|
|
%end
|
|
KICKSTART
|
|
|
|
log "Kickstart written to ${HTTPDIR}/ks.cfg"
|
|
|
|
# ──── Generate iPXE boot script ───────────────────────────────────
|
|
cat > "$HTTPDIR/boot.ipxe" << IPXE
|
|
#!ipxe
|
|
|
|
echo
|
|
echo =======================================
|
|
echo Lab PXE Bastion — Fedora ${FEDORA_VERSION}
|
|
echo Target: ${TARGET_HOSTNAME}
|
|
echo =======================================
|
|
echo
|
|
|
|
kernel http://${SERVER_IP}:${HTTP_PORT}/vmlinuz inst.ks=http://${SERVER_IP}:${HTTP_PORT}/ks.cfg inst.repo=${FEDORA_MIRROR} inst.text
|
|
initrd http://${SERVER_IP}:${HTTP_PORT}/initrd.img
|
|
boot
|
|
IPXE
|
|
|
|
# ──── Generate dnsmasq config ─────────────────────────────────────
|
|
cat > "$BASTION_DIR/dnsmasq.conf" << DNSMASQ
|
|
# Lab PXE Bastion — dnsmasq config
|
|
# ProxyDHCP mode: adds PXE options without replacing existing DHCP
|
|
|
|
# Disable DNS (we only want DHCP/TFTP)
|
|
port=0
|
|
|
|
# Listen on the right interface
|
|
interface=${IFACE}
|
|
bind-interfaces
|
|
|
|
# ProxyDHCP — works alongside existing DHCP (UniFi etc)
|
|
dhcp-range=${NETWORK},proxy
|
|
|
|
# TFTP for initial PXE boot
|
|
enable-tftp
|
|
tftp-root=${TFTPDIR}
|
|
|
|
# Detect client architecture
|
|
dhcp-match=set:bios,option:client-arch,0
|
|
dhcp-match=set:efi64,option:client-arch,7
|
|
dhcp-match=set:efi64,option:client-arch,9
|
|
|
|
# Detect iPXE clients (already chainloaded)
|
|
dhcp-userclass=set:ipxe,iPXE
|
|
|
|
# First PXE boot → serve iPXE binary via TFTP
|
|
dhcp-boot=tag:bios,tag:!ipxe,undionly.kpxe
|
|
dhcp-boot=tag:efi64,tag:!ipxe,ipxe.efi
|
|
|
|
# iPXE clients → chain to boot script via HTTP
|
|
dhcp-boot=tag:ipxe,http://${SERVER_IP}:${HTTP_PORT}/boot.ipxe
|
|
|
|
# Verbose logging (see what's happening)
|
|
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
|
|
# ProxyDHCP uses port 4011
|
|
firewall-cmd --quiet --add-port=4011/udp 2>/dev/null || true
|
|
FW_OPENED=true
|
|
fi
|
|
|
|
# ──── Stop conflicting services ───────────────────────────────────
|
|
# dnsmasq might be running as a system service
|
|
if systemctl is-active --quiet dnsmasq 2>/dev/null; then
|
|
warn "System dnsmasq is running — stopping it temporarily"
|
|
systemctl stop dnsmasq
|
|
RESTART_DNSMASQ=true
|
|
fi
|
|
|
|
# ──── Start HTTP server ───────────────────────────────────────────
|
|
log "Starting HTTP server on :${HTTP_PORT}..."
|
|
(cd "$HTTPDIR" && python3 -m http.server "$HTTP_PORT" --bind 0.0.0.0 >/dev/null 2>&1) &
|
|
HTTP_PID=$!
|
|
sleep 0.5
|
|
|
|
if ! kill -0 "$HTTP_PID" 2>/dev/null; then
|
|
die "HTTP server failed to start — is port ${HTTP_PORT} in use?"
|
|
fi
|
|
|
|
# ──── Start dnsmasq (proxyDHCP + TFTP) ────────────────────────────
|
|
log "Starting PXE server (proxyDHCP on ${IFACE})..."
|
|
echo ""
|
|
echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════${NC}"
|
|
echo -e "${CYAN}${BOLD} PXE Bastion ready!${NC}"
|
|
echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════${NC}"
|
|
echo ""
|
|
echo -e " Network: ${BOLD}${NETWORK}/24${NC} via ${BOLD}${IFACE}${NC}"
|
|
echo -e " HTTP: ${BOLD}http://${SERVER_IP}:${HTTP_PORT}/${NC}"
|
|
echo -e " OS: ${BOLD}Fedora ${FEDORA_VERSION} (${ARCH})${NC}"
|
|
echo -e " Hostname: ${BOLD}${TARGET_HOSTNAME}${NC}"
|
|
echo -e " Kickstart: ${BOLD}http://${SERVER_IP}:${HTTP_PORT}/ks.cfg${NC}"
|
|
echo ""
|
|
echo -e " ${YELLOW}Now PXE-boot the target machine.${NC}"
|
|
echo -e " ${YELLOW}Set boot order to Network/PXE in BIOS, or use one-time boot menu.${NC}"
|
|
echo ""
|
|
echo -e " Press ${BOLD}Ctrl-C${NC} to stop the bastion."
|
|
echo ""
|
|
echo -e "${CYAN}──── dnsmasq log (watch for DHCP/PXE requests) ────${NC}"
|
|
echo ""
|
|
|
|
# Run dnsmasq in foreground so logs stream to terminal
|
|
dnsmasq --no-daemon --conf-file="$BASTION_DIR/dnsmasq.conf" &
|
|
DNSMASQ_PID=$!
|
|
|
|
# Wait for dnsmasq — if it exits, something went wrong
|
|
wait "$DNSMASQ_PID" || {
|
|
err "dnsmasq exited unexpectedly. Check if another DHCP/TFTP service is running."
|
|
err "Try: ss -ulnp | grep -E ':(67|69|4011) '"
|
|
exit 1
|
|
}
|