Add portable RADIUS lifecycle harness

This commit is contained in:
Herbert Wolverson committed 2026-07-21 13:39:07 -05:00
1 parent 6416672f7b
commit 9987302830
20 files changed
+612

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
/artifacts/
/images/
/run/
+78
View File
@@ -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.
```
+138
View File
@@ -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 <command>
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
+18
View File
@@ -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
+68
View File
@@ -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"
}
+60
View File
@@ -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"
+59
View File
@@ -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"
@@ -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
1 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
2 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
@@ -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}
@@ -0,0 +1,4 @@
client radius-lqos-nat {
ipaddr = 198.18.10.10
secret = __RADIUS_SHARED_SECRET__
}
+12
View File
@@ -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
@@ -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]}
+106
View File
@@ -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"
@@ -0,0 +1,5 @@
<network>
<name>radius-access</name>
<bridge name='vbr-raccess'/>
<forward mode='none'/>
</network>
@@ -0,0 +1,5 @@
<network>
<name>radius-control</name>
<bridge name='vbr-rcontrol'/>
<forward mode='none'/>
</network>
@@ -0,0 +1,5 @@
<network>
<name>radius-transit</name>
<bridge name='vbr-rtransit'/>
<forward mode='none'/>
</network>
+16
View File
@@ -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"
}
}
}
}
@@ -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]}
+8
View File
@@ -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
+12
View File
@@ -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