mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 13:57:40 -05:00
Add portable RADIUS lifecycle harness
This commit is contained in:
1 parent
6416672f7b
commit
9987302830
20 files changed
+612
No files matched your search
@@ -0,0 +1,3 @@
|
||||
/artifacts/
|
||||
/images/
|
||||
/run/
|
||||
@@ -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.
|
||||
```
|
||||
Executable
+138
@@ -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
|
||||
@@ -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
|
||||
Executable
+68
@@ -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"
|
||||
}
|
||||
Executable
+60
@@ -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"
|
||||
Executable
+59
@@ -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
|
||||
|
@@ -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__
|
||||
}
|
||||
@@ -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]}
|
||||
@@ -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>
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user