217 lines
8.0 KiB
Bash
217 lines
8.0 KiB
Bash
|
|
#!/bin/bash
|
||
|
|
# Shared helpers for the lab network simulation.
|
||
|
|
# shellcheck disable=SC2034
|
||
|
|
|
||
|
|
LIBVIRT_URI="${LIBVIRT_URI:-qemu:///system}"
|
||
|
|
IMG_DIR="${IMG_DIR:-/var/lib/libvirt/images/labsim}"
|
||
|
|
BASE_IMAGE="${BASE_IMAGE:-$IMG_DIR/alpine-base.qcow2}"
|
||
|
|
ALPINE_URL="${ALPINE_URL:-https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2}"
|
||
|
|
|
||
|
|
VM_MEM="${VM_MEM:-256}" # MB — Alpine is happy here
|
||
|
|
VM_CPUS="${VM_CPUS:-1}"
|
||
|
|
VM_DISK="${VM_DISK:-1G}"
|
||
|
|
PREFIX="labsim"
|
||
|
|
|
||
|
|
CONF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vlans.conf"
|
||
|
|
|
||
|
|
log() { printf '\033[0;36m[labsim]\033[0m %s\n' "$*"; }
|
||
|
|
warn() { printf '\033[1;33m[labsim]\033[0m %s\n' "$*" >&2; }
|
||
|
|
die() { printf '\033[0;31m[labsim]\033[0m %s\n' "$*" >&2; exit 1; }
|
||
|
|
|
||
|
|
virsh_q() { sudo virsh --connect "$LIBVIRT_URI" "$@"; }
|
||
|
|
|
||
|
|
net_name() { echo "${PREFIX}-vlan$1"; }
|
||
|
|
vm_name() { echo "${PREFIX}-$1-$2"; } # labsim-2-k8s
|
||
|
|
# Linux bridge names are capped at 15 chars — keep it short and unique.
|
||
|
|
br_name() { echo "vbr-ls$1"; }
|
||
|
|
|
||
|
|
require_tools() {
|
||
|
|
for t in virsh virt-install qemu-img genisoimage; do
|
||
|
|
command -v "$t" >/dev/null 2>&1 || die "missing required tool: $t"
|
||
|
|
done
|
||
|
|
sudo -n true 2>/dev/null || warn "sudo may prompt for a password"
|
||
|
|
}
|
||
|
|
|
||
|
|
find_ssh_pubkey() {
|
||
|
|
local home="${SUDO_USER:+/home/$SUDO_USER}"
|
||
|
|
home="${home:-$HOME}"
|
||
|
|
for n in id_ed25519 id_ecdsa id_rsa; do
|
||
|
|
[ -f "$home/.ssh/$n.pub" ] && { cat "$home/.ssh/$n.pub"; return; }
|
||
|
|
done
|
||
|
|
die "no SSH public key found in $home/.ssh"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Populate SELECTED[] from argv (VLAN ids) or the whole config.
|
||
|
|
selected_vlans() {
|
||
|
|
SELECTED=()
|
||
|
|
local want=("$@")
|
||
|
|
while IFS= read -r line; do
|
||
|
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||
|
|
[[ -z "${line// }" ]] && continue
|
||
|
|
local vid="${line%%:*}"
|
||
|
|
if [ ${#want[@]} -eq 0 ]; then
|
||
|
|
SELECTED+=("$line")
|
||
|
|
else
|
||
|
|
for w in "${want[@]}"; do [ "$w" = "$vid" ] && SELECTED+=("$line"); done
|
||
|
|
fi
|
||
|
|
done < "$CONF"
|
||
|
|
[ ${#SELECTED[@]} -gt 0 ] || die "no VLANs selected (checked $CONF)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# cloud-init NoCloud seed: static addressing + SSH key + hello-world HTTP.
|
||
|
|
build_seed() {
|
||
|
|
local iso="$1" vm="$2" vid="$3" name="$4" prefix="$5" ip="$6" real="$7" pubkey="$8"
|
||
|
|
local tmp; tmp="$(mktemp -d)"
|
||
|
|
|
||
|
|
cat > "$tmp/meta-data" <<EOF
|
||
|
|
instance-id: $vm
|
||
|
|
local-hostname: $vm
|
||
|
|
EOF
|
||
|
|
|
||
|
|
# Alpine's cloud-init does not reliably apply netplan-style network-config,
|
||
|
|
# and these networks have no DHCP server on purpose — so configure the
|
||
|
|
# interface the Alpine-native way instead (verified: hostname applied but no
|
||
|
|
# address, i.e. the seed was read and network-config was ignored).
|
||
|
|
#
|
||
|
|
# The default route deliberately points at the router under test (.1), not
|
||
|
|
# the host (.2), so a broken/absent router shows up as a failed test rather
|
||
|
|
# than being silently papered over by host routing. post-up ... || true keeps
|
||
|
|
# the interface up even while no router exists yet.
|
||
|
|
cat > "$tmp/network-config" <<EOF
|
||
|
|
version: 1
|
||
|
|
config:
|
||
|
|
- type: physical
|
||
|
|
name: eth0
|
||
|
|
subnets:
|
||
|
|
- type: static
|
||
|
|
address: $ip
|
||
|
|
netmask: 255.255.255.0
|
||
|
|
# Default route via the router under test. Without this the VMs can
|
||
|
|
# reach their own /24 and their gateway, but nothing beyond it — which
|
||
|
|
# looks exactly like "the router is broken" in the matrix.
|
||
|
|
gateway: ${prefix}.1
|
||
|
|
EOF
|
||
|
|
|
||
|
|
cat > "$tmp/user-data" <<EOF
|
||
|
|
#cloud-config
|
||
|
|
hostname: $vm
|
||
|
|
users:
|
||
|
|
- name: alpine
|
||
|
|
# NOTE: this Alpine image ships no sudo (and cloud-init's sudo: directive
|
||
|
|
# is therefore inert). For privileged work in these VMs, ssh as root —
|
||
|
|
# the key is installed there too.
|
||
|
|
shell: /bin/ash
|
||
|
|
# Without this cloud-init leaves the account locked ("!*" in /etc/shadow)
|
||
|
|
# and sshd refuses key auth for it — verified on the first build.
|
||
|
|
lock_passwd: false
|
||
|
|
plain_text_passwd: labsim
|
||
|
|
ssh_authorized_keys:
|
||
|
|
- $pubkey
|
||
|
|
ssh_authorized_keys:
|
||
|
|
- $pubkey
|
||
|
|
disable_root: false
|
||
|
|
chpasswd:
|
||
|
|
list: |
|
||
|
|
root:labsim
|
||
|
|
expire: false
|
||
|
|
write_files:
|
||
|
|
- path: /etc/network/interfaces
|
||
|
|
content: |
|
||
|
|
auto lo
|
||
|
|
iface lo inet loopback
|
||
|
|
auto eth0
|
||
|
|
iface eth0 inet static
|
||
|
|
address $ip
|
||
|
|
netmask 255.255.255.0
|
||
|
|
post-up ip route add default via ${prefix}.1 || true
|
||
|
|
- path: /var/www/index.html
|
||
|
|
content: |
|
||
|
|
<html><body>
|
||
|
|
<h1>labsim vlan $vid — $name</h1>
|
||
|
|
<p>host: $vm</p>
|
||
|
|
<p>address: $ip/24</p>
|
||
|
|
<p>gateway under test: ${prefix}.1</p>
|
||
|
|
<p>mirrors production: $real</p>
|
||
|
|
</body></html>
|
||
|
|
- path: /etc/local.d/labsim-http.start
|
||
|
|
permissions: '0755'
|
||
|
|
content: |
|
||
|
|
#!/bin/sh
|
||
|
|
# This image's busybox has no httpd applet ("applet not found"), and the
|
||
|
|
# VMs are isolated so apk cannot fetch one. python3 is already present
|
||
|
|
# (cloud-init depends on it), so serve with http.server — no packages,
|
||
|
|
# no internet.
|
||
|
|
#
|
||
|
|
# Two traps already hit here, both silent:
|
||
|
|
# - start-stop-daemon --exec /usr/bin/python3 matches cloud-init's OWN
|
||
|
|
# python3 at boot, says "already running", starts nothing.
|
||
|
|
# - busybox pgrep -f PATTERN matches its own argv, so a
|
||
|
|
# "skip if running" guard always fires (verified: guard_exit=0 with
|
||
|
|
# nothing listening).
|
||
|
|
# So: no guard, no start-stop-daemon. Binding twice is harmless — the
|
||
|
|
# second just fails to bind.
|
||
|
|
nohup /usr/bin/python3 -m http.server 80 --directory /var/www \\
|
||
|
|
>/var/log/labsim-http.log 2>&1 &
|
||
|
|
runcmd:
|
||
|
|
# cloud-init's network-config (v1, above) already applies the address, so do
|
||
|
|
# NOT restart networking here — it fails, and one failing runcmd aborts every
|
||
|
|
# command after it, which is what silently left httpd unstarted. Each command
|
||
|
|
# is || true for the same reason.
|
||
|
|
- [ sh, -c, "rc-update add sshd default || true" ]
|
||
|
|
- [ sh, -c, "rc-update add local default || true" ]
|
||
|
|
- [ sh, -c, "/etc/local.d/labsim-http.start || true" ]
|
||
|
|
EOF
|
||
|
|
|
||
|
|
# Validate before building the ISO. The heredoc above is intentionally
|
||
|
|
# unquoted (it interpolates $ip/$prefix), which means backticks or $( ) in
|
||
|
|
# ANY line — including comments — get executed by the host shell and their
|
||
|
|
# output silently corrupts the YAML. Cheap check, expensive bug.
|
||
|
|
python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$tmp/user-data" \
|
||
|
|
|| die "generated user-data is not valid YAML (backticks or \$( ) in build_seed?): $tmp/user-data"
|
||
|
|
|
||
|
|
sudo genisoimage -quiet -output "$iso" -volid cidata -joliet -rock \
|
||
|
|
"$tmp/user-data" "$tmp/meta-data" "$tmp/network-config"
|
||
|
|
rm -rf "$tmp"
|
||
|
|
}
|
||
|
|
|
||
|
|
ssh_to() {
|
||
|
|
local ip="$1"; shift
|
||
|
|
timeout 12 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||
|
|
-o ConnectTimeout=5 -o BatchMode=yes -o LogLevel=ERROR \
|
||
|
|
"alpine@$ip" "$@" 2>/dev/null
|
||
|
|
}
|
||
|
|
|
||
|
|
wait_ready() {
|
||
|
|
local deadline=$((SECONDS + 240)) pending=1
|
||
|
|
while [ $SECONDS -lt $deadline ]; do
|
||
|
|
pending=0
|
||
|
|
for entry in "${SELECTED[@]}"; do
|
||
|
|
IFS=: read -r vid _n prefix _r <<<"$entry"
|
||
|
|
# Wait for BOTH: sshd is up well before cloud-init's runcmd starts the
|
||
|
|
# web server, so checking SSH alone reports "ready" then shows HTTP FAIL.
|
||
|
|
ssh_to "${prefix}.10" true >/dev/null 2>&1 \
|
||
|
|
&& curl -sS -o /dev/null --max-time 4 "http://${prefix}.10/" 2>/dev/null \
|
||
|
|
|| pending=$((pending + 1))
|
||
|
|
done
|
||
|
|
[ $pending -eq 0 ] && { log "all ${#SELECTED[@]} VMs reachable"; return 0; }
|
||
|
|
sleep 5
|
||
|
|
done
|
||
|
|
warn "$pending VM(s) still not answering SSH after 240s — see status below"
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
status_table() {
|
||
|
|
printf ' %-18s %-6s %-16s %-9s %-7s %s\n' VM VLAN ADDRESS STATE SSH HTTP
|
||
|
|
printf ' %-18s %-6s %-16s %-9s %-7s %s\n' ------------------ ------ ---------------- --------- ------- ----
|
||
|
|
for entry in "${SELECTED[@]}"; do
|
||
|
|
IFS=: read -r vid name prefix _r <<<"$entry"
|
||
|
|
local vm ip state ssh http
|
||
|
|
vm="$(vm_name "$vid" "$name")"; ip="${prefix}.10"
|
||
|
|
state="$(virsh_q domstate "$vm" 2>/dev/null | head -1 | tr -d '\n')"
|
||
|
|
[ -z "$state" ] && state="absent"
|
||
|
|
ssh_to "$ip" true >/dev/null 2>&1 && ssh=ok || ssh=FAIL
|
||
|
|
if curl -sS -o /dev/null --max-time 5 "http://$ip/" 2>/dev/null; then http=ok; else http=FAIL; fi
|
||
|
|
printf ' %-18s %-6s %-16s %-9s %-7s %s\n' "$vm" "$vid" "$ip" "$state" "$ssh" "$http"
|
||
|
|
done
|
||
|
|
}
|