diff --git a/.agents/skills/libreqos-node-manager-frontend/SKILL.md b/.agents/skills/libreqos-node-manager-frontend/SKILL.md index 24189a8e..cbf962d0 100755 --- a/.agents/skills/libreqos-node-manager-frontend/SKILL.md +++ b/.agents/skills/libreqos-node-manager-frontend/SKILL.md @@ -57,11 +57,13 @@ If the page is served through the templated/authenticated router, use `%CACHEBUS ## Validation +- Run `src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh` - Run `src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh` - Run `src/rust/lqosd/src/node_manager/js_build/esbuild.sh` - For broader local iteration, run `src/rust/lqosd/dev_build.sh` ## Notes +- `test-node-manager.sh` uses Node's built-in test runner only; it must not grow a `node_modules` workflow. - The build-contract test checks page list alignment, required vendored assets, entrypoint/source alignment, and bundle output presence. - `copy_files.sh` and `dev_build.sh` should stay aligned with the same node_manager asset pipeline. diff --git a/src/rust/lqos_config/src/cpu_topology.rs b/src/rust/lqos_config/src/cpu_topology.rs index 5b3472a2..a1ccae4e 100644 --- a/src/rust/lqos_config/src/cpu_topology.rs +++ b/src/rust/lqos_config/src/cpu_topology.rs @@ -433,7 +433,9 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { use std::arch::x86_64::__cpuid_count; // Query leaf 0 first to determine the maximum supported CPUID leaf before using leaf 0x1A. - if __cpuid_count(0, 0).eax < 0x1a { + // SAFETY: `CPUID` is a CPU identification instruction that does not dereference pointers or + // access memory. We probe leaf 0 first and only use the returned register values. + if unsafe { __cpuid_count(0, 0) }.eax < 0x1a { return None; } @@ -453,7 +455,9 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { return None; } - let leaf = __cpuid_count(0x1a, 0); + // SAFETY: leaf 0x1A is only queried after confirming via leaf 0 that the CPU supports it. + // The intrinsic only executes `CPUID` and reads the returned registers. + let leaf = unsafe { __cpuid_count(0x1a, 0) }; let core_type = (leaf.eax >> 24) & 0xff; match core_type { CPUID_CORE_TYPE_CORE => performance.push(*cpu), diff --git a/src/rust/lqosd/dev_build.sh b/src/rust/lqosd/dev_build.sh index f3377c4b..0d4d53d8 100755 --- a/src/rust/lqosd/dev_build.sh +++ b/src/rust/lqosd/dev_build.sh @@ -9,8 +9,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "[dev_build] Building JS bundles..." pushd "$SCRIPT_DIR/src/node_manager/js_build" >/dev/null -./esbuild.sh -./test-build-contract.sh +./test-node-manager.sh popd >/dev/null echo "[dev_build] Copying static HTML/CSS assets to bin/static2..." diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index 2cb88538..1d5eab27 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -6,7 +6,6 @@ shaped-devices.js tree.js help.js unknown-ips.js -configuration.js circuit.js ethernet_caps.js site_map.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 17959146..7a149f99 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -123,46 +123,47 @@ export function saveConfig(onComplete, onError) { } export function saveNetworkAndDevices(network_json, shaped_devices, onComplete, onError) { - // Validate network_json structure - if (!network_json || typeof network_json !== 'object') { + if (!network_json || typeof network_json !== "object") { alert("Invalid network configuration"); return; } - - // Validate shaped_devices structure if (!Array.isArray(shaped_devices)) { alert("Invalid shaped devices configuration"); return; } - // Validate individual shaped devices const validationErrors = []; const validNodes = validNodeList(network_json); - console.log(validNodes); - shaped_devices.forEach((device, index) => { - // Required fields if (!device.circuit_id || device.circuit_id.trim() === "") { validationErrors.push(`Device ${index + 1}: Circuit ID is required`); } if (!device.device_id || device.device_id.trim() === "") { validationErrors.push(`Device ${index + 1}: Device ID is required`); } - - // Parent node validation - if (device.parent_node && validNodes.length > 0 && !validNodes.includes(device.parent_node)) { - validationErrors.push(`Device ${index + 1}: Parent node '${device.parent_node}' does not exist`); + if ( + device.parent_node && + validNodes.length > 0 && + !validNodes.includes(device.parent_node) + ) { + validationErrors.push( + `Device ${index + 1}: Parent node '${device.parent_node}' does not exist`, + ); } - // Bandwidth validation (supports fractional Mbps) - // Minimums must be >= 0.1 Mbps, maximums must be >= 0.2 Mbps const dmin = parseFloat(device.download_min_mbps); const umin = parseFloat(device.upload_min_mbps); const dmax = parseFloat(device.download_max_mbps); const umax = parseFloat(device.upload_max_mbps); - - if (Number.isNaN(dmin) || Number.isNaN(umin) || Number.isNaN(dmax) || Number.isNaN(umax)) { - validationErrors.push(`Device ${index + 1}: One or more bandwidth fields are not valid numbers`); + if ( + Number.isNaN(dmin) || + Number.isNaN(umin) || + Number.isNaN(dmax) || + Number.isNaN(umax) + ) { + validationErrors.push( + `Device ${index + 1}: One or more bandwidth fields are not valid numbers`, + ); } else { if (dmin < 0.1 || umin < 0.1) { validationErrors.push(`Device ${index + 1}: Min rates must be >= 0.1 Mbps`); @@ -178,21 +179,14 @@ export function saveNetworkAndDevices(network_json, shaped_devices, onComplete, return; } - // Prepare data for submission - const submission = { - network_json, - shaped_devices - }; - console.log(submission); - sendWsRequest( "UpdateNetworkAndDevicesResult", - { UpdateNetworkAndDevices: submission }, + { UpdateNetworkAndDevices: { network_json, shaped_devices } }, (msg) => { if (onComplete) onComplete(!!msg.ok, msg.message); }, (msg) => { - const errorMsg = (msg && msg.message) ? msg.message : "Request failed"; + const errorMsg = msg && msg.message ? msg.message : "Request failed"; if (onComplete) onComplete(false, errorMsg); if (onError) { onError(msg); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.mjs b/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.mjs new file mode 100644 index 00000000..110d7081 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.mjs @@ -0,0 +1,88 @@ +export function parseIpv4Address(text) { + const parts = text.split("."); + if (parts.length !== 4) return null; + const octets = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = parseInt(part, 10); + if (octet < 0 || octet > 255) return null; + octets.push(octet); + } + return octets; +} + +function expandIpv4Tail(text) { + if (!text.includes(".")) return text; + const idx = text.lastIndexOf(":"); + if (idx === -1) return null; + const tail = parseIpv4Address(text.slice(idx + 1)); + if (!tail) return null; + const high = ((tail[0] << 8) | tail[1]).toString(16); + const low = ((tail[2] << 8) | tail[3]).toString(16); + return text.slice(0, idx) + ":" + high + ":" + low; +} + +export function parseIpv6Address(text) { + let input = (text || "").trim().toLowerCase(); + if (!input || input.includes("%")) return null; + input = expandIpv4Tail(input); + if (!input) return null; + + const doubleColon = input.match(/::/g) || []; + if (doubleColon.length > 1) return null; + + let head = []; + let tail = []; + if (input.includes("::")) { + const parts = input.split("::"); + head = parts[0] ? parts[0].split(":") : []; + tail = parts[1] ? parts[1].split(":") : []; + } else { + head = input.split(":"); + } + + const validGroup = (group) => /^[0-9a-f]{1,4}$/.test(group); + if (!head.every(validGroup) || !tail.every(validGroup)) return null; + + if (!input.includes("::") && head.length !== 8) return null; + if (input.includes("::") && head.length + tail.length >= 8) return null; + + const groups = input.includes("::") + ? [ + ...head, + ...new Array(8 - head.length - tail.length).fill("0"), + ...tail, + ] + : head; + if (groups.length !== 8) return null; + + const bytes = []; + for (const group of groups) { + const value = parseInt(group, 16); + if (Number.isNaN(value) || value < 0 || value > 0xffff) return null; + bytes.push((value >> 8) & 0xff, value & 0xff); + } + return bytes; +} + +export function parseIpInput(text, family) { + if (!text) return []; + const defaultPrefix = family === 6 ? 128 : 32; + const tokens = text.split(/[\n,]+/); + const result = []; + tokens.forEach((token) => { + const trimmed = token.trim(); + if (!trimmed) return; + const parts = trimmed.split("/"); + const addr = parts[0].trim(); + if (!addr) return; + let prefix = defaultPrefix; + if (parts.length > 1 && parts[1].trim().length > 0) { + const parsed = parseInt(parts[1].trim(), 10); + if (!Number.isNaN(parsed)) prefix = parsed; + } + const encoded = family === 6 ? parseIpv6Address(addr) : parseIpv4Address(addr); + result.push([encoded || addr, prefix]); + }); + return result; +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.test.mjs new file mode 100644 index 00000000..6f26768a --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/shaped_device_wire.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseIpInput, + parseIpv4Address, + parseIpv6Address, +} from "./shaped_device_wire.mjs"; + +test("encodes IPv4 hosts and prefixes into byte tuples", () => { + assert.deepEqual(parseIpv4Address("192.168.1.2"), [192, 168, 1, 2]); + assert.deepEqual(parseIpInput("192.168.1.2,10.0.0.0/24", 4), [ + [[192, 168, 1, 2], 32], + [[10, 0, 0, 0], 24], + ]); +}); + +test("encodes IPv6 hosts into 16-byte tuples", () => { + assert.deepEqual(parseIpv6Address("2001:db8::1"), [ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ]); + assert.deepEqual(parseIpInput("2001:db8::1/64", 6), [[ + [0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], + 64, + ]]); +}); + +test("encodes IPv4-mapped IPv6 addresses into 16-byte tuples", () => { + assert.deepEqual(parseIpv6Address("::ffff:192.0.2.1"), [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xc0, 0x00, 0x02, 0x01, + ]); +}); + +test("preserves invalid input as strings so UI validation can reject it", () => { + assert.equal(parseIpv4Address("300.1.1.1"), null); + assert.equal(parseIpv6Address("2001:::1"), null); + assert.deepEqual(parseIpInput("300.1.1.1\n2001:::1", 4), [ + ["300.1.1.1", 32], + ["2001:::1", 32], + ]); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js index 38f740bf..7eabf335 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js @@ -10,6 +10,7 @@ import { updateShapedDevice, validNodeList, } from "./config/config_helper"; +import { parseIpInput } from "./config/shaped_device_wire.mjs"; let current_rows = []; let network_json = null; @@ -187,27 +188,6 @@ function ipListToText(list, defaultPrefix) { .join("\n"); } -function parseIpInput(text, family) { - if (!text) return []; - const defaultPrefix = family === 6 ? 128 : 32; - const tokens = text.split(/[\n,]+/); - const result = []; - tokens.forEach((token) => { - const trimmed = token.trim(); - if (!trimmed) return; - const parts = trimmed.split("/"); - const addr = parts[0].trim(); - if (!addr) return; - let prefix = defaultPrefix; - if (parts.length > 1 && parts[1].trim().length > 0) { - const parsed = parseInt(parts[1].trim(), 10); - if (!Number.isNaN(parsed)) prefix = parsed; - } - result.push([addr, prefix]); - }); - return result; -} - function parseSqmOverride(raw) { const normalized = (raw || "").toLowerCase(); let down = ""; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/configuration.js b/src/rust/lqosd/src/node_manager/js_build/src/configuration.js deleted file mode 100644 index 53993d5e..00000000 --- a/src/rust/lqosd/src/node_manager/js_build/src/configuration.js +++ /dev/null @@ -1,1567 +0,0 @@ -// Mostly ported from the original node manager -import { get_ws_client } from "./pubsub/ws"; -import { - activeTopologySourceIntegrations, - loadAllShapedDevices, - topologyEditorsLockMessage, - topologyEditorsLocked, -} from "./config/config_helper"; - -let nics = null; -let lqosd_config = null; -let shaped_devices = null; -let network_json = null; -let qoo_profiles = null; -let shaped_devices_loaded = false; -let shaped_devices_loading = false; -let shaped_devices_dirty = false; -const MIN_NODE_MBPS = 0.1; -let topology_editor_locked = false; -let topology_editor_lock_message = ""; - -const wsClient = get_ws_client(); - -function sendWsRequest(responseEvent, request, onComplete, onError) { - let done = false; - const responseHandler = (msg) => { - if (done) return; - done = true; - wsClient.off(responseEvent, responseHandler); - wsClient.off("Error", errorHandler); - onComplete(msg); - }; - const errorHandler = (msg) => { - if (done) return; - done = true; - wsClient.off(responseEvent, responseHandler); - wsClient.off("Error", errorHandler); - if (onError) { - onError(msg); - } - }; - wsClient.on(responseEvent, responseHandler); - wsClient.on("Error", errorHandler); - wsClient.send(request); -} - -const bindings = [ - // General - { field: "bindVersion", path: ".version", data: "string", editable: false }, - { field: "bindPath", path: ".lqos_directory", data: "string", editable: true, required: true }, - { field: "bindNodeId", path: ".node_id", data: "string", editable: true, required: true }, - { field: "bindNodeName", path: ".node_name", data: "string", editable: true, required: true }, - { field: "bindQooProfile", path: ".qoo_profile_id", data: "select-nullable", editable: true }, - { field: "bindPacketCaptureTime", path: ".packet_capture_time", data: "integer", editable: true, min: 1, max: 300 }, - { field: "bindQueueCheckPeriodMs", path: ".queue_check_period_ms", data: "integer", editable: true, min: 100, max: 100000 }, - - // Tuning - { field: "bindStopIrqBalance", path: ".tuning.stop_irq_balance", data: "bool", editable: true }, - { field: "bindNetdevBudgetUs", path: ".tuning.netdev_budget_usecs", data: "integer", editable: true }, - { field: "bindNetdevBudgetPackets", path: ".tuning.netdev_budget_packets", data: "integer", editable: true }, - { field: "bindRxUs", path: ".tuning.rx_usecs", data: "integer", editable: true }, - { field: "bindTxUs", path: ".tuning.tx_usecs", data: "integer", editable: true }, - { field: "bindDisableRxVlan", path: ".tuning.disable_rxvlan", data: "bool", editable: true }, - { field: "bindDisableTxVlan", path: ".tuning.disable_txvlan", data: "bool", editable: true }, - { field: "bindDisableOffload", path: ".tuning.disable_offload", data: "array_of_strings", editable: true }, - - // Bridge/Stick - { conditional: true, condition: "if_exists", path: ".bridge", showDiv: "bridgeMode", hideDiv: "OnAStickMode" }, - { field: "bindUseXdpBridge", path: ".bridge.use_xdp_bridge", data: "bool", editable: true }, - { field: "bindBridgeToInternet", path: ".bridge.to_internet", data: "interface", editable: true }, - { field: "bindBridgeToNetwork", path: ".bridge.to_network", data: "interface", editable: true }, - { end_conditional: true }, - { conditional: true, condition: "if_exists", path: ".single_interface", showDiv: "OnAStickMode", hideDiv: "bridgeMode" }, - { field: "bindSingleInterfaceNic", path: ".single_interface.interface", data: "interface", editable: true }, - { field: "bindSingleInterfaceInternetVlan", path: ".single_interface.internet_vlan", data: "integer", editable: true }, - { field: "bindSingleInterfaceNetworklan", path: ".single_interface.network_vlan", data: "integer", editable: true }, - { end_conditional: true }, - - // Queues - { field: "bindSqm", path: ".queues.default_sqm", data: "select-premade", editable: true }, - { field: "bindQueueMode", path: ".queues.queue_mode", data: "select-premade", editable: true }, - { field: "bindUplinkMbps", path: ".queues.uplink_bandwidth_mbps", data: "integer", editable: true }, - { field: "bindDownlinkMbps", path: ".queues.downlink_bandwidth_mbps", data: "integer", editable: true }, - { field: "bindGeneratedDownlinkMbps", path: ".queues.generated_pn_download_mbps", data: "integer", editable: true }, - { field: "bindGeneratedUplinkMbps", path: ".queues.generated_pn_upload_mbps", data: "integer", editable: true }, - { field: "bindDryRun", path: ".queues.dry_run", data: "bool", editable: true }, - { field: "bindSudo", path: ".queues.sudo", data: "bool", editable: true }, - { field: "bindBinpack", path: ".queues.use_binpacking", data: "bool", editable: true }, - { field: "bindQueuesAvailableOverride", path: ".queues.override_available_queues", data: "integer", editable: true }, - - // LTS - { field: "bindEnableLTS", path: ".long_term_stats.gather_stats", data: "bool", editable: true }, - { field: "bindLtsCollation", path: ".long_term_stats.collation_period_seconds", data: "integer", editable: true }, - { field: "bindLtsLicense", path: ".long_term_stats.license_key", data: "string", editable: true }, - { field: "bindLtsUispInterval", path: ".long_term_stats.uisp_reporting_interval_seconds", data: "integer", editable: true }, - - // IP Ranges - { field: "bindIgnoreRanges", path: ".ip_ranges.ignore_subnets", data: "ip_array", editable: true }, - { field: "bindAllowRanges", path: ".ip_ranges.allow_subnets", data: "ip_array", editable: true }, - - // Flow tracking - { field: "bindFlowsTimeout", path: ".flows.flow_timeout_seconds", data: "integer", editable: true }, - { field: "bindEnableNetflow", path: ".flows.netflow_enabled", data: "bool", editable: true }, - { field: "bindFlowsPort", path: ".flows.netflow_port", data: "integer", editable: true }, - { field: "bindFlowsTarget", path: ".flows.netflow_ip", data: "string", editable: true }, - { field: "bindFlowsVersion", path: ".flows.netflow_ip", data: "select-premade", editable: true }, - - // Integration Common - { field: "bindCircuitNameAsAddress", path: ".integration_common.circuit_name_as_address", data: "bool", editable: true }, - { field: "bindOverwriteNetJson", path: ".integration_common.always_overwrite_network_json", data: "bool", editable: true }, - { field: "bindIntegrationMikrotik", path: ".integration_common.use_mikrotik_ipv6", data: "bool", editable: true }, - { field: "bindQueueRefreshInterval", path: ".integration_common.queue_refresh_interval_mins", data: "integer", editable: true }, - - // Splynx - { field: "bindSplynxEnable", path: ".splynx_integration.enable_splynx", data: "bool", editable: true }, - { field: "bindSplynxApiKey", path: ".splynx_integration.api_key", data: "string", editable: true }, - { field: "bindSplynxApiSecret", path: ".splynx_integration.api_secret", data: "string", editable: true }, - { field: "bindSplynxApiUrl", path: ".splynx_integration.url", data: "string", editable: true }, - - // Netzur - { field: "bindNetzurEnable", path: ".netzur_integration.enable_netzur", data: "bool", editable: true }, - { field: "bindNetzurApiKey", path: ".netzur_integration.api_key", data: "string", editable: true }, - { field: "bindNetzurApiUrl", path: ".netzur_integration.api_url", data: "string", editable: true }, - { field: "bindNetzurTimeout", path: ".netzur_integration.timeout_secs", data: "integer", editable: true }, - - // UISP - { field: "bindUispEnable", path: ".uisp_integration.enable_uisp", data: "bool", editable: true }, - { field: "bindUispToken", path: ".uisp_integration.token", data: "string", editable: true }, - { field: "bindUispUrl", path: ".uisp_integration.url", data: "string", editable: true }, - { field: "bindUispSite", path: ".uisp_integration.site", data: "string", editable: true }, - { field: "bindUispStrategy", path: ".uisp_integration.strategy", data: "select-premade", editable: true }, - { field: "bindUispSuspended", path: ".uisp_integration.suspended_strategy", data: "select-premade", editable: true }, - { field: "bindUispAirmaxCapacity", path: ".uisp_integration.airmax_capacity", data: "float", editable: true }, - { field: "bindUispLtuCapacity", path: ".uisp_integration.ltu_capacity", data: "float", editable: true }, - { field: "bindUispIpv6Mikrotik", path: ".uisp_integration.ipv6_with_mikrotik", data: "bool", editable: true }, - { field: "bindUispOverheadFactor", path: ".uisp_integration.bandwidth_overhead_factor", data: "float", editable: true }, - { field: "bindUispCommitMultiplier", path: ".uisp_integration.commit_bandwidth_multiplier", data: "float", editable: true }, - { field: "bindUispExcludeSites", path: ".uisp_integration.exclude_sites", data: "array_of_strings", editable: true }, - { field: "bindUispExceptionCpes", path: ".uisp_integration.exception_cpes", data: "array_of_strings", editable: true }, - { field: "bindUispUsePtmpAsParent", path: ".uisp_integration.use_ptmp_as_parent", data: "bool", editable: true }, - { field: "bindUispIgnoreCalculatedCapacity", path:".uisp_integration.ignore_calculated_capacity", data: "bool", editable: true }, - - // Powercode - { field: "bindPowercodeEnable", path: ".powercode_integration.enable_powercode", data: "bool", editable: true }, - { field: "bindPowercodeKey", path: ".powercode_integration.powercode_api_key", data: "string", editable: true }, - { field: "bindPowercodeUrl", path: ".powercode_integration.powercode_api_url", data: "string", editable: true }, - - // Sonar - { field: "bindSonarEnable", path: ".sonar_integration.enable_sonar", data: "bool", editable: true }, - { field: "bindSonarApiKey", path: ".sonar_integration.sonar_api_key", data: "string", editable: true }, - { field: "bindSonarApiUrl", path: ".sonar_integration.sonar_api_url", data: "string", editable: true }, - { field: "bindSonarSnmp", path: ".sonar_integration.snmp_community", data: "string", editable: true }, - { field: "bindSonarAirmax", path: ".sonar_integration.airmax_model_ids", data: "array_of_strings", editable: true }, - { field: "bindSonarLtu", path: ".sonar_integration.ltu_model_ids", data: "array_of_strings", editable: true }, - { field: "bindSonarActive", path: ".sonar_integration.active_status_ids", data: "array_of_strings", editable: true }, - - // Influx - { field: "bindInfluxEnable", path: ".influxdb.enable_influxdb", data: "bool", editable: true }, - { field: "bindInfluxUrl", path: ".influxdb.url", data: "string", editable: true }, - { field: "bindInfluxOrg", path: ".influxdb.org", data: "string", editable: true }, - { field: "bindInfluxBucket", path: ".influxdb.bucket", data: "string", editable: true }, - { field: "bindInfluxToken", path: ".influxdb.token", data: "string", editable: true }, - -]; - -function getConfigPath(path) { - if (!path || lqosd_config == null) { - return { found: false, value: undefined }; - } - let trimmed = path.startsWith(".") ? path.slice(1) : path; - if (trimmed.length === 0) { - return { found: true, value: lqosd_config }; - } - let current = lqosd_config; - let parts = trimmed.split("."); - for (let i = 0; i < parts.length; i++) { - let part = parts[i]; - if (current == null || !Object.prototype.hasOwnProperty.call(current, part)) { - return { found: false, value: undefined }; - } - current = current[part]; - } - return { found: true, value: current }; -} - -function ensureSplynxSection(config) { - if (!config || typeof config !== "object") { - return config; - } - - if (!config.splynx_integration || typeof config.splynx_integration !== "object") { - config.splynx_integration = {}; - } - - const splynx = config.splynx_integration; - if (typeof splynx.enable_splynx !== "boolean") splynx.enable_splynx = false; - if (typeof splynx.api_key !== "string") splynx.api_key = ""; - if (typeof splynx.api_secret !== "string") splynx.api_secret = ""; - if (typeof splynx.url !== "string") splynx.url = ""; - if (typeof splynx.strategy !== "string" || splynx.strategy.length === 0) { - splynx.strategy = "ap_only"; - } - - return config; -} - -function topologyEditDisabledAttr() { - return topology_editor_locked ? " disabled aria-disabled='true'" : ""; -} - -function applyTopologyEditorLockState() { - $("#btnSaveNetDevices").prop("disabled", topology_editor_locked); - $("#btnFlattenNetwork").prop("disabled", topology_editor_locked); - $("#btnAddNetworkNode").prop("disabled", topology_editor_locked); - $("#btnAddShapedDeviceRow").prop("disabled", topology_editor_locked || !shaped_devices_loaded); - $("#njsNewNodeName, #njsNewNodeDown, #njsNewNodeUp").prop("disabled", topology_editor_locked); - $("#netjson, #shapedDeviceTable").toggleClass("opacity-75", topology_editor_locked); - - const targets = [ - "#legacyTopologyEditorLockSummary", - "#legacyNetworkEditorLock", - "#legacyDevicesEditorLock", - ]; - for (const selector of targets) { - const element = $(selector); - if (topology_editor_locked && topology_editor_lock_message) { - element.removeClass("d-none").text(topology_editor_lock_message); - } else { - element.addClass("d-none").text(""); - } - } -} - -function doBindings() { - let active = true; - - for (var i=0; i', currentValue); - changes.push(i); - } - } - } - return changes; -} - -function validateConfig() { - console.log("Starting validator"); - $(".invalid").removeClass("invalid"); - let changes = detectChanges(); - if (changes.length === 0) { - console.log("Nothing changed!"); - alert("No configuration changes were made."); - return { - valid: false, - changes: [], - }; - } - - let valid = true; - let errors = []; - for (let i=0; i target.max) { - valid = false; - errors.push(target.path + " must be between " + target.min + " and " + target.max + "."); - $("#" + target.field).addClass("invalid"); - } - } - } - } else if (target.data === "float") { - let newValue = $("#" + target.field).val(); - newValue = parseFloat(newValue); - if (isNaN(newValue)) { - valid = false; - errors.push(target.path + " must be a decimal number."); - $("#" + target.field).addClass("invalid"); - } else { - if (target.min != null) { - if (newValue < target.min) { - valid = false; - errors.push(target.path + " must be between " + target.min + " and " + target.max + "."); - $("#" + target.field).addClass("invalid"); - } - } - if (target.max != null) { - if (newValue > target.max) { - valid = false; - errors.push(target.path + " must be between " + target.min + " and " + target.max + "."); - $("#" + target.field).addClass("invalid"); - } - } - } - } - } - - if (!valid) { - let errorMessage = ""; - for (let i=0; i { - if (msg && msg.ok) { - alert("Configuration saved"); - } else { - const message = msg && msg.message ? msg.message : "Error"; - alert("Configuration not saved: " + message); - } - }, - () => { - alert("Configuration not saved: Error"); - }, - ); -} - -function iterateNetJson(level, depth) { - let html = "
"; - const devices = Array.isArray(shaped_devices) ? shaped_devices : []; - const disabledAttr = topologyEditDisabledAttr(); - for (const [key, value] of Object.entries(level)) { - const isVirtual = value && value.virtual === true; - html += "
"; - html += "" + key + ""; - if (depth > 0) { - html += " "; - } - html += " "; - html += " "; - html += "
"; - html += "Download: " + value.downloadBandwidthMbps + " Mbps
"; - html += "Upload: " + value.uploadBandwidthMbps + " Mbps
"; - html += "Virtual: " + (isVirtual ? " Yes" : "No") + - "
"; - let num_children = 0; - for (let i=0; i
"; - //console.log(`${key}: ${value}`); - //console.log("Children", value.children); - if (value.children != null) { - html += iterateNetJson(value.children, depth + 1); - } - html += "
"; - } - html += "
"; - return html; -} - -function RenderNetworkJson() { - let html = ""; - html += iterateNetJson(network_json, 0); - $("#netjson").html(html); -} - -function flattenNetwork() { - if (topology_editor_locked) return; - if (confirm("Are you sure you wish to flatten your network? All topology will be removed, giving a flat network. All Shaped Devices will be reparented to the single node.")) { - network_json = {}; - RenderNetworkJson(); - if (Array.isArray(shaped_devices)) { - for (let i=0; i 0 && !isNaN(newDown) && !isNaN(newUp) && newDown >= MIN_NODE_MBPS && newUp >= MIN_NODE_MBPS) { - network_json[newName] = { - downloadBandwidthMbps: newDown, - uploadBandwidthMbps: newUp, - virtual: false, - children: {} - } - } - RenderNetworkJson(); -} - -function promoteNode(nodeId) { - if (topology_editor_locked) return; - console.log("Promoting ", nodeId); - let previousParent = null; - - function iterate(tree, depth) { - for (const [key, value] of Object.entries(tree)) { - console.log(key); - if (key === nodeId) { - console.log(key); - let tmp = value; - delete tree[nodeId]; - previousParent[nodeId] = tmp; - } - - if (value.children != null) { - previousParent = tree; - iterate(value.children, depth+1); - } - } - } - - iterate(network_json); - RenderNetworkJson(); -} - -function nodeSpeedChange(nodeId, direction) { - if (topology_editor_locked) return; - let newVal = prompt(`New ${direction === 'd' ? 'download' : 'upload'} value in Mbps`); - newVal = parseFloat(newVal); - if (isNaN(newVal)) { - alert("Please enter a valid number"); - return; - } - if (newVal < MIN_NODE_MBPS) { - alert(`New value must be at least ${MIN_NODE_MBPS} Mbps`); - return; - } - - // Find and set - function iterate(tree, depth) { - for (const [key, value] of Object.entries(tree)) { - console.log(key); - if (key === nodeId) { - switch (direction) { - case 'd' : value.downloadBandwidthMbps = newVal; break; - case 'u' : value.uploadBandwidthMbps = newVal; break; - default: console.log("Oopsie - unknown direction"); - } - } - - if (value.children != null) { - iterate(value.children, depth+1); - } - } - } - - iterate(network_json); - RenderNetworkJson(); -} - -function toggleVirtualNode(nodeId) { - if (topology_editor_locked) return; - function iterate(tree) { - for (const [key, value] of Object.entries(tree)) { - if (key === nodeId) { - value.virtual = !(value && value.virtual === true); - } - if (value.children != null) { - iterate(value.children); - } - } - } - - iterate(network_json); - RenderNetworkJson(); -} - -function deleteNode(nodeId) { - if (topology_editor_locked) return; - if (!confirm("Are you sure you want to delete " + nodeId + "? All child nodes will also be deleted.")) { - return; - } - let deleteList = [ nodeId ]; - let deleteParent = ""; - - // Find the node to delete - function iterate(tree, depth, parent) { - for (const [key, value] of Object.entries(tree)) { - if (key === nodeId) { - // Find nodes that will go away - if (value.children != null) { - iterateTargets(value.children, depth+1); - } - deleteParent = parent; - delete tree[key]; - } - - if (value.children != null) { - iterate(value.children, depth+1, key); - } - } - } - - function iterateTargets(tree, depth) { - for (const [key, value] of Object.entries(tree)) { - deleteList.push(key); - - if (value.children != null) { - iterateTargets(value.children, depth+1); - } - } - } - - // Find the nodes to delete and erase them - iterate(network_json, ""); - - // Now we have a list in deleteList of all the nodes that were deleted - // We need to go through ShapedDevices and re-parent devices - console.log(deleteParent); - if (!Array.isArray(shaped_devices)) { - RenderNetworkJson(); - return; - } - - if (deleteParent == null) { - // We deleted something at the top of the tree, so there's no - // natural parent! So we'll set them to be at the root. That's - // only really the right answer if the user went "flat" - but there's - // no way to know. So they'll have to fix some validation themselves. - for (let i=0; i -1) { - sd.parent_node = ""; - } - } - alert("Because there was no obvious parent, you may have to fix some parenting in your Shaped Devices list."); - } else { - // Move everything up the tree - for (let i=0; i -1) { - sd.parent_node = deleteParent; - } - } - } - - // Update the display - shaped_devices_dirty = true; - RenderNetworkJson(); - shapedDevices(); -} - -function renameNode(nodeId) { - if (topology_editor_locked) return; - let newName = prompt("New node name?"); - if (!newName || newName.length === 0) { - return; - } - - function iterate(tree, depth) { - for (const [key, value] of Object.entries(tree)) { - console.log(key); - if (key === nodeId) { - console.log(key); - let tmp = value; - delete tree[nodeId]; - tree[newName] = tmp; - } - - if (value.children != null) { - iterate(value.children, depth+1); - } - } - } - - iterate(network_json); - - if (Array.isArray(shaped_devices)) { - for (let i=0; i" - } else { - html = "" - } - return html; -} - -function makeSheetNumberBox(rowId, boxId, value) { - let html = "" - return html; -} - -function separatedIpArray(rowId, boxId, value) { - let html = ""; - let val = ""; - for (i = 0; i 0) { - val = val.substring(0, val.length-2); - } - html += ""; - html += ""; - return html; -} - -function nodeDropDown(rowId, boxId, selectedNode) { - let html = ""; - html += ""; - } - html += ""; - return html; -} - -function display() { - let colorPreference = window.localStorage.getItem("colorPreference"); - if (colorPreference == null) { - window.localStorage.setItem("colorPreference", 0); - colorPreference = 0; - } - $("#colorMode option[id='" + colorPreference + "']").attr("selected", true); - let redact = window.localStorage.getItem("redact"); - if (redact == null) { - window.localStorage.setItem("redact", false); - redact = false; - } - if (redact == "false") redact = false; - $("#redact").prop('checked', redact); - $("#applyDisplay").on('click', () => { - let colorPreference = $("#colorMode").find('option:selected').attr('id'); - window.localStorage.setItem("colorPreference", colorPreference); - let redact = $("#redact").prop('checked'); - window.localStorage.setItem("redact", redact); - }); -} - -function start() { - // Bindings - $("#btnFlattenNetwork").on('click', flattenNetwork); - $("#btnAddNetworkNode").on('click', addNetworkNode); - window.setBridgeMode = setBridgeMode; - window.setStickMode = setStickMode; - window.newSdRow = newSdRow; - window.promoteNode = promoteNode; - window.renameNode = renameNode; - window.deleteNode = deleteNode; - window.nodeSpeedChange = nodeSpeedChange; - window.toggleVirtualNode = toggleVirtualNode; - window.deleteSdRow = deleteSdRow; - renderShapedDevicesPlaceholder("Open the Shaped Devices tab to load and edit device rows."); - $("#v-pills-shapeddevs-tab").on("shown.bs.tab", () => { - ensureShapedDevicesLoaded(); - }); - $("#shapedDeviceTable").on("input change", "input, select, textarea", () => { - if (shaped_devices_loaded) { - shaped_devices_dirty = true; - } - }); - - // Old - display(); - sendWsRequest( - "AdminCheck", - { AdminCheck: {} }, - (msg) => { - const is_admin = msg && msg.ok; - if (!is_admin) { - $("#controls").html("
-
-
-
-
Configuration
-
-
- Save - Configuration - -
-
- - - - - -
- -
- -
-

General Settings

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- Used for QoO/QoQ scoring (defaults to Web browsing). -
-
- - -
-

Tuning Settings

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-

Bridge Settings

- -

Connection Mode:

- - -
- - -
-
- -
-

Bridge Mode

- - - - - - - - - - - - - -
-
-
-
- -
-

Single-Interface ("On a Stick") Mode

- - - - - - - - - - - - - -
-
-
- - -
-

Queue Settings

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- Observe removes the LibreQoS shaping tree so you can gather a true baseline. Changing modes requires a reload and may briefly interrupt traffic. -
-
-
- - -
-

Insight Settings

- - - - - - - - - - - - - - - - - -
-
- - -
-

IP Ranges Settings

- - - - - - - - - -
- Enter one IP range per Line
- -
- Enter one IP range per Line
- -
-
- - -
-

Flows Settings

- - - - - - - - - - - - - - - - - - - - - -
- -
-
- - -
-

Common Integration Settings

- - - - - - - - - - - - - - - - - -
-
- - -
-

Splynx Integration Settings

- - - - - - - - - - - - - - - - - -
-
- - -
-

Netzur Integration Settings

- - - - - - - - - - - - - - - - - -
-
- - -
-

UISP Integration Settings

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
-
- - -
-

Powercode Integration Settings

- - - - - - - - - - - - - -
-
- - -
-

Sonar Integration Settings

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-

InfluxDB Integration Settings

- - - - - - - - - - - - - - - - - - - - - -
-
- - -
-

Network.Json - Network Layout

- - -

Network Adjustment Options

-
-
- -
-
-
-
-
- -
-
- -
-
-
- - -
-

Shaped Devices

- -

All shaped device rows currently known to LibreQoS.

- - -
-
- -
-

LibreQos Web Interface Users

-
-
-
-
- - -
-
- - diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index 7c95f2c4..bbe4435c 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -34,7 +34,6 @@ pub(super) fn static_routes() -> Result { "tree.html", "help.html", "unknown_ips.html", - "configuration.html", "circuit.html", "ethernet_caps.html", "site_map.html", diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 1a0e10ab..eeb3f5dd 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -2004,3 +2004,153 @@ fn payload_hint(payload: &[u8]) -> &'static str { _ => "binary", } } + +#[cfg(test)] +mod tests { + use super::decode_ws_request; + use crate::node_manager::ws::messages::WsRequest; + use serde_cbor::Value as CborValue; + use std::collections::BTreeMap; + + fn text(value: &str) -> CborValue { + CborValue::Text(value.to_string()) + } + + fn integer(value: i128) -> CborValue { + CborValue::Integer(value) + } + + fn float(value: f64) -> CborValue { + CborValue::Float(value) + } + + fn array(items: Vec) -> CborValue { + CborValue::Array(items) + } + + fn map(entries: Vec<(&str, CborValue)>) -> CborValue { + let mut out = BTreeMap::new(); + for (key, value) in entries { + out.insert(text(key), value); + } + CborValue::Map(out) + } + + fn device_value(ipv4: CborValue, ipv6: CborValue) -> CborValue { + map(vec![ + ("circuit_id", text("circuit-1")), + ("circuit_name", text("Circuit 1")), + ("device_id", text("device-1")), + ("device_name", text("Device 1")), + ("parent_node", text("tower-a")), + ("mac", text("")), + ("ipv4", ipv4), + ("ipv6", ipv6), + ("download_min_mbps", float(100.0)), + ("upload_min_mbps", float(100.0)), + ("download_max_mbps", float(200.0)), + ("upload_max_mbps", float(200.0)), + ("comment", text("")), + ]) + } + + fn create_shaped_device_payload(device: CborValue) -> Vec { + serde_cbor::to_vec(&map(vec![( + "CreateShapedDevice", + map(vec![("device", device)]), + )])) + .expect("valid create payload") + } + + fn update_shaped_device_payload(device: CborValue) -> Vec { + serde_cbor::to_vec(&map(vec![( + "UpdateShapedDevice", + map(vec![ + ("original_device_id", text("device-1")), + ("device", device), + ]), + )])) + .expect("valid update payload") + } + + #[test] + fn decodes_create_shaped_device_with_browser_style_ipv4_bytes() { + let payload = create_shaped_device_payload(device_value( + array(vec![array(vec![ + array(vec![ + integer(192), + integer(168), + integer(1), + integer(2), + ]), + integer(32), + ])]), + array(vec![]), + )); + + let request = decode_ws_request(&payload).expect("create request should decode"); + match request { + WsRequest::CreateShapedDevice { device } => { + assert_eq!(device.device_id, "device-1"); + assert_eq!(device.ipv4.len(), 1); + assert_eq!(device.ipv4[0].0.octets(), [192, 168, 1, 2]); + assert_eq!(device.ipv4[0].1, 32); + } + other => panic!("unexpected request: {other:?}"), + } + } + + #[test] + fn decodes_update_shaped_device_with_browser_style_ipv6_bytes() { + let payload = update_shaped_device_payload(device_value( + array(vec![]), + array(vec![array(vec![ + array(vec![ + integer(0x20), + integer(0x01), + integer(0x0d), + integer(0xb8), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(0), + integer(1), + ]), + integer(128), + ])]), + )); + + let request = decode_ws_request(&payload).expect("update request should decode"); + match request { + WsRequest::UpdateShapedDevice { + original_device_id, + device, + } => { + assert_eq!(original_device_id, "device-1"); + assert_eq!(device.ipv6.len(), 1); + assert_eq!(device.ipv6[0].0.to_string(), "2001:db8::1"); + assert_eq!(device.ipv6[0].1, 128); + } + other => panic!("unexpected request: {other:?}"), + } + } + + #[test] + fn rejects_legacy_string_encoded_ipv4_payloads() { + let payload = create_shaped_device_payload(device_value( + array(vec![array(vec![text("192.168.1.2"), integer(32)])]), + array(vec![]), + )); + + let err = decode_ws_request(&payload).expect_err("legacy payload must fail"); + assert!(err.contains("expected an array of length 4")); + assert!(err.contains("192.168.1.2")); + } +}