Add RADIUS accounting config page

This commit is contained in:
Herbert Wolverson committed 2026-06-26 14:22:23 -05:00
1 parent 645cf55191
commit c6e5dc4966
5 files changed
+554

No files matched your search

@@ -28,6 +28,7 @@ config_lts.js
config_iprange.js
config_flows.js
config_dynamic_circuits.js
config_radius_accounting.js
config_integration.js
config_splynx.js
config_netzur.js
@@ -680,6 +680,7 @@ export function renderConfigMenu(currentPage) {
{ href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" },
{ href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" },
{ href: "config_dynamic_circuits.html", icon: "fa-layer-group", text: "Dynamic Circuits", id: "dynamic_circuits" },
{ href: "config_radius_accounting.html", icon: "fa-satellite-dish", text: "RADIUS Accounting", id: "radius_accounting" },
{ href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" },
{ href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" },
{ href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" },
@@ -0,0 +1,371 @@
import { loadConfig, renderConfigMenu, saveConfig } from "./config/config_helper";
const DEFAULT_TTL_SECONDS = 900;
const DEFAULT_STALE_GRACE_SECONDS = 120;
function parsePositiveInt(value) {
const num = parseInt(String(value ?? "").trim(), 10);
if (!Number.isFinite(num) || num <= 0) return null;
return num;
}
function parsePositiveFloat(value) {
const num = parseFloat(String(value ?? "").trim());
if (!Number.isFinite(num) || num <= 0) return null;
return num;
}
function optionalText(value) {
const trimmed = String(value ?? "").trim();
return trimmed.length > 0 ? trimmed : null;
}
function ensureRadiusConfig(config) {
if (!config || typeof config !== "object") return;
if (!config.radius_accounting || typeof config.radius_accounting !== "object") {
config.radius_accounting = {};
}
const radius = config.radius_accounting;
if (typeof radius.enabled !== "boolean") radius.enabled = false;
if (!Number.isFinite(Number(radius.default_ttl_seconds))) {
radius.default_ttl_seconds = DEFAULT_TTL_SECONDS;
}
if (!Number.isFinite(Number(radius.stale_grace_seconds))) {
radius.stale_grace_seconds = DEFAULT_STALE_GRACE_SECONDS;
}
if (!radius.dynamic_circuit_application || typeof radius.dynamic_circuit_application !== "object") {
radius.dynamic_circuit_application = {};
}
const application = radius.dynamic_circuit_application;
if (typeof application.enabled !== "boolean") application.enabled = false;
if (typeof application.match_shaped_devices_by_mac !== "boolean") {
application.match_shaped_devices_by_mac = false;
}
if (!Array.isArray(radius.clients)) radius.clients = [];
}
function sourceListToText(source) {
if (Array.isArray(source)) {
return source.map((entry) => String(entry ?? "").trim()).filter(Boolean).join("\n");
}
return String(source ?? "").trim();
}
function textToSourceList(value) {
return String(value ?? "")
.split(/\r?\n|,/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function isValidIPv4(ip) {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
const parts = ip.split(".").map((part) => parseInt(part, 10));
return parts.length === 4 && !parts.some((part) => Number.isNaN(part) || part < 0 || part > 255);
}
function isValidIPv6(ip) {
return ip.includes(":") && /^[0-9a-fA-F:.]+$/.test(ip);
}
function isValidIpAddress(ip) {
return isValidIPv4(ip) || isValidIPv6(ip);
}
function isValidIpOrCidr(value) {
const text = String(value ?? "").trim();
const [ip, mask, extra] = text.split("/");
if (!ip || extra !== undefined) return false;
if (!isValidIpAddress(ip)) return false;
if (mask === undefined) return true;
const maskNum = parseInt(mask, 10);
if (!Number.isInteger(maskNum)) return false;
return ip.includes(":") ? maskNum >= 0 && maskNum <= 128 : maskNum >= 0 && maskNum <= 32;
}
function getDefaultClient() {
return {
name: "",
source: [],
secret_file: "",
};
}
function announceClientChange(message) {
const status = document.getElementById("radiusClientsStatus");
if (status) status.textContent = message;
}
function renderClientsTable(focusClientIndex = null) {
const tbody = document.getElementById("radiusClientsBody");
if (!tbody) return;
tbody.innerHTML = "";
const clients = window.config?.radius_accounting?.clients;
if (!Array.isArray(clients) || clients.length === 0) {
const empty = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 4;
cell.className = "text-muted";
cell.textContent = "No trusted clients configured.";
empty.appendChild(cell);
tbody.appendChild(empty);
return;
}
clients.forEach((client, index) => {
const tr = document.createElement("tr");
const addCell = (label, child) => {
const td = document.createElement("td");
td.dataset.label = label;
td.appendChild(child);
tr.appendChild(td);
};
const name = document.createElement("input");
name.type = "text";
name.className = "form-control form-control-sm";
name.placeholder = "NAS name";
name.value = String(client?.name ?? "");
name.setAttribute("aria-label", `Client ${index + 1} name`);
name.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].name = ev.target.value;
});
addCell("Name", name);
const source = document.createElement("textarea");
source.className = "form-control form-control-sm";
source.rows = 2;
source.placeholder = "192.0.2.10\n192.0.2.0/24";
source.value = sourceListToText(client?.source);
source.setAttribute("aria-label", `Client ${index + 1} source IPs or CIDRs`);
source.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].source = textToSourceList(ev.target.value);
});
addCell("Source IPs or CIDRs", source);
const secretFile = document.createElement("input");
secretFile.type = "text";
secretFile.className = "form-control form-control-sm";
secretFile.placeholder = "/etc/libreqos/radius/client.secret";
secretFile.value = String(client?.secret_file ?? "");
secretFile.setAttribute("aria-label", `Client ${index + 1} secret file`);
secretFile.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].secret_file = ev.target.value;
});
addCell("Secret File", secretFile);
const removeTd = document.createElement("td");
removeTd.dataset.label = "Actions";
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn btn-sm btn-outline-danger";
removeBtn.textContent = "Remove";
removeBtn.setAttribute("aria-label", `Remove client ${index + 1}`);
removeBtn.addEventListener("click", () => {
window.config.radius_accounting.clients.splice(index, 1);
renderClientsTable(Math.min(index, window.config.radius_accounting.clients.length - 1));
announceClientChange(`Removed client ${index + 1}.`);
});
removeTd.appendChild(removeBtn);
tr.appendChild(removeTd);
tbody.appendChild(tr);
if (index === focusClientIndex) {
name.focus();
}
});
}
function setFallbackSpeedInputsEnabled(enabled) {
[
"radiusDownloadMinMbps",
"radiusUploadMinMbps",
"radiusDownloadMaxMbps",
"radiusUploadMaxMbps",
].forEach((id) => {
const input = document.getElementById(id);
if (input) input.disabled = !enabled;
});
}
function updateConfigFromUi() {
const radius = window.config.radius_accounting;
radius.enabled = !!document.getElementById("radiusEnabled")?.checked;
radius.listen = optionalText(document.getElementById("radiusListen")?.value);
radius.default_ttl_seconds = parsePositiveInt(
document.getElementById("radiusTtlSeconds")?.value,
) ?? DEFAULT_TTL_SECONDS;
radius.stale_grace_seconds = parsePositiveInt(
document.getElementById("radiusStaleGraceSeconds")?.value,
) ?? DEFAULT_STALE_GRACE_SECONDS;
const application = radius.dynamic_circuit_application;
application.enabled = !!document.getElementById("radiusDynamicApplicationEnabled")?.checked;
application.match_shaped_devices_by_mac = !!document.getElementById("radiusMatchShapedDevicesByMac")?.checked;
application.fallback_parent_node = optionalText(document.getElementById("radiusFallbackParentNode")?.value);
application.fallback_parent_node_id = optionalText(document.getElementById("radiusFallbackParentNodeId")?.value);
application.fallback_anchor_node_id = optionalText(document.getElementById("radiusFallbackAnchorNodeId")?.value);
if (document.getElementById("radiusFallbackSpeedEnabled")?.checked) {
radius.fallback_speed_profile = {
download_min_mbps: parsePositiveFloat(document.getElementById("radiusDownloadMinMbps")?.value),
upload_min_mbps: parsePositiveFloat(document.getElementById("radiusUploadMinMbps")?.value),
download_max_mbps: parsePositiveFloat(document.getElementById("radiusDownloadMaxMbps")?.value),
upload_max_mbps: parsePositiveFloat(document.getElementById("radiusUploadMaxMbps")?.value),
};
} else {
radius.fallback_speed_profile = null;
}
if (Array.isArray(radius.clients)) {
radius.clients = radius.clients.map((client) => ({
name: String(client?.name ?? "").trim(),
source: textToSourceList(sourceListToText(client?.source)),
secret_file: String(client?.secret_file ?? "").trim(),
}));
}
}
function validateListenAddress(value) {
const text = String(value ?? "").trim();
if (!text) return false;
const ipv6Match = text.match(/^\[[^\]]+\]:(\d+)$/);
if (ipv6Match) {
const ip = text.slice(1, text.indexOf("]"));
if (!isValidIPv6(ip)) return false;
const port = parseInt(ipv6Match[1], 10);
return port > 0 && port <= 65535;
}
const portStart = text.lastIndexOf(":");
if (portStart <= 0) return false;
const ip = text.slice(0, portStart);
if (!isValidIPv4(ip)) return false;
const port = parseInt(text.slice(portStart + 1), 10);
return Number.isInteger(port) && port > 0 && port <= 65535;
}
function validateConfig() {
const errors = [];
const radius = window.config?.radius_accounting;
if (!radius || typeof radius !== "object") {
errors.push("radius_accounting section is missing");
} else {
if (radius.enabled && !validateListenAddress(radius.listen)) {
errors.push("Listen Address is required when RADIUS accounting is enabled and must include a valid port.");
}
if (parsePositiveInt(radius.default_ttl_seconds) === null) {
errors.push("TTL must be a positive integer.");
}
if (parsePositiveInt(radius.stale_grace_seconds) === null) {
errors.push("Stale Grace must be a positive integer.");
}
const application = radius.dynamic_circuit_application || {};
if (!application.fallback_parent_node
&& (application.fallback_parent_node_id || application.fallback_anchor_node_id)) {
errors.push("Fallback Parent Node is required when fallback node IDs are configured.");
}
if (radius.fallback_speed_profile) {
const profile = radius.fallback_speed_profile;
const dmin = parsePositiveFloat(profile.download_min_mbps);
const umin = parsePositiveFloat(profile.upload_min_mbps);
const dmax = parsePositiveFloat(profile.download_max_mbps);
const umax = parsePositiveFloat(profile.upload_max_mbps);
if (dmin === null || umin === null || dmax === null || umax === null) {
errors.push("Fallback speed profile values must be positive numbers.");
} else {
if (dmin > dmax) errors.push("Fallback Download Min must be <= Download Max.");
if (umin > umax) errors.push("Fallback Upload Min must be <= Upload Max.");
}
}
const clients = Array.isArray(radius.clients) ? radius.clients : [];
if (radius.enabled && clients.length === 0) {
errors.push("At least one trusted client is required when RADIUS accounting is enabled.");
}
clients.forEach((client, index) => {
const label = client?.name ? `Client '${client.name}'` : `Client #${index + 1}`;
const sources = Array.isArray(client?.source) ? client.source : [];
if (sources.length === 0) {
errors.push(`${label}: Source IPs or CIDRs are required.`);
}
sources.forEach((source) => {
if (!isValidIpOrCidr(source)) {
errors.push(`${label}: '${source}' must be an IP address or CIDR network.`);
}
});
if (!String(client?.secret_file ?? "").trim()) {
errors.push(`${label}: Secret File is required.`);
}
});
}
if (errors.length === 0) return true;
alert("Validation errors:\n" + errors.join("\n"));
return false;
}
function loadUiFromConfig() {
const radius = window.config.radius_accounting;
const application = radius.dynamic_circuit_application;
const profile = radius.fallback_speed_profile;
document.getElementById("radiusEnabled").checked = !!radius.enabled;
document.getElementById("radiusListen").value = radius.listen ?? "";
document.getElementById("radiusTtlSeconds").value = radius.default_ttl_seconds ?? DEFAULT_TTL_SECONDS;
document.getElementById("radiusStaleGraceSeconds").value = radius.stale_grace_seconds ?? DEFAULT_STALE_GRACE_SECONDS;
document.getElementById("radiusDynamicApplicationEnabled").checked = !!application.enabled;
document.getElementById("radiusMatchShapedDevicesByMac").checked = !!application.match_shaped_devices_by_mac;
document.getElementById("radiusFallbackParentNode").value = application.fallback_parent_node ?? "";
document.getElementById("radiusFallbackParentNodeId").value = application.fallback_parent_node_id ?? "";
document.getElementById("radiusFallbackAnchorNodeId").value = application.fallback_anchor_node_id ?? "";
document.getElementById("radiusFallbackSpeedEnabled").checked = !!profile;
document.getElementById("radiusDownloadMinMbps").value = profile?.download_min_mbps ?? "";
document.getElementById("radiusUploadMinMbps").value = profile?.upload_min_mbps ?? "";
document.getElementById("radiusDownloadMaxMbps").value = profile?.download_max_mbps ?? "";
document.getElementById("radiusUploadMaxMbps").value = profile?.upload_max_mbps ?? "";
setFallbackSpeedInputsEnabled(!!profile);
renderClientsTable();
}
renderConfigMenu("radius_accounting");
loadConfig(() => {
ensureRadiusConfig(window.config);
loadUiFromConfig();
document.getElementById("radiusFallbackSpeedEnabled").addEventListener("change", (ev) => {
setFallbackSpeedInputsEnabled(!!ev.target.checked);
});
document.getElementById("addRadiusClient").addEventListener("click", () => {
window.config.radius_accounting.clients.push(getDefaultClient());
const newIndex = window.config.radius_accounting.clients.length - 1;
renderClientsTable(newIndex);
announceClientChange(`Added client ${newIndex + 1}.`);
});
document.getElementById("saveButton").addEventListener("click", () => {
updateConfigFromUi();
if (!validateConfig()) return;
saveConfig(() => {
alert("Configuration saved successfully!");
});
});
});
@@ -0,0 +1,180 @@
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">RADIUS Accounting</h5>
<div class="lqos-config-panel-subtitle">
Listen for accounting packets and apply RADIUS-backed dynamic circuits.
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Listener</h6>
<div class="lqos-config-section-subtitle">Enable the UDP listener and choose session expiry behavior.</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusEnabled">
<label class="form-check-label" for="radiusEnabled">Enable RADIUS Accounting</label>
<div class="form-text">Starts the lqosd RADIUS accounting listener.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<label for="radiusListen" class="form-label">Listen Address</label>
<input type="text" class="form-control" id="radiusListen" placeholder="0.0.0.0:1813">
<div class="form-text">UDP socket address used by NAS accounting clients.</div>
</div>
<div class="col-12 col-md-6 col-xl-2">
<label for="radiusTtlSeconds" class="form-label">TTL</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusTtlSeconds" min="1" step="1" value="900">
<span class="input-group-text">sec</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-2">
<label for="radiusStaleGraceSeconds" class="form-label">Stale Grace</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusStaleGraceSeconds" min="1" step="1" value="120">
<span class="input-group-text">sec</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Dynamic Circuit Application</h6>
<div class="lqos-config-section-subtitle">Control whether accepted sessions create or refresh dynamic circuits.</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusDynamicApplicationEnabled">
<label class="form-check-label" for="radiusDynamicApplicationEnabled">Apply Dynamic Circuits</label>
<div class="form-text">Allows RADIUS sessions to update dynamic circuit state.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusMatchShapedDevicesByMac">
<label class="form-check-label" for="radiusMatchShapedDevicesByMac">Match ShapedDevices by MAC</label>
<div class="form-text">Uses Calling-Station-Id to reuse shaped device metadata.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<label for="radiusFallbackParentNode" class="form-label">Fallback Parent Node</label>
<input type="text" class="form-control" id="radiusFallbackParentNode" placeholder="Optional network node">
</div>
<div class="col-12 col-xl-6">
<label for="radiusFallbackParentNodeId" class="form-label">Fallback Parent Node ID</label>
<input type="text" class="form-control" id="radiusFallbackParentNodeId" placeholder="Optional stable node ID">
</div>
<div class="col-12 col-xl-6">
<label for="radiusFallbackAnchorNodeId" class="form-label">Fallback Anchor Node ID</label>
<input type="text" class="form-control" id="radiusFallbackAnchorNodeId" placeholder="Optional stable anchor ID">
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Fallback Speed Profile</h6>
<div class="lqos-config-section-subtitle">Optional rates used when packets and matched shaped devices do not supply rates.</div>
<div class="row g-3">
<div class="col-12">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusFallbackSpeedEnabled">
<label class="form-check-label" for="radiusFallbackSpeedEnabled">Enable Fallback Speed Profile</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusDownloadMinMbps" class="form-label">Download Min</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusDownloadMinMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusUploadMinMbps" class="form-label">Upload Min</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusUploadMinMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusDownloadMaxMbps" class="form-label">Download Max</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusDownloadMaxMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusUploadMaxMbps" class="form-label">Upload Max</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusUploadMaxMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Trusted Clients</h6>
<div class="lqos-config-section-subtitle">Allow only known NAS sources and shared secret files.</div>
<div class="d-flex justify-content-end mb-3">
<button type="button" id="addRadiusClient" class="btn btn-outline-primary btn-sm">
<i class="fa fa-plus me-1"></i>Add Client
</button>
</div>
<div id="radiusClientsStatus" class="visually-hidden" aria-live="polite"></div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle lqos-config-responsive-table">
<thead>
<tr>
<th style="min-width: 9rem;">Name</th>
<th style="min-width: 14rem;">Source IPs or CIDRs</th>
<th style="min-width: 14rem;">Secret File</th>
<th style="min-width: 5rem;">Actions</th>
</tr>
</thead>
<tbody id="radiusClientsBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</div>
</form>
</div>
<script src="config_radius_accounting.js%CACHEBUSTERS%"></script>
@@ -67,6 +67,7 @@ pub(super) fn static_routes() -> Result<Router> {
"config_iprange.html",
"config_flows.html",
"config_dynamic_circuits.html",
"config_radius_accounting.html",
"config_integration.html",
"config_splynx.html",
"config_netzur.html",