Files
lab/bastion.sh
Michal Rydlikowski ac695f506f first commit
2026-03-15 23:50:43 +00:00

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
}