diff --git a/radius-harness/.gitignore b/radius-harness/.gitignore new file mode 100644 index 00000000..6aac2f89 --- /dev/null +++ b/radius-harness/.gitignore @@ -0,0 +1,3 @@ +/artifacts/ +/images/ +/run/ diff --git a/radius-harness/README.md b/radius-harness/README.md new file mode 100644 index 00000000..c1a0d1c9 --- /dev/null +++ b/radius-harness/README.md @@ -0,0 +1,78 @@ +# LibreQoS RADIUS PPPoE harness + +This disposable libvirt harness verifies the RADIUS accounting lifecycle end to +end: a RouterOS CHR NAS authenticates a PPPoE client with FreeRADIUS, sends +accounting to a root-run LibreQoS guest, and the test verifies dynamic-circuit +creation, profile selection, Interim-Update, and removal on disconnect. + +It creates `radius-*` libvirt domains and networks, sparse qcow2 overlays, and +temporary `vbr-r*` host-side bridge interfaces for its isolated networks. +`./radius-harness/lab down` removes them with the overlays and VMs. `purge` +additionally removes downloaded base images and the host-built runtime bundle. +The harness does not change existing host interfaces, LibreQoS services, or +host shaping. + +The `radius-*` domain and network names are reserved for this harness. Do not +run `down` or `purge` on a host where another workload intentionally uses one +of those names. + +## Requirements + +Run from a LibreQoS checkout on a host with libvirt/KVM and OVMF/edk2 UEFI +firmware. Install `virsh`, `virt-install`, `qemu-img`, `cloud-localds` (from +`cloud-image-utils`), `curl`, `unzip`, `rsync`, `sshpass`, and a Rust toolchain. +The invoking account must be allowed to manage the configured libvirt URI +(default: `qemu:///system`). + +The harness discovers common OVMF locations. If your distribution stores its +firmware elsewhere, set `OVMF_CODE_PATH` and `OVMF_VARS_PATH` in the environment +before `up`. + +The pinned image hashes are in [lab.env](lab.env). Review and update the URL, +version, and SHA-256 together when changing an image. + +## Run + +Set the one-time RouterOS administrator password and the lab-only RADIUS shared +secret. They are never committed and are written only to ignored run-state and +disposable guests. + +```bash +export ROUTEROS_ADMIN_PASSWORD='a-routeros-password' +export RADIUS_SHARED_SECRET='a-lab-radius-secret' + +./radius-harness/lab init +./radius-harness/lab up +./radius-harness/lab console # set the RouterOS admin password once; exit with Ctrl+] +./radius-harness/lab configure +./radius-harness/lab test +./radius-harness/lab down +``` + +`init` downloads the pinned Ubuntu and RouterOS bases once and builds `lqosd` +and `lqos_python` on the host. The guest receives only the resulting runtime +bundle, not the Rust build tree. + +The test covers three cases: + +1. A RADIUS rate-limit attribute supplies the queue speed. +2. A known PPPoE username in the `MAC` field of `ShapedDevices.csv` supplies + the circuit identity and speed when RADIUS supplies no rate. +3. An unknown username receives the configured fallback queue. + +The harness uses temporary IPv4 ranges `198.18.10.0/24`, `198.18.30.0/24`, and +`100.64.0.0/16`. If those conflict with local infrastructure, change the +templates before running it. + +## Commands + +```text +./radius-harness/lab init Download bases and build the runtime bundle. +./radius-harness/lab up Create networks, overlays, and VMs. +./radius-harness/lab status Show domains and management addresses. +./radius-harness/lab configure Install fixtures and start services. +./radius-harness/lab test Run the lifecycle assertions. +./radius-harness/lab down Remove VMs, networks, overlays, and secrets. +./radius-harness/lab purge Also remove cached images and runtime artifacts. +./radius-harness/lab console Open the RouterOS serial console. +``` diff --git a/radius-harness/lab b/radius-harness/lab new file mode 100755 index 00000000..da698b58 --- /dev/null +++ b/radius-harness/lab @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")" && pwd) +source "$LAB_ROOT/lib/common.sh" +source "$LAB_ROOT/lab.env" + +readonly UBUNTU_BASE="$IMAGE_DIR/$(basename "$UBUNTU_IMAGE_URL")" +readonly ROUTER_ARCHIVE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.img.zip" +readonly ROUTER_BASE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.qcow2" +readonly SSH_KEY="$RUN_DIR/id_ed25519" +OVMF_CODE= +OVMF_VARS= + +usage() { + cat <<'EOF' +Usage: ./radius-harness/lab + +Commands: init up status configure test down purge console +EOF +} + +require_host_tools() { + for tool in virsh virt-install qemu-img cloud-localds curl unzip sha256sum rsync ssh scp; do require_command "$tool"; done +} + +ensure_ssh_key() { + mkdir -p "$RUN_DIR" + [[ -f $SSH_KEY ]] || ssh-keygen -q -t ed25519 -N '' -f "$SSH_KEY" +} + +resolve_ovmf() { + local code_path vars_path + if [[ -n ${OVMF_CODE_PATH:-} || -n ${OVMF_VARS_PATH:-} ]]; then + [[ -n ${OVMF_CODE_PATH:-} && -n ${OVMF_VARS_PATH:-} ]] || die "set both OVMF_CODE_PATH and OVMF_VARS_PATH" + [[ -r $OVMF_CODE_PATH && -r $OVMF_VARS_PATH ]] || die "configured OVMF firmware paths are not readable" + OVMF_CODE=$OVMF_CODE_PATH + OVMF_VARS=$OVMF_VARS_PATH + return + fi + + for code_path in \ + /usr/share/OVMF/OVMF_CODE_4M.fd \ + /usr/share/OVMF/OVMF_CODE.fd \ + /usr/share/edk2-ovmf/x64/OVMF_CODE.fd \ + /usr/share/edk2/ovmf/OVMF_CODE.fd; do + vars_path=${code_path/CODE/VARS} + if [[ -r $code_path && -r $vars_path ]]; then + OVMF_CODE=$code_path + OVMF_VARS=$vars_path + return + fi + done + die "OVMF firmware was not found; install OVMF/edk2 or set OVMF_CODE_PATH and OVMF_VARS_PATH" +} + +download_routeros() { + [[ -n ${ROUTEROS_CHR_SHA256:-} ]] || die "set ROUTEROS_CHR_SHA256 in lab.env before downloading RouterOS" + if [[ ! -f $ROUTER_ARCHIVE ]]; then + note "downloading RouterOS CHR $ROUTEROS_VERSION" + curl --fail --location --output "$ROUTER_ARCHIVE.partial" "$ROUTEROS_CHR_URL" + printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE.partial" | sha256sum --check --status || die "RouterOS archive checksum did not match" + mv "$ROUTER_ARCHIVE.partial" "$ROUTER_ARCHIVE" + fi + printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE" | sha256sum --check --status || die "cached RouterOS archive checksum did not match" + if [[ ! -f $ROUTER_BASE ]]; then + unzip -p "$ROUTER_ARCHIVE" >"$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" + qemu-img convert -f raw -O qcow2 "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" "$ROUTER_BASE" + rm -f "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" + fi +} + +download_ubuntu() { + [[ -n ${UBUNTU_IMAGE_SHA256:-} ]] || die "set UBUNTU_IMAGE_SHA256 in lab.env before downloading Ubuntu" + if [[ ! -f $UBUNTU_BASE ]]; then + note "downloading Ubuntu 24.04 cloud image" + curl --fail --location --output "$UBUNTU_BASE.partial" "$UBUNTU_IMAGE_URL" + printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE.partial" | sha256sum --check --status || die "Ubuntu image checksum did not match" + mv "$UBUNTU_BASE.partial" "$UBUNTU_BASE" + fi + printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE" | sha256sum --check --status || die "cached Ubuntu image checksum did not match" +} + +build_runtime_bundle() { + [[ -d $REPO_ROOT/src/rust ]] || die "LibreQoS checkout not found: $REPO_ROOT" + note "building lqosd and lqos_python on the host" + (cd "$REPO_ROOT/src/rust" && cargo build --release -p lqosd -p lqos_python) + safe_remove "$RUNTIME_DIR" + mkdir -p "$RUNTIME_DIR/bin" "$RUNTIME_DIR/src" + install -m 0755 "$REPO_ROOT/src/rust/target/release/lqosd" "$RUNTIME_DIR/bin/lqosd" + install -m 0755 "$REPO_ROOT/src/rust/target/release/liblqos_python.so" "$RUNTIME_DIR/liblqos_python.so" + rsync -a --delete --exclude rust --exclude dist --exclude bin --exclude geo2.bin --exclude lts2 --exclude '*.pyc' --exclude '__pycache__' "$REPO_ROOT/src/" "$RUNTIME_DIR/src/" +} + +create_network() { local name=$1 xml=$2; if ! network_exists "$name"; then virsh_lab net-define "$xml"; virsh_lab net-start "$name"; fi; } +destroy_network() { local name=$1; if network_exists "$name"; then virsh_lab net-destroy "$name" >/dev/null 2>&1 || true; virsh_lab net-undefine "$name"; fi; } +create_overlay() { local name=$1 backing=$2 size=${3:-}; if [[ -n $size ]]; then qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" "$size" >/dev/null; else qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" >/dev/null; fi; } + +render_cloud_init() { + local name=$1 network_config=$2 + sed "s|__LAB_SSH_KEY__|$(<"$SSH_KEY.pub")|" "$LAB_ROOT/templates/user-data.yaml" >"$RUN_DIR/$name-user-data" + cloud-localds --network-config="$network_config" "$RUN_DIR/$name-seed.iso" "$RUN_DIR/$name-user-data" +} + +install_linux_domain() { + local name=$1 memory=$2 vcpus=$3 network_args=$4 + install -m 0644 "$OVMF_VARS" "$RUN_DIR/$name-vars.fd" + virt-install --connect "$LIBVIRT_URI" --name "$name" --memory "$memory" --vcpus "$vcpus" --import --boot "loader=$OVMF_CODE,loader.type=pflash,loader.readonly=yes,loader.secure=no,nvram=$RUN_DIR/$name-vars.fd" --disk "path=$RUN_DIR/$name.qcow2,format=qcow2,bus=virtio" --disk "path=$RUN_DIR/$name-seed.iso,device=cdrom" --os-variant ubuntu24.04 --graphics vnc,listen=127.0.0.1 --noautoconsole $network_args +} + +create_domains() { + create_overlay "$LQOS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$RADIUS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$CLIENT_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$ROUTER_DOMAIN" "$ROUTER_BASE" + install_linux_domain "$LQOS_DOMAIN" 4096 2 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:10:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:10:00:11 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:10:00:12" + install_linux_domain "$RADIUS_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:20:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:20:00:11" + install_linux_domain "$CLIENT_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:30:00:10 --network network=$ACCESS_NETWORK,model=virtio,mac=02:00:00:00:00:42" + virt-install --connect "$LIBVIRT_URI" --name "$ROUTER_DOMAIN" --memory 512 --vcpus 1 --import --disk "path=$RUN_DIR/$ROUTER_DOMAIN.qcow2,format=qcow2,bus=virtio" --os-variant generic --graphics none --noautoconsole --network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:40:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:40:00:11 --network network=$ACCESS_NETWORK,model=virtio,mac=52:54:00:40:00:12 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:40:00:13 + sleep 5 +} + +destroy_domain() { local name=$1; if domain_exists "$name"; then virsh_lab destroy "$name" >/dev/null 2>&1 || true; virsh_lab undefine "$name" --nvram >/dev/null 2>&1 || virsh_lab undefine "$name"; fi; } +cmd_init() { require_host_tools; mkdir -p "$IMAGE_DIR" "$RUN_DIR"; download_ubuntu; download_routeros; build_runtime_bundle; note "lab bases and runtime bundle are ready"; } +cmd_up() { + require_host_tools; resolve_ovmf; [[ -f $UBUNTU_BASE && -f $ROUTER_BASE && -x $RUNTIME_DIR/bin/lqosd ]] || die "run ./radius-harness/lab init first"; ensure_lab_stopped + trap 'status=$?; trap - ERR; note "lab creation failed; removing partial resources"; cmd_down || true; exit "$status"' ERR + safe_remove "$RUN_DIR"; mkdir -p "$RUN_DIR"; ensure_ssh_key + for guest in lqos radius client; do cp "$LAB_ROOT/templates/$guest-network.yaml" "$RUN_DIR/$guest-network.yaml"; done + render_cloud_init "$LQOS_DOMAIN" "$RUN_DIR/lqos-network.yaml"; render_cloud_init "$RADIUS_DOMAIN" "$RUN_DIR/radius-network.yaml"; render_cloud_init "$CLIENT_DOMAIN" "$RUN_DIR/client-network.yaml" + create_network "$CONTROL_NETWORK" "$LAB_ROOT/templates/network-control.xml"; create_network "$ACCESS_NETWORK" "$LAB_ROOT/templates/network-access.xml"; create_network "$TRANSIT_NETWORK" "$LAB_ROOT/templates/network-transit.xml"; create_domains + trap - ERR; note "VMs created. Use ./radius-harness/lab console once to set RouterOS admin password, then run ./radius-harness/lab configure." +} +cmd_status() { for item in "$LQOS_DOMAIN 52:54:00:10:00:10" "$RADIUS_DOMAIN 52:54:00:20:00:10" "$CLIENT_DOMAIN 52:54:00:30:00:10" "$ROUTER_DOMAIN 52:54:00:40:00:10"; do set -- $item; printf '%-20s %s\n' "$1" "$(management_ip "$2" || true)"; done; } +cmd_console() { domain_exists "$ROUTER_DOMAIN" || die "RouterOS domain is not running; use ./radius-harness/lab up first"; virsh_lab console "$ROUTER_DOMAIN"; } +cmd_configure() { "$LAB_ROOT/lib/configure.sh"; } +cmd_test() { "$LAB_ROOT/lib/test-lifecycle.sh"; } +cmd_down() { for domain in "$CLIENT_DOMAIN" "$RADIUS_DOMAIN" "$ROUTER_DOMAIN" "$LQOS_DOMAIN"; do destroy_domain "$domain"; done; for network in "$TRANSIT_NETWORK" "$ACCESS_NETWORK" "$CONTROL_NETWORK"; do destroy_network "$network"; done; [[ -d $RUN_DIR ]] && safe_remove "$RUN_DIR"; note "disposable lab resources removed"; } +cmd_purge() { cmd_down; [[ -d $IMAGE_DIR ]] && safe_remove "$IMAGE_DIR"; [[ -d $RUNTIME_DIR ]] && safe_remove "$RUNTIME_DIR"; note "cached bases and runtime artifacts removed"; } + +case ${1:-} in init) cmd_init ;; up) cmd_up ;; status) cmd_status ;; configure) cmd_configure ;; test) cmd_test ;; down) cmd_down ;; purge) cmd_purge ;; console) cmd_console ;; *) usage; exit 2 ;; esac diff --git a/radius-harness/lab.env b/radius-harness/lab.env new file mode 100644 index 00000000..61c872a5 --- /dev/null +++ b/radius-harness/lab.env @@ -0,0 +1,18 @@ +# Pinned RouterOS long-term CHR release. Update version, URL, and SHA together. +ROUTEROS_VERSION=7.21.5 +ROUTEROS_CHR_URL=https://download.mikrotik.com/routeros/7.21.5/chr-7.21.5.img.zip +ROUTEROS_CHR_SHA256=dbb8f1739da73a68e5167fcbc731a5ba3d50f9d9b195731b96f96fcc58904580 + +# Pinned Ubuntu 24.04 cloud image. Update URL and SHA together. +UBUNTU_IMAGE_URL=https://cloud-images.ubuntu.com/releases/noble/release-20260518/ubuntu-24.04-server-cloudimg-amd64.img +UBUNTU_IMAGE_SHA256=53fdde898feed8b027d94baa9cfe8229867f330a1d9c49dc7d84465ee7f229f7 + +# Set these before `./radius-harness/lab configure`; they are deliberately not +# committed here: +# export ROUTEROS_ADMIN_PASSWORD='choose-a-password' +# export RADIUS_SHARED_SECRET='choose-a-shared-secret' + +# Optional UEFI firmware overrides for distributions with non-standard OVMF +# locations. Set both paths together when automatic discovery does not find one. +# export OVMF_CODE_PATH=/path/to/OVMF_CODE.fd +# export OVMF_VARS_PATH=/path/to/OVMF_VARS.fd diff --git a/radius-harness/lib/common.sh b/radius-harness/lib/common.sh new file mode 100755 index 00000000..af4933ed --- /dev/null +++ b/radius-harness/lib/common.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +REPO_ROOT=${LIBREQOS_REPO_ROOT:-$(cd -- "$LAB_ROOT/.." && pwd)} +RUNTIME_DIR="$LAB_ROOT/artifacts/runtime" +IMAGE_DIR="$LAB_ROOT/images" +RUN_DIR="$LAB_ROOT/run" +LIBVIRT_URI=${LIBVIRT_URI:-qemu:///system} +MANAGEMENT_NETWORK=default + +readonly LQOS_DOMAIN=radius-lqos +readonly ROUTER_DOMAIN=radius-routeros +readonly RADIUS_DOMAIN=radius-freeradius +readonly CLIENT_DOMAIN=radius-client +readonly CONTROL_NETWORK=radius-control +readonly ACCESS_NETWORK=radius-access +readonly TRANSIT_NETWORK=radius-transit + +die() { echo "error: $*" >&2; exit 1; } +note() { echo "==> $*"; } +require_command() { command -v "$1" >/dev/null || die "required command not found: $1"; } +require_lab_secret() { local secret_name=$1; [[ -n ${!secret_name:-} ]] || die "set $secret_name in the environment"; } +virsh_lab() { virsh -c "$LIBVIRT_URI" "$@"; } +domain_exists() { virsh_lab dominfo "$1" >/dev/null 2>&1; } +network_exists() { virsh_lab net-info "$1" >/dev/null 2>&1; } + +ensure_lab_stopped() { + for domain in "$LQOS_DOMAIN" "$ROUTER_DOMAIN" "$RADIUS_DOMAIN" "$CLIENT_DOMAIN"; do + domain_exists "$domain" && die "lab domain already exists: $domain (run ./radius-harness/lab down first)" + done + for network in "$CONTROL_NETWORK" "$ACCESS_NETWORK" "$TRANSIT_NETWORK"; do + network_exists "$network" && die "lab network already exists: $network (inspect and remove it before starting this lab)" + done +} + +management_ip() { + local mac=$1 + virsh_lab net-dhcp-leases "$MANAGEMENT_NETWORK" | awk -v wanted_mac="$mac" 'tolower($0) ~ tolower(wanted_mac) { expires = $1 " " $2; if (expires > latest) { latest = expires; split($5, address, "/"); ip = address[1] } } END { print ip }' +} + +wait_for_management_ip() { + local mac=$1 ip + for _ in $(seq 1 60); do + ip=$(management_ip "$mac") + [[ -n $ip ]] && { printf '%s\n' "$ip"; return 0; } + sleep 2 + done + die "timed out waiting for DHCP lease for $mac" +} + +wait_for_ssh() { + local host=$1 + for _ in $(seq 1 60); do + if ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=2 -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" true >/dev/null 2>&1; then return 0; fi + sleep 2 + done + die "timed out waiting for SSH on $host" +} + +lab_ssh() { local host=$1; shift; ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" "$@"; } +lab_scp() { local source=$1 host=$2 destination=$3; scp -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -p -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new -r "$source" "lab@$host:$destination"; } + +safe_remove() { + local path=$1 + [[ $path == "$LAB_ROOT"/* ]] || die "refusing to remove path outside lab root: $path" + rm -rf -- "$path" +} diff --git a/radius-harness/lib/configure.sh b/radius-harness/lib/configure.sh new file mode 100755 index 00000000..15e5eab0 --- /dev/null +++ b/radius-harness/lib/configure.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd) +source "$LAB_ROOT/lib/common.sh" +source "$LAB_ROOT/lab.env" + +note "checking configuration prerequisites" +require_command sshpass +require_lab_secret RADIUS_SHARED_SECRET +require_lab_secret ROUTEROS_ADMIN_PASSWORD + +note "discovering guest management addresses" +lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10) +radius_ip=$(wait_for_management_ip 52:54:00:20:00:10) +client_ip=$(wait_for_management_ip 52:54:00:30:00:10) +router_ip=$(wait_for_management_ip 52:54:00:40:00:10) +note "waiting for SSH on LibreQoS, FreeRADIUS, and PPPoE client guests" +wait_for_ssh "$lqos_ip"; wait_for_ssh "$radius_ip"; wait_for_ssh "$client_ip" + +note "rendering isolated lab configuration" +install -d -m 0700 "$RUN_DIR/config" +umask 077 +cp "$LAB_ROOT/templates/lqos.conf" "$RUN_DIR/config/lqos.conf" +cp "$LAB_ROOT/templates/network.json" "$RUN_DIR/config/network.json" +cp "$LAB_ROOT/templates/ShapedDevices.csv" "$RUN_DIR/config/ShapedDevices.csv" +escaped_radius_secret=$(printf '%s' "$RADIUS_SHARED_SECRET" | sed 's/[\\/&]/\\&/g') +sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/freeradius-client.conf" >"$RUN_DIR/config/radius-lab.conf" +sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/routeros.rsc" >"$RUN_DIR/config/radius-lab.rsc" + +note "uploading host-built LibreQoS runtime and fixtures" +lab_ssh "$lqos_ip" 'rm -rf /tmp/runtime' +lab_scp "$RUNTIME_DIR" "$lqos_ip" /tmp/ +for file in lqos.conf network.json ShapedDevices.csv; do lab_scp "$RUN_DIR/config/$file" "$lqos_ip" "/tmp/$file"; done +lab_scp "$RUN_DIR/config/radius-lab.conf" "$radius_ip" /home/lab/radius-lab.conf +lab_scp "$LAB_ROOT/templates/freeradius-users" "$radius_ip" /tmp/users + +note "installing LibreQoS fixture and RADIUS listener configuration" +lab_ssh "$lqos_ip" 'sudo mkdir -p /opt/libreqos/src /etc/libreqos; sudo rm -rf /opt/libreqos/src/*; sudo cp -a /tmp/runtime/src/. /opt/libreqos/src/; sudo mv /tmp/runtime/bin /opt/libreqos/src/bin; sudo mv /tmp/runtime/liblqos_python.so /opt/libreqos/src/liblqos_python.so; rm -rf /tmp/runtime; sudo install -m 0644 /tmp/lqos.conf /etc/lqos.conf; sudo install -m 0644 /tmp/network.json /opt/libreqos/src/network.json; sudo install -m 0644 /tmp/ShapedDevices.csv /opt/libreqos/src/ShapedDevices.csv' +note "creating isolated multiqueue veth interfaces for LibreQoS validation" +lab_ssh "$lqos_ip" 'for interface in lqos-lan lqos-wan; do sudo ip link del "$interface" 2>/dev/null || true; sudo ip link add "$interface" numrxqueues 2 numtxqueues 2 type veth peer name "${interface}-p" numrxqueues 2 numtxqueues 2; sudo ip link set "$interface" up; sudo ip link set "${interface}-p" up; done' +printf '%s\n' "$RADIUS_SHARED_SECRET" | lab_ssh "$lqos_ip" 'sudo tee /etc/libreqos/radius-shared-secret >/dev/null; sudo chmod 600 /etc/libreqos/radius-shared-secret' +lab_ssh "$lqos_ip" "sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null; sudo nft delete table ip radius_lab 2>/dev/null || true; sudo nft add table ip radius_lab; sudo nft 'add chain ip radius_lab prerouting { type nat hook prerouting priority dstnat; }'; sudo nft 'add chain ip radius_lab postrouting { type nat hook postrouting priority srcnat; }'; sudo nft add rule ip radius_lab prerouting ip daddr 198.18.10.10 udp dport 1812 dnat to 198.18.10.20; sudo nft add rule ip radius_lab postrouting ip daddr 198.18.10.20 udp dport 1812 snat to 198.18.10.10" + +note "configuring and starting FreeRADIUS" +lab_ssh "$radius_ip" "sudo install -m 0644 /tmp/users /etc/freeradius/3.0/mods-config/files/authorize; sudo grep -q 'client radius-lqos-nat' /etc/freeradius/3.0/clients.conf || sudo sh -c 'cat /home/lab/radius-lab.conf >> /etc/freeradius/3.0/clients.conf'; rm -f /home/lab/radius-lab.conf; sudo systemctl enable --now freeradius; sudo systemctl restart freeradius" +note "starting lqosd before building the LibreQoS fixture state" +lab_ssh "$lqos_ip" 'sudo systemctl stop radius-lqosd.service 2>/dev/null || true; sudo systemd-run --unit=radius-lqosd --collect --setenv=RUST_LOG=debug /opt/libreqos/src/bin/lqosd; sleep 1; sudo systemctl is-active --quiet radius-lqosd.service' +note "building the LibreQoS fixture state inside the lab" +lab_ssh "$lqos_ip" 'cd /opt/libreqos/src && sudo env PYTHONPATH=/opt/libreqos/src python3 LibreQoS.py' + +note "uploading RouterOS PPPoE and RADIUS configuration" +export SSHPASS=$ROUTEROS_ADMIN_PASSWORD +sshpass -e scp -p -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "$RUN_DIR/config/radius-lab.rsc" "admin@$router_ip:radius-lab.rsc" +sshpass -e ssh -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "admin@$router_ip" '/import file-name=radius-lab.rsc' +unset SSHPASS + +note "creating PPPoE client profile" +printf '%s\n' 'plugin rp-pppoe.so pppoe0' 'user pppoe-lab' 'password pppoe-lab-password' 'noauth' 'persist' 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null' +note "guest configuration complete; run ./radius-harness/lab test" diff --git a/radius-harness/lib/test-lifecycle.sh b/radius-harness/lib/test-lifecycle.sh new file mode 100755 index 00000000..015b3a0f --- /dev/null +++ b/radius-harness/lib/test-lifecycle.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd) +source "$LAB_ROOT/lib/common.sh" + +journal_cursor() { local lqos_ip=$1; lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd -n 0 --show-cursor --no-pager | sed -n 's/^-- cursor: //p'"; } + +wait_for_log() { + local lqos_ip=$1 cursor=$2 pattern=$3 description=$4 + for _ in $(seq 1 20); do + lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd --after-cursor='$cursor' --no-pager | grep -q -- '$pattern'" && return 0 + sleep 2 + done + die "$description" +} + +dynamic_circuit_count_is() { local lqos_ip=$1 expected_count=$2; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; sys.exit(0 if len(circuits) == $expected_count else 1)'"; } +dynamic_circuit_has_profile() { local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, math, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; matches = [c.get(\"shaped\", {}) for c in circuits if (\"$expected_circuit\" == \"any\" or c.get(\"shaped\", {}).get(\"circuit_id\") == \"$expected_circuit\")]; sys.exit(0 if any(math.isclose(float(c.get(\"download_max_mbps\", -1)), $expected_download) and math.isclose(float(c.get(\"upload_max_mbps\", -1)), $expected_upload) for c in matches) else 1)'"; } + +wait_for_dynamic_circuit_profile() { + local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4 description=$5 + for _ in $(seq 1 20); do dynamic_circuit_has_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" && return 0; sleep 2; done + die "$description" +} + +run_pppoe_case() { + local name=$1 username=$2 password=$3 expected_circuit=$4 expected_download=$5 expected_upload=$6 + note "checking that no dynamic circuit is present before $name" + dynamic_circuit_count_is "$lqos_ip" 0 || die "a dynamic circuit was already present before $name" + start_cursor=$(journal_cursor "$lqos_ip"); [[ -n $start_cursor ]] || die "could not establish an lqosd journal cursor" + note "starting PPPoE session for $name" + printf '%s\n' "plugin rp-pppoe.so pppoe0" "user $username" "password $password" noauth persist 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null' + lab_ssh "$client_ip" 'sudo poff radius-lab || true; sudo pon radius-lab' + for _ in $(seq 1 30); do lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" && break; sleep 2; done + lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" || die "PPPoE client did not receive 100.64.0.2 for $name" + note "waiting for RADIUS Start and the expected $name queue" + wait_for_log "$lqos_ip" "$start_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Start[)]' "lqosd did not accept a RADIUS Start packet for $name" + wait_for_log "$lqos_ip" "$start_cursor" 'applied RADIUS dynamic-circuit request.*intent="create"' "dynamic circuit creation was not observed for $name" + wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not have the expected ${expected_download}/${expected_upload} Mbps profile" + interim_cursor=$(journal_cursor "$lqos_ip"); note "waiting for the RouterOS five-second RADIUS Interim-Update for $name" + wait_for_log "$lqos_ip" "$interim_cursor" 'accepted RADIUS accounting packet.*status=Some[(]InterimUpdate[)]' "lqosd did not accept a RADIUS Interim-Update packet for $name" + wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not retain its expected profile after Interim-Update" + stop_cursor=$(journal_cursor "$lqos_ip"); note "disconnecting $name and waiting for RADIUS Stop plus queue removal" + lab_ssh "$client_ip" 'sudo poff radius-lab' + wait_for_log "$lqos_ip" "$stop_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Stop[)]' "lqosd did not accept a RADIUS Stop packet for $name" + wait_for_log "$lqos_ip" "$stop_cursor" 'applied RADIUS dynamic-circuit request.*intent="remove"' "dynamic circuit removal was not observed for $name" + for _ in $(seq 1 20); do dynamic_circuit_count_is "$lqos_ip" 0 && return 0; sleep 2; done + die "the $name dynamic circuit remained after RADIUS Stop" +} + +client_ip=$(wait_for_management_ip 52:54:00:30:00:10) +lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10) +trap 'lab_ssh "$client_ip" "sudo poff radius-lab || true" >/dev/null 2>&1 || true' EXIT +run_pppoe_case 'packet-rate fallback identity' pppoe-rate pppoe-rate-password any 10 25 +run_pppoe_case 'known username ShapedDevices identity' pppoe-known pppoe-known-password radius-known-circuit 60 20 +run_pppoe_case 'unknown username default identity' pppoe-unknown pppoe-unknown-password any 30 12 +trap - EXIT +note "all RADIUS lifecycle and queue-profile tests passed" diff --git a/radius-harness/templates/ShapedDevices.csv b/radius-harness/templates/ShapedDevices.csv new file mode 100644 index 00000000..2b2e86ed --- /dev/null +++ b/radius-harness/templates/ShapedDevices.csv @@ -0,0 +1,2 @@ +Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment +radius-known-circuit,Radius Known Circuit,radius-known-device,Known RADIUS Subscriber,PPPoE Access,lab-pppoe-access,lab-core,pppoe-known,,,6,3,60,20,RADIUS username speed fixture diff --git a/radius-harness/templates/client-network.yaml b/radius-harness/templates/client-network.yaml new file mode 100644 index 00000000..a63c0e64 --- /dev/null +++ b/radius-harness/templates/client-network.yaml @@ -0,0 +1,4 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:30:00:10'}, set-name: mgmt0, dhcp4: true} + pppoe0: {match: {macaddress: '02:00:00:00:00:42'}, set-name: pppoe0, dhcp4: false} diff --git a/radius-harness/templates/freeradius-client.conf b/radius-harness/templates/freeradius-client.conf new file mode 100644 index 00000000..498e7144 --- /dev/null +++ b/radius-harness/templates/freeradius-client.conf @@ -0,0 +1,4 @@ +client radius-lqos-nat { + ipaddr = 198.18.10.10 + secret = __RADIUS_SHARED_SECRET__ +} diff --git a/radius-harness/templates/freeradius-users b/radius-harness/templates/freeradius-users new file mode 100644 index 00000000..72eec23f --- /dev/null +++ b/radius-harness/templates/freeradius-users @@ -0,0 +1,12 @@ +pppoe-rate Cleartext-Password := "pppoe-rate-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255, + Mikrotik-Rate-Limit := "25M/10M" + +pppoe-known Cleartext-Password := "pppoe-known-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255 + +pppoe-unknown Cleartext-Password := "pppoe-unknown-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255 diff --git a/radius-harness/templates/lqos-network.yaml b/radius-harness/templates/lqos-network.yaml new file mode 100644 index 00000000..ed513879 --- /dev/null +++ b/radius-harness/templates/lqos-network.yaml @@ -0,0 +1,5 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:10:00:10'}, set-name: mgmt0, dhcp4: true} + control0: {match: {macaddress: '52:54:00:10:00:11'}, set-name: control0, addresses: [198.18.10.10/24]} + transit0: {match: {macaddress: '52:54:00:10:00:12'}, set-name: transit0, addresses: [198.18.30.10/24]} diff --git a/radius-harness/templates/lqos.conf b/radius-harness/templates/lqos.conf new file mode 100644 index 00000000..aa4da552 --- /dev/null +++ b/radius-harness/templates/lqos.conf @@ -0,0 +1,106 @@ +version = "1.5" +lqos_directory = "/opt/libreqos/src" +state_directory = "/opt/libreqos/state" +node_id = "radius-lab" +node_name = "RADIUS PPPoE Lab" +packet_capture_time = 10 +queue_check_period_ms = 1000 +display_cobrand = false +enable_circuit_heatmaps = true +enable_site_heatmaps = true +enable_asn_heatmaps = true + +[tuning] +set_cpu_governor_performance = false +stop_irq_balance = false +netdev_budget_usecs = 8000 +netdev_budget_packets = 300 +rx_usecs = 8 +tx_usecs = 8 +disable_rxvlan = true +disable_txvlan = true +disable_offload = ["gso", "tso", "lro", "sg", "gro"] + +[bridge] +use_xdp_bridge = false +to_internet = "lqos-wan" +to_network = "lqos-lan" + +[queues] +default_sqm = "cake diffserv4" +queue_mode = "shape" +monitor_only = false +uplink_bandwidth_mbps = 100 +downlink_bandwidth_mbps = 100 +generated_pn_download_mbps = 100 +generated_pn_upload_mbps = 100 +dry_run = true +sudo = false +use_binpacking = false + +[long_term_stats] +gather_stats = false +collation_period_seconds = 10 +license_key = "" + +[local_api] +bearer_token = "" + +[ip_ranges] +ignore_subnets = [] +allow_subnets = ["172.16.0.0/12", "10.0.0.0/8", "100.64.0.0/16", "192.168.0.0/16"] + +[integration_common] +circuit_name_as_address = false +queue_refresh_interval_mins = 30 +use_mikrotik_ipv6 = false +ethernet_port_limits_enabled = true + +[netzur_integration] +enable_netzur = false +api_key = "" +api_url = "" +timeout_secs = 60 + +[visp_integration] +enable_visp = false +client_id = "" +client_secret = "" +username = "" +password = "" +timeout_secs = 20 + +[influxdb] +enable_influxdb = false +url = "http://localhost:8086" +org = "libreqos" +bucket = "RADIUS Lab" +token = "" + +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "198.18.10.10:1813" +default_ttl_seconds = 12 +stale_grace_seconds = 2 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +fallback_parent_node = "PPPoE Access" +fallback_parent_node_id = "lab-pppoe-access" +fallback_anchor_node_id = "lab-core" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 4.0 +upload_min_mbps = 2.0 +download_max_mbps = 30.0 +upload_max_mbps = 12.0 + +[[radius_accounting.clients]] +name = "radius-routeros" +source = ["198.18.10.1/32"] +secret_file = "/etc/libreqos/radius-shared-secret" diff --git a/radius-harness/templates/network-access.xml b/radius-harness/templates/network-access.xml new file mode 100644 index 00000000..fc76fa07 --- /dev/null +++ b/radius-harness/templates/network-access.xml @@ -0,0 +1,5 @@ + + radius-access + + + diff --git a/radius-harness/templates/network-control.xml b/radius-harness/templates/network-control.xml new file mode 100644 index 00000000..55a281a8 --- /dev/null +++ b/radius-harness/templates/network-control.xml @@ -0,0 +1,5 @@ + + radius-control + + + diff --git a/radius-harness/templates/network-transit.xml b/radius-harness/templates/network-transit.xml new file mode 100644 index 00000000..5fc4433d --- /dev/null +++ b/radius-harness/templates/network-transit.xml @@ -0,0 +1,5 @@ + + radius-transit + + + diff --git a/radius-harness/templates/network.json b/radius-harness/templates/network.json new file mode 100644 index 00000000..2d8295ca --- /dev/null +++ b/radius-harness/templates/network.json @@ -0,0 +1,16 @@ +{ + "Lab Core": { + "id": "lab-core", + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 100, + "type": "site", + "children": { + "PPPoE Access": { + "id": "lab-pppoe-access", + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 100, + "type": "ap" + } + } + } +} diff --git a/radius-harness/templates/radius-network.yaml b/radius-harness/templates/radius-network.yaml new file mode 100644 index 00000000..b3e46fa3 --- /dev/null +++ b/radius-harness/templates/radius-network.yaml @@ -0,0 +1,4 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:20:00:10'}, set-name: mgmt0, dhcp4: true} + control0: {match: {macaddress: '52:54:00:20:00:11'}, set-name: control0, addresses: [198.18.10.20/24]} diff --git a/radius-harness/templates/routeros.rsc b/radius-harness/templates/routeros.rsc new file mode 100644 index 00000000..29794279 --- /dev/null +++ b/radius-harness/templates/routeros.rsc @@ -0,0 +1,8 @@ +/ip address add address=198.18.10.1/24 interface=ether2 +/ip address add address=198.18.30.1/24 interface=ether4 +/ip pool add name=radius-pppoe ranges=100.64.0.2-100.64.0.2 +/ppp profile add name=radius-pppoe local-address=100.64.0.1 remote-address=radius-pppoe use-encryption=no +/interface pppoe-server server add interface=ether3 service-name=radius-lab default-profile=radius-pppoe authentication=pap one-session-per-host=yes disabled=no +/radius add service=ppp address=198.18.10.10 authentication-port=1812 accounting-port=1813 secret=__RADIUS_SHARED_SECRET__ +/ppp aaa set use-radius=yes accounting=yes interim-update=5s +/ip service set ssh disabled=no diff --git a/radius-harness/templates/user-data.yaml b/radius-harness/templates/user-data.yaml new file mode 100644 index 00000000..1baa3a3a --- /dev/null +++ b/radius-harness/templates/user-data.yaml @@ -0,0 +1,12 @@ +#cloud-config +users: + - name: lab + groups: [adm, sudo] + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - __LAB_SSH_KEY__ +package_update: true +packages: [libelf1, nftables, ppp, pppoe, python3-chardet, python3-deepdiff, python3-psutil, freeradius, freeradius-utils] +runcmd: + - systemctl disable --now freeradius || true