mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Merge remote-tracking branch 'origin/develop' into fix/blocking_node_manager
# Conflicts: # src/rust/lqos_network_devices/src/actor.rs # src/rust/lqosd/src/node_manager/ws.rs
This commit is contained in:
commit
d72ed90fa0
73 files changed
+4595
-627
No files matched your search
+88
-15
@@ -51,13 +51,37 @@ from liblqos_python import is_lqosd_alive, clear_ip_mappings, delete_ip_mapping,
|
||||
shaping_cpu_count, \
|
||||
Bakery
|
||||
|
||||
# Optional: urgent issue submission (available in newer liblqos_python)
|
||||
# Optional: urgent issue helpers (available in newer liblqos_python)
|
||||
try:
|
||||
from liblqos_python import submit_urgent_issue # type: ignore
|
||||
except Exception:
|
||||
def submit_urgent_issue(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
try:
|
||||
from liblqos_python import clear_urgent_issue_by_identity # type: ignore
|
||||
except ImportError:
|
||||
_missing_clear_urgent_issue_by_identity_warned = False
|
||||
|
||||
def clear_urgent_issue_by_identity(*_args, **_kwargs):
|
||||
global _missing_clear_urgent_issue_by_identity_warned
|
||||
if not _missing_clear_urgent_issue_by_identity_warned:
|
||||
logging.warning("Unable to clear recovered urgent issues: liblqos_python helper is unavailable")
|
||||
_missing_clear_urgent_issue_by_identity_warned = True
|
||||
return False
|
||||
|
||||
try:
|
||||
from liblqos_python import xdp_ip_mapping_ready # type: ignore
|
||||
except ImportError:
|
||||
_missing_xdp_ip_mapping_ready_warned = False
|
||||
|
||||
def xdp_ip_mapping_ready():
|
||||
global _missing_xdp_ip_mapping_ready_warned
|
||||
if not _missing_xdp_ip_mapping_ready_warned:
|
||||
logging.warning("Unable to check XDP IP mapping readiness: liblqos_python helper is unavailable")
|
||||
_missing_xdp_ip_mapping_ready_warned = True
|
||||
return True
|
||||
|
||||
try:
|
||||
from liblqos_python import calculate_topology_source_generation # type: ignore
|
||||
except Exception:
|
||||
@@ -78,6 +102,11 @@ class ValidationFailure(RefreshFailure):
|
||||
pass
|
||||
|
||||
|
||||
XDP_IP_MAPPING_APPLY_FAILED = "XDP_IP_MAPPING_APPLY_FAILED"
|
||||
XDP_MAPPING_READY_TIMEOUT_SECONDS = 5.0
|
||||
XDP_MAPPING_READY_INTERVAL_SECONDS = 0.1
|
||||
|
||||
|
||||
def report_refresh_failure(code, message, context=None, dedupe_key=None):
|
||||
logging.error(message)
|
||||
print("ERROR: " + message)
|
||||
@@ -98,6 +127,56 @@ def report_refresh_failure(code, message, context=None, dedupe_key=None):
|
||||
pass
|
||||
raise RefreshFailure(message)
|
||||
|
||||
|
||||
def wait_for_xdp_ip_mapping_ready(
|
||||
ready=xdp_ip_mapping_ready,
|
||||
sleep=time.sleep,
|
||||
now=time.monotonic,
|
||||
timeout_seconds=XDP_MAPPING_READY_TIMEOUT_SECONDS,
|
||||
interval_seconds=XDP_MAPPING_READY_INTERVAL_SECONDS,
|
||||
):
|
||||
deadline = now() + timeout_seconds
|
||||
while now() < deadline:
|
||||
if ready():
|
||||
return
|
||||
sleep(interval_seconds)
|
||||
raise TimeoutError("XDP IP mapping BPF maps are not ready")
|
||||
|
||||
|
||||
def apply_xdp_ip_mappings(
|
||||
ip_map_batch,
|
||||
context,
|
||||
ready=xdp_ip_mapping_ready,
|
||||
sleep=time.sleep,
|
||||
report_failure=report_refresh_failure,
|
||||
clear_recovered_issue=clear_urgent_issue_by_identity,
|
||||
):
|
||||
try:
|
||||
wait_for_xdp_ip_mapping_ready(ready=ready, sleep=sleep)
|
||||
except (TimeoutError, OSError) as exc:
|
||||
report_failure(
|
||||
XDP_IP_MAPPING_APPLY_FAILED,
|
||||
"Failed to apply XDP IP mappings: " + str(exc),
|
||||
context,
|
||||
XDP_IP_MAPPING_APPLY_FAILED,
|
||||
)
|
||||
return
|
||||
try:
|
||||
ip_map_batch.finish_ip_mappings()
|
||||
ip_map_batch.submit()
|
||||
except OSError as exc:
|
||||
report_failure(
|
||||
XDP_IP_MAPPING_APPLY_FAILED,
|
||||
"Failed to apply XDP IP mappings: " + str(exc),
|
||||
context,
|
||||
XDP_IP_MAPPING_APPLY_FAILED,
|
||||
)
|
||||
return
|
||||
try:
|
||||
clear_recovered_issue(XDP_IP_MAPPING_APPLY_FAILED, XDP_IP_MAPPING_APPLY_FAILED)
|
||||
except OSError as exc:
|
||||
logging.warning("Unable to clear recovered XDP IP mapping urgent issue: %s", exc)
|
||||
|
||||
R2Q = 10
|
||||
#MAX_R2Q = 200_000
|
||||
MAX_R2Q = 60_000 # See https://lartc.vger.kernel.narkive.com/NKaH1ZNG/htb-quantum-of-class-100001-is-small-consider-r2q-change
|
||||
@@ -2757,20 +2836,14 @@ def refreshShapers():
|
||||
print("Observe mode active; skipping XDP-CPUMAP-TC IP filter apply after clearing mappings")
|
||||
numXdpCommands = 0
|
||||
else:
|
||||
ipMapBatch.finish_ip_mappings()
|
||||
try:
|
||||
ipMapBatch.submit()
|
||||
except Exception as e:
|
||||
report_refresh_failure(
|
||||
"XDP_IP_MAPPING_APPLY_FAILED",
|
||||
"Failed to apply XDP IP mappings: " + str(e),
|
||||
{
|
||||
"required_ip_mappings": requiredIpMappings,
|
||||
"queued_requests": numXdpCommands,
|
||||
"on_a_stick": on_a_stick(),
|
||||
},
|
||||
"XDP_IP_MAPPING_APPLY_FAILED",
|
||||
)
|
||||
apply_xdp_ip_mappings(
|
||||
ipMapBatch,
|
||||
{
|
||||
"required_ip_mappings": requiredIpMappings,
|
||||
"queued_requests": numXdpCommands,
|
||||
"on_a_stick": on_a_stick(),
|
||||
},
|
||||
)
|
||||
#for command in xdpCPUmapCommands:
|
||||
# logging.info(command)
|
||||
# commands = command.split(' ')
|
||||
|
||||
@@ -5,7 +5,9 @@ Requires=lqosd.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/libreqos/src
|
||||
ExecStart=/usr/bin/python3 /opt/libreqos/src/scheduler.py
|
||||
ExecStartPre=/bin/test -x /opt/libreqos/venv/bin/python
|
||||
ExecStartPre=/opt/libreqos/venv/bin/python -c "import apscheduler, chardet, deepdiff, psutil, requests"
|
||||
ExecStart=/opt/libreqos/venv/bin/python /opt/libreqos/src/scheduler.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
VENV_DIR="/opt/libreqos/venv"
|
||||
REQUIREMENTS=""
|
||||
CONSTRAINTS=""
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "rebuild_python_venv.sh must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for candidate in /opt/libreqos/src/requirements.txt /opt/libreqos/requirements.txt; do
|
||||
if [ -f "$candidate" ]; then
|
||||
REQUIREMENTS="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$REQUIREMENTS" ]; then
|
||||
echo "LibreQoS Python requirements file not found in /opt/libreqos/src or /opt/libreqos." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for candidate in /opt/libreqos/src/deb-requirements-constraints.txt /opt/libreqos/deb-requirements-constraints.txt; do
|
||||
if [ -s "$candidate" ]; then
|
||||
CONSTRAINTS="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
python3 -m venv --clear "$VENV_DIR"
|
||||
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
||||
|
||||
if [ -n "$CONSTRAINTS" ]; then
|
||||
"$VENV_DIR/bin/python" -m pip install --upgrade -c "$CONSTRAINTS" -r "$REQUIREMENTS"
|
||||
else
|
||||
"$VENV_DIR/bin/python" -m pip install --upgrade -r "$REQUIREMENTS"
|
||||
fi
|
||||
|
||||
chown -R root:root "$VENV_DIR"
|
||||
chmod -R go-w "$VENV_DIR"
|
||||
+23
-16
@@ -12,7 +12,7 @@ PACKAGE=libreqos
|
||||
VERSION=$(cat ./VERSION_STRING).$BUILD_DATE
|
||||
PKGVERSION="${PACKAGE}_${VERSION}"
|
||||
DPKG_DIR=dist/$PKGVERSION-1_amd64
|
||||
APT_DEPENDENCIES="python3-pip, nano, curl, ca-certificates"
|
||||
APT_DEPENDENCIES="python3-pip, python3-venv, nano, curl, ca-certificates"
|
||||
DEBIAN_DIR=$DPKG_DIR/DEBIAN
|
||||
LQOS_DIR=$DPKG_DIR/opt/libreqos/src
|
||||
LQOS_STATE_DIR=$DPKG_DIR/opt/libreqos/state
|
||||
@@ -27,6 +27,7 @@ LQOS_FILES=(
|
||||
integrationNetzur.py
|
||||
integrationRestHttp.py
|
||||
integrationSonar.py
|
||||
integrationUtils.py
|
||||
integrationSplynx.py
|
||||
integrationVISP.py
|
||||
integrationWISPGate.py
|
||||
@@ -53,6 +54,7 @@ LQOS_BIN_FILES=(
|
||||
lqosd.service.example
|
||||
lqos_api.service.example
|
||||
lqos_setup.service.example
|
||||
rebuild_python_venv.sh
|
||||
)
|
||||
|
||||
RUSTPROGS=(
|
||||
@@ -140,16 +142,10 @@ else
|
||||
fi
|
||||
}
|
||||
|
||||
# Install Python Dependencies
|
||||
pushd /opt/libreqos > /dev/null
|
||||
# - Setup Python dependencies as a post-install task. Use --ignore-installed so
|
||||
# pip does not try to uninstall Debian-managed packages that do not ship a
|
||||
# pip RECORD file (for example blinker on Ubuntu 24.04).
|
||||
if [ -s src/deb-requirements-constraints.txt ]; then
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -c src/deb-requirements-constraints.txt -r src/requirements.txt
|
||||
else
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -r src/requirements.txt
|
||||
fi
|
||||
# - Setup Python dependencies in a root-owned virtual environment. LibreQoS
|
||||
# services still run as root, but Python packages no longer mix with
|
||||
# apt-managed system site-packages.
|
||||
/opt/libreqos/src/bin/rebuild_python_venv.sh
|
||||
|
||||
# Ensure folder permissions are correct post-install
|
||||
set_libreqos_operator_permissions
|
||||
@@ -161,10 +157,20 @@ install -m 0644 /opt/libreqos/src/bin/lqos_api.service.example /etc/systemd/syst
|
||||
install -m 0644 /opt/libreqos/src/bin/lqos_setup.service.example /etc/systemd/system/lqos_setup.service
|
||||
/bin/rm -f /etc/systemd/system/lqos_netplan_helper.service || true
|
||||
/bin/systemctl daemon-reload || true
|
||||
/bin/systemctl stop lqos_node_manager || true # In case it's running from a previous release
|
||||
/bin/systemctl disable lqos_node_manager || true # In case it's running from a previous release
|
||||
/bin/systemctl stop lqos_netplan_helper || true
|
||||
/bin/systemctl disable lqos_netplan_helper || true
|
||||
|
||||
unit_stop_disable_if_present() {
|
||||
local unit="$1"
|
||||
if /bin/systemctl cat "${unit}" >/dev/null 2>&1 \
|
||||
|| [ -e "/etc/systemd/system/${unit}" ] \
|
||||
|| [ -e "/lib/systemd/system/${unit}" ] \
|
||||
|| [ -e "/usr/lib/systemd/system/${unit}" ]; then
|
||||
/bin/systemctl stop "${unit}" || true
|
||||
/bin/systemctl disable "${unit}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
unit_stop_disable_if_present lqos_node_manager.service # In case it's running from a previous release
|
||||
unit_stop_disable_if_present lqos_netplan_helper.service
|
||||
case "$(/opt/libreqos/src/bin/lqos_setup postinst-action)" in
|
||||
activate_runtime)
|
||||
/opt/libreqos/src/bin/lqos_setup activate-runtime
|
||||
@@ -191,7 +197,6 @@ HOTFIX
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
popd > /dev/null
|
||||
EOF
|
||||
|
||||
# Uninstall Script
|
||||
@@ -235,6 +240,8 @@ for file in "${LQOS_BIN_FILES[@]}"; do
|
||||
cp "bin/$file" "$LQOS_DIR/bin"
|
||||
done
|
||||
|
||||
chmod a+x "$LQOS_DIR/bin/rebuild_python_venv.sh"
|
||||
|
||||
# Copy the remove pinned maps
|
||||
cp rust/remove_pinned_maps.sh "$LQOS_DIR"/rust
|
||||
|
||||
|
||||
+30
-3
@@ -22,8 +22,10 @@ do
|
||||
esac
|
||||
done
|
||||
|
||||
VENV_PYTHON="/opt/libreqos/venv/bin/python"
|
||||
|
||||
# Check Pre-Requisites
|
||||
sudo apt install python3-pip clang gcc gcc-multilib llvm libelf-dev git nano curl screen llvm pkg-config linux-tools-common linux-tools-`uname -r` libbpf-dev libssl-dev curl
|
||||
sudo apt install python3-pip python3-venv clang gcc gcc-multilib llvm libelf-dev git nano curl screen llvm pkg-config linux-tools-common linux-tools-`uname -r` libbpf-dev libssl-dev curl
|
||||
|
||||
if ! rustup -V &> /dev/null
|
||||
then
|
||||
@@ -214,7 +216,30 @@ clear_pinned_maps_before_lqosd_restart() {
|
||||
fi
|
||||
}
|
||||
|
||||
rebuild_python_venv() {
|
||||
local script_path="./bin/rebuild_python_venv.sh"
|
||||
if [ ! -x "$script_path" ]; then
|
||||
echo "Expected $script_path to exist and be executable before refreshing Python services."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rebuilding /opt/libreqos/venv before restarting Python services."
|
||||
if ! sudo "$script_path"; then
|
||||
echo "Failed to rebuild /opt/libreqos/venv. Skipping service refresh/restarts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "Expected $VENV_PYTHON to exist and be executable after rebuilding the Python venv."
|
||||
echo "Skipping service refresh/restarts."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
SERVICE_UNITS_UPDATED=0
|
||||
|
||||
rebuild_python_venv
|
||||
|
||||
refresh_service_unit lqosd
|
||||
refresh_service_unit lqos_scheduler
|
||||
refresh_service_unit lqos_api
|
||||
@@ -250,8 +275,9 @@ else
|
||||
echo "lqosd is active as a service. Restarting it. You may need to enter your sudo password."
|
||||
sudo systemctl restart lqosd
|
||||
fi
|
||||
if service_is_active lqos_scheduler; then
|
||||
echo "lqos_scheduler is active as a service. Restarting it. You may need to enter your sudo password."
|
||||
if service_is_active lqos_scheduler || service_is_failed lqos_scheduler || service_is_enabled lqos_scheduler; then
|
||||
echo "lqos_scheduler is installed as an active, failed, or enabled service. Restarting it. You may need to enter your sudo password."
|
||||
sudo systemctl reset-failed lqos_scheduler || true
|
||||
sudo systemctl restart lqos_scheduler
|
||||
fi
|
||||
if service_is_active lqos_api || service_is_failed lqos_api || service_is_enabled lqos_api; then
|
||||
@@ -267,6 +293,7 @@ fi
|
||||
echo "-----------------------------------------------------------------"
|
||||
echo "Don't forget to setup /etc/lqos.conf!"
|
||||
echo "Template .service files can be found in bin/"
|
||||
echo "Debian package installs create/update /opt/libreqos/venv for Python dependencies."
|
||||
echo "If src/deb-requirements-constraints.txt exists, Debian package installs use it to constrain Python dependencies."
|
||||
echo "Use ./systemd_hotfix.sh to evaluate or install the Ubuntu 24.04 networkd hotfix from the LibreQoS APT repo at https://repo.libreqos.com."
|
||||
echo "The hotfix installer now offers to schedule a reboot after it finishes."
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_mac(mac: Any) -> str:
|
||||
if mac is None:
|
||||
return ""
|
||||
value = str(mac).strip()
|
||||
if not value:
|
||||
return ""
|
||||
return value.replace(":", "").replace("-", "").upper()
|
||||
+5
-14
@@ -34,6 +34,7 @@ from integrationCommon import (
|
||||
apply_client_bandwidth_multiplier,
|
||||
isIntegrationOutputIpAllowed,
|
||||
)
|
||||
from integrationUtils import normalize_mac
|
||||
|
||||
|
||||
TOKEN_URL = "https://data.visp.net/token"
|
||||
@@ -71,16 +72,6 @@ def _unit_to_mbps(value: Any, unit: Any) -> Optional[float]:
|
||||
return v
|
||||
|
||||
|
||||
def _normalize_mac(mac: Any) -> str:
|
||||
if mac is None:
|
||||
return ""
|
||||
s = str(mac).strip()
|
||||
if not s:
|
||||
return ""
|
||||
# VISP sometimes returns MACs without separators. Keep uppercase hex.
|
||||
return s.replace(":", "").replace("-", "").upper()
|
||||
|
||||
|
||||
def _safe_isp_id_from_token_payload(payload: Dict[str, Any]) -> Optional[int]:
|
||||
isp_ids = payload.get("ispId")
|
||||
if isinstance(isp_ids, list) and isp_ids:
|
||||
@@ -505,7 +496,7 @@ def _extract_mac_candidates(row: Dict[str, Any]) -> List[str]:
|
||||
if not isinstance(equipment_data, dict):
|
||||
equipment_data = {}
|
||||
for key in ("Fiber MAC", "mac_address", "Ethernet MAC", "WAN MAC", "MAC Addr", "radio_mac"):
|
||||
mac = _normalize_mac(equipment_data.get(key))
|
||||
mac = normalize_mac(equipment_data.get(key))
|
||||
if mac and mac not in seen:
|
||||
seen.add(mac)
|
||||
candidates.append(mac)
|
||||
@@ -921,7 +912,7 @@ def createShaper() -> None:
|
||||
if eq_id > 0:
|
||||
equipment_ids.add(eq_id)
|
||||
for key in ("mac_address", "radio_mac"):
|
||||
mac = _normalize_mac(eq.get(key))
|
||||
mac = normalize_mac(eq.get(key))
|
||||
if mac:
|
||||
preferred_macs.add(mac)
|
||||
for ws in ws_list:
|
||||
@@ -947,7 +938,7 @@ def createShaper() -> None:
|
||||
if not ipv4s:
|
||||
counters["skipped_no_ip"] += 1
|
||||
continue
|
||||
mac = _normalize_mac(ws.get("mac_address"))
|
||||
mac = normalize_mac(ws.get("mac_address"))
|
||||
if mac:
|
||||
preferred_macs.add(mac)
|
||||
add_service_record(
|
||||
@@ -1032,7 +1023,7 @@ def createShaper() -> None:
|
||||
ipv4s = list(ip_cache)
|
||||
except Exception:
|
||||
ipv4s = list(ip_cache)
|
||||
mac = _normalize_mac(service_details.get("mac_address"))
|
||||
mac = normalize_mac(service_details.get("mac_address"))
|
||||
if not mac and preferred_macs:
|
||||
mac = sorted(preferred_macs)[0]
|
||||
|
||||
|
||||
@@ -25,12 +25,14 @@ disable_offload = [ "gso", "tso", "lro", "sg", "gro" ]
|
||||
use_xdp_bridge = false
|
||||
to_internet = "eth0"
|
||||
to_network = "eth1"
|
||||
# mtu = 1500
|
||||
|
||||
# OR:
|
||||
#[single_interface]
|
||||
#interface = "eth0"
|
||||
#internet_vlan = 2
|
||||
#network_vlan = 3
|
||||
# mtu = 1500
|
||||
|
||||
[queues]
|
||||
# Global default SQM is a single token list applied to both directions.
|
||||
|
||||
+204
-52
@@ -1,12 +1,39 @@
|
||||
#!/usr/bin/python3
|
||||
import csv
|
||||
import ipaddress
|
||||
import json
|
||||
import sys
|
||||
from enum import IntEnum
|
||||
from typing import NamedTuple
|
||||
|
||||
import routeros_api
|
||||
|
||||
from integrationUtils import normalize_mac
|
||||
from liblqos_python import load_mikrotik_ipv6_routers_json, mikrotik_ipv6_config_path
|
||||
|
||||
class SourcePriority(IntEnum):
|
||||
ARP = 0
|
||||
NEIGHBOR = 1
|
||||
DHCP = 2
|
||||
|
||||
class Ipv6Rank(IntEnum):
|
||||
INVALID = -1
|
||||
LINK_LOCAL = 0
|
||||
HOST = 1
|
||||
PREFIX = 2
|
||||
|
||||
class IpRecord(NamedTuple):
|
||||
source_priority: SourcePriority
|
||||
address_rank: int
|
||||
canonical_address: str
|
||||
address: str
|
||||
ambiguous: bool = False
|
||||
|
||||
class ClientAddressRecord(NamedTuple):
|
||||
canonical_address: str
|
||||
address: str
|
||||
ambiguous: bool = False
|
||||
|
||||
def _load_legacy_router_csv(csv_path):
|
||||
router_list = []
|
||||
with open(csv_path) as csv_file:
|
||||
@@ -32,13 +59,182 @@ def _load_router_list(configPath=None):
|
||||
return _load_legacy_router_csv(configPath)
|
||||
raise ValueError("Explicit Mikrotik IPv6 config overrides must currently point to a legacy .csv file")
|
||||
|
||||
def pullMikrotikIPv6(configPath=None):
|
||||
import routeros_api
|
||||
def _parse_ip_address(address):
|
||||
try:
|
||||
return ipaddress.ip_address(address.split('/')[0])
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
|
||||
def _canonical_ip_text(address):
|
||||
parsed_address = _parse_ip_address(address)
|
||||
if parsed_address is None:
|
||||
return None
|
||||
return str(parsed_address)
|
||||
|
||||
def _canonical_ip_network_text(address):
|
||||
try:
|
||||
network = ipaddress.ip_network(address, strict=False)
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
return network.with_prefixlen
|
||||
|
||||
def _entry_value(entry, key):
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
return entry.get(key)
|
||||
|
||||
def _record_address_by_mac(entry, mac_to_ipv4, mac_to_ipv6):
|
||||
mac = _entry_value(entry, 'mac-address')
|
||||
address = _entry_value(entry, 'address')
|
||||
parsed_address = _parse_ip_address(address)
|
||||
if mac is None or parsed_address is None:
|
||||
return
|
||||
normalized_mac = normalize_mac(mac)
|
||||
if parsed_address.version == 4:
|
||||
_record_ipv4_for_mac(mac_to_ipv4, normalized_mac, address, source_priority=SourcePriority.ARP)
|
||||
else:
|
||||
_record_ipv6_for_mac(mac_to_ipv6, normalized_mac, address, source_priority=SourcePriority.ARP)
|
||||
|
||||
def _record_dhcp4_address_by_mac(entry, mac_to_ipv4):
|
||||
mac = _entry_value(entry, 'mac-address')
|
||||
address = _entry_value(entry, 'address')
|
||||
parsed_address = _parse_ip_address(address)
|
||||
if mac is None or parsed_address is None or parsed_address.version != 4:
|
||||
return
|
||||
_record_ipv4_for_mac(mac_to_ipv4, normalize_mac(mac), address, source_priority=SourcePriority.DHCP)
|
||||
|
||||
def _mac_from_dhcpv6_duid(duid):
|
||||
duid_hex = str(duid).strip().replace(":", "").replace("-", "").lower()
|
||||
if duid_hex.startswith("0x"):
|
||||
duid_hex = duid_hex[2:]
|
||||
try:
|
||||
duid_bytes = bytes.fromhex(duid_hex)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(duid_bytes) == 14 and duid_bytes[0:2] == b'\x00\x01':
|
||||
return normalize_mac(duid_bytes[8:14].hex())
|
||||
if len(duid_bytes) == 10 and duid_bytes[0:2] == b'\x00\x03':
|
||||
return normalize_mac(duid_bytes[4:10].hex())
|
||||
return None
|
||||
|
||||
def _record_ipv4_for_mac(mac_to_ipv4, mac, address, source_priority):
|
||||
if not mac or not address:
|
||||
return
|
||||
parsed_address = _parse_ip_address(address)
|
||||
if parsed_address is None:
|
||||
return
|
||||
current = mac_to_ipv4.get(mac)
|
||||
candidate = IpRecord(source_priority, int(parsed_address), str(parsed_address), address)
|
||||
if current is None or source_priority > current.source_priority:
|
||||
mac_to_ipv4[mac] = candidate
|
||||
elif source_priority == current.source_priority and candidate.canonical_address != current.canonical_address:
|
||||
mac_to_ipv4[mac] = current._replace(ambiguous=True)
|
||||
|
||||
def _ipv6_rank(address):
|
||||
try:
|
||||
network = ipaddress.ip_network(address, strict=False)
|
||||
except ValueError:
|
||||
return Ipv6Rank.INVALID
|
||||
if network.version != 6:
|
||||
return Ipv6Rank.INVALID
|
||||
ip = network.network_address
|
||||
if ip.is_unspecified or ip.is_loopback or ip.is_multicast:
|
||||
return Ipv6Rank.INVALID
|
||||
if ip.is_link_local:
|
||||
address_priority = Ipv6Rank.LINK_LOCAL
|
||||
elif network.prefixlen < 128:
|
||||
address_priority = Ipv6Rank.PREFIX
|
||||
else:
|
||||
address_priority = Ipv6Rank.HOST
|
||||
return address_priority
|
||||
|
||||
def _record_ipv6_for_mac(mac_to_ipv6, mac, address, source_priority):
|
||||
if not mac or not address:
|
||||
return
|
||||
address_rank = _ipv6_rank(address)
|
||||
if address_rank in (Ipv6Rank.INVALID, Ipv6Rank.LINK_LOCAL):
|
||||
return
|
||||
canonical_address = _canonical_ip_network_text(address)
|
||||
if canonical_address is None:
|
||||
return
|
||||
current = mac_to_ipv6.get(mac)
|
||||
candidate = IpRecord(source_priority, address_rank, canonical_address, address)
|
||||
if current is None or source_priority > current.source_priority:
|
||||
mac_to_ipv6[mac] = candidate
|
||||
elif source_priority == current.source_priority:
|
||||
if address_rank > current.address_rank:
|
||||
mac_to_ipv6[mac] = candidate
|
||||
elif address_rank == current.address_rank and canonical_address != current.canonical_address:
|
||||
mac_to_ipv6[mac] = current._replace(ambiguous=True)
|
||||
|
||||
def _record_client_address_binding(client_address_to_ipv6, client_address, address):
|
||||
if not client_address or not address or _ipv6_rank(address) in (Ipv6Rank.INVALID, Ipv6Rank.LINK_LOCAL):
|
||||
return
|
||||
canonical_address = _canonical_ip_network_text(address)
|
||||
if canonical_address is None:
|
||||
return
|
||||
current = client_address_to_ipv6.get(client_address)
|
||||
if current is None:
|
||||
client_address_to_ipv6[client_address] = ClientAddressRecord(canonical_address, address)
|
||||
elif canonical_address != current.canonical_address:
|
||||
client_address_to_ipv6[client_address] = current._replace(ambiguous=True)
|
||||
|
||||
def _build_ipv4_to_ipv6_map(arp_entries, dhcp4_entries, dhcp6_bindings, neighbor6_entries):
|
||||
ipv4_to_ipv6 = {}
|
||||
mac_to_ipv4 = {}
|
||||
mac_to_ipv6 = {}
|
||||
client_address_to_ipv6 = {}
|
||||
|
||||
for entry in arp_entries:
|
||||
_record_address_by_mac(entry, mac_to_ipv4, mac_to_ipv6)
|
||||
for entry in dhcp4_entries:
|
||||
_record_dhcp4_address_by_mac(entry, mac_to_ipv4)
|
||||
for entry in dhcp6_bindings:
|
||||
mac = _mac_from_dhcpv6_duid(_entry_value(entry, 'duid') or '')
|
||||
address = _entry_value(entry, 'address')
|
||||
if mac is not None and address:
|
||||
_record_ipv6_for_mac(mac_to_ipv6, mac, address, source_priority=SourcePriority.DHCP)
|
||||
elif _entry_value(entry, 'client-address') and address:
|
||||
client_address = _canonical_ip_text(_entry_value(entry, 'client-address'))
|
||||
if client_address is not None:
|
||||
_record_client_address_binding(client_address_to_ipv6, client_address, address)
|
||||
for entry in neighbor6_entries:
|
||||
mac = _entry_value(entry, 'mac-address')
|
||||
address = _entry_value(entry, 'address')
|
||||
if mac is None or _parse_ip_address(address) is None:
|
||||
continue
|
||||
normalized_mac = normalize_mac(mac)
|
||||
neighbor_address = _canonical_ip_text(address)
|
||||
mapped_ipv6_record = client_address_to_ipv6.get(neighbor_address)
|
||||
if mapped_ipv6_record is not None and mapped_ipv6_record.ambiguous:
|
||||
print('Skipped ambiguous DHCPv6 client-address entries for ' + neighbor_address, file=sys.stderr)
|
||||
_record_ipv6_for_mac(mac_to_ipv6, normalized_mac, address, source_priority=SourcePriority.NEIGHBOR)
|
||||
elif mapped_ipv6_record is not None:
|
||||
_record_ipv6_for_mac(mac_to_ipv6, normalized_mac, mapped_ipv6_record.address, source_priority=SourcePriority.DHCP)
|
||||
else:
|
||||
_record_ipv6_for_mac(mac_to_ipv6, normalized_mac, address, source_priority=SourcePriority.NEIGHBOR)
|
||||
|
||||
for mac, ipv6_record in mac_to_ipv6.items():
|
||||
if ipv6_record.ambiguous:
|
||||
print('Skipped ambiguous IPv6 entries for MAC ' + mac, file=sys.stderr)
|
||||
continue
|
||||
ipv6 = ipv6_record.address
|
||||
try:
|
||||
ipv4_record = mac_to_ipv4[mac]
|
||||
if ipv4_record.ambiguous:
|
||||
print('Skipped ambiguous IPv4 entries for MAC ' + mac, file=sys.stderr)
|
||||
continue
|
||||
ipv4 = ipv4_record.address
|
||||
ipv4_to_ipv6[ipv4] = ipv6
|
||||
except KeyError:
|
||||
print('Failed to find associated IPv4 for ' + ipv6, file=sys.stderr)
|
||||
|
||||
return ipv4_to_ipv6
|
||||
|
||||
def pullMikrotikIPv6(configPath=None):
|
||||
ipv4ToIPv6 = {}
|
||||
routerList = _load_router_list(configPath)
|
||||
for router in routerList:
|
||||
RouterName = router['name']
|
||||
IP = router['host']
|
||||
inputUsername = router['username']
|
||||
inputPassword = router['password']
|
||||
@@ -47,62 +243,18 @@ def pullMikrotikIPv6(configPath=None):
|
||||
plaintext_login = bool(router.get('plaintext_login', True))
|
||||
connection = routeros_api.RouterOsApiPool(IP, username=inputUsername, password=inputPassword, port=apiPort, use_ssl=use_ssl, ssl_verify=False, ssl_verify_hostname=False, plaintext_login=plaintext_login)
|
||||
api = connection.get_api()
|
||||
macToIPv4 = {}
|
||||
macToIPv6 = {}
|
||||
clientAddressToIPv6 = {}
|
||||
# list_dhcp4 = api.get_resource('/ip/dhcp-server/lease')
|
||||
# entries = list_dhcp4.get()
|
||||
# for entry in entries:
|
||||
# try:
|
||||
# macToIPv4[entry['mac-address']] = entry['address']
|
||||
# except:
|
||||
# pass
|
||||
list_arp4 = api.get_resource('/ip/arp')
|
||||
entries = list_arp4.get()
|
||||
for entry in entries:
|
||||
try:
|
||||
macToIPv4[entry['mac-address']] = entry['address']
|
||||
except:
|
||||
pass
|
||||
arp_entries = list_arp4.get()
|
||||
list_dhcp4 = api.get_resource('/ip/dhcp-server/lease')
|
||||
entries = list_dhcp4.get()
|
||||
for entry in entries:
|
||||
try:
|
||||
macToIPv4[entry['mac-address']] = entry['address']
|
||||
except:
|
||||
pass
|
||||
dhcp4_entries = list_dhcp4.get()
|
||||
list_binding6 = api.get_resource('/ipv6/dhcp-server/binding')
|
||||
entries = list_binding6.get()
|
||||
for entry in entries:
|
||||
if len(entry['duid']) == 14:
|
||||
mac = entry['duid'][2:14].upper()
|
||||
macNew = mac[0:2] + ':' + mac[2:4] + ':' + mac[4:6] + ':' + mac[6:8] + ':' + mac[8:10] + ':' + mac[10:12]
|
||||
macToIPv6[macNew] = entry['address']
|
||||
else:
|
||||
try:
|
||||
clientAddressToIPv6[entry['client-address']] = entry['address']
|
||||
except:
|
||||
pass
|
||||
dhcp6_bindings = list_binding6.get()
|
||||
list_neighbor6 = api.get_resource('/ipv6/neighbor')
|
||||
entries = list_neighbor6.get()
|
||||
for entry in entries:
|
||||
try:
|
||||
realIPv6 = clientAddressToIPv6[entry['address']]
|
||||
macToIPv6[entry['mac-address']] = realIPv6
|
||||
except:
|
||||
pass
|
||||
for mac, ipv6 in macToIPv6.items():
|
||||
try:
|
||||
ipv4 = macToIPv4[mac]
|
||||
ipv4ToIPv6[ipv4] = ipv6
|
||||
except:
|
||||
print('Failed to find associated IPv4 for ' + ipv6, file=sys.stderr)
|
||||
neighbor6_entries = list_neighbor6.get()
|
||||
ipv4ToIPv6.update(_build_ipv4_to_ipv6_map(arp_entries, dhcp4_entries, dhcp6_bindings, neighbor6_entries))
|
||||
|
||||
return json.dumps(ipv4ToIPv6)
|
||||
|
||||
def pullMikrotikIPv6_Mock(CsvPath):
|
||||
return "{\n\"172.29.200.2\": \"2602:fdca:800:1500::/56\"\n}"
|
||||
|
||||
if __name__ == '__main__':
|
||||
# If the first argument is a string, it's treated as a legacy CSV path override.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == '--show-config':
|
||||
|
||||
@@ -8,3 +8,12 @@ username = "admin"
|
||||
password = "password"
|
||||
use_ssl = false
|
||||
plaintext_login = true
|
||||
|
||||
[[router]]
|
||||
name = "secondary"
|
||||
host = "100.64.0.2"
|
||||
port = 8728
|
||||
username = "admin"
|
||||
password = "password"
|
||||
use_ssl = false
|
||||
plaintext_login = true
|
||||
+198
-26
@@ -1513,7 +1513,14 @@ pub const FQ_CODEL_QDISC_ESTIMATED_MEMORY_BYTES: u64 = 64 * 1024;
|
||||
/// substantially exceeding the earlier heuristic during busy periods.
|
||||
pub const CAKE_QDISC_ESTIMATED_MEMORY_BYTES: u64 = 512 * 1024;
|
||||
/// Minimum memory headroom Bakery tries to leave unused after a projected or in-flight apply.
|
||||
pub const BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES: u64 = 768 * 1024 * 1024;
|
||||
pub const BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
/// Divisor used to scale Bakery's memory guard floor with installed RAM.
|
||||
pub const BAKERY_MEMORY_GUARD_TOTAL_RAM_DIVISOR: u64 = 8;
|
||||
|
||||
/// Returns Bakery's effective memory guard floor for a host with `total_bytes` RAM.
|
||||
pub fn bakery_memory_guard_min_available_bytes(total_bytes: u64) -> u64 {
|
||||
BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES.max(total_bytes / BAKERY_MEMORY_GUARD_TOTAL_RAM_DIVISOR)
|
||||
}
|
||||
/// Maximum number of mapped circuits allowed without Insight.
|
||||
const DEFAULT_MAPPED_CIRCUITS_LIMIT: usize = 1000;
|
||||
/// Minimum interval between repeated mapped-circuit-limit urgent issues.
|
||||
@@ -2055,22 +2062,69 @@ fn telemetry_state() -> &'static RwLock<BakeryTelemetryState> {
|
||||
BAKERY_TELEMETRY.get_or_init(|| RwLock::new(BakeryTelemetryState::default()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TcCommandType {
|
||||
Class,
|
||||
Qdisc,
|
||||
}
|
||||
|
||||
fn tc_command_type(argv: &[String]) -> Option<TcCommandType> {
|
||||
match argv.first()?.as_str() {
|
||||
"class" => Some(TcCommandType::Class),
|
||||
"qdisc" => Some(TcCommandType::Qdisc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_tc_command_types(commands: &[Vec<String>]) -> (usize, usize, usize) {
|
||||
let total = commands.len();
|
||||
let mut class_count = 0usize;
|
||||
let mut qdisc_count = 0usize;
|
||||
for argv in commands {
|
||||
if let Some(kind) = argv.first() {
|
||||
match kind.as_str() {
|
||||
"class" => class_count += 1,
|
||||
"qdisc" => qdisc_count += 1,
|
||||
_ => {}
|
||||
}
|
||||
match tc_command_type(argv) {
|
||||
Some(TcCommandType::Class) => class_count += 1,
|
||||
Some(TcCommandType::Qdisc) => qdisc_count += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
(total, class_count, qdisc_count)
|
||||
}
|
||||
|
||||
fn count_tc_command_types_and_estimate_qdisc_memory(
|
||||
commands: &[Vec<String>],
|
||||
) -> (usize, usize, usize, u64) {
|
||||
let total = commands.len();
|
||||
let mut class_count = 0usize;
|
||||
let mut qdisc_count = 0usize;
|
||||
let mut estimated_qdisc_memory_bytes = 0u64;
|
||||
let mut seen_qdiscs = HashSet::new();
|
||||
|
||||
for argv in commands {
|
||||
match tc_command_type(argv) {
|
||||
Some(TcCommandType::Class) => class_count += 1,
|
||||
Some(TcCommandType::Qdisc) => qdisc_count += 1,
|
||||
None => {}
|
||||
}
|
||||
|
||||
let Some(qdisc_identity) = planned_qdisc_identity(argv) else {
|
||||
continue;
|
||||
};
|
||||
if !seen_qdiscs.insert(qdisc_identity) {
|
||||
continue;
|
||||
}
|
||||
let kind = planned_qdisc_kind(argv).unwrap_or(PlannedQdiscKind::Infra);
|
||||
estimated_qdisc_memory_bytes =
|
||||
estimated_qdisc_memory_bytes.saturating_add(qdisc_kind_estimated_memory_bytes(kind));
|
||||
}
|
||||
|
||||
(
|
||||
total,
|
||||
class_count,
|
||||
qdisc_count,
|
||||
estimated_qdisc_memory_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
fn push_bakery_event(event: &str, status: &str, summary: String) {
|
||||
push_bakery_event_with_site(event, status, None, summary);
|
||||
}
|
||||
@@ -2505,6 +2559,8 @@ pub struct QdiscBudgetEstimate {
|
||||
pub estimated_total_memory_bytes: u64,
|
||||
/// Current host memory snapshot used for preflight, if available.
|
||||
pub memory_snapshot: Option<MemorySnapshot>,
|
||||
/// Effective memory floor used for the memory preflight.
|
||||
pub memory_guard_min_available_bytes: u64,
|
||||
/// Whether the memory preflight passed.
|
||||
pub memory_ok: bool,
|
||||
}
|
||||
@@ -2649,10 +2705,14 @@ pub fn estimate_full_reload_auto_qdisc_budget(
|
||||
acc.saturating_add(detail.estimated_memory_bytes)
|
||||
});
|
||||
let memory_snapshot = read_memory_snapshot().ok();
|
||||
let memory_guard_min_available_bytes = memory_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| bakery_memory_guard_min_available_bytes(snapshot.total_bytes))
|
||||
.unwrap_or(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES);
|
||||
let memory_ok = memory_snapshot.as_ref().is_none_or(|snapshot| {
|
||||
snapshot
|
||||
.available_bytes
|
||||
.saturating_sub(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES)
|
||||
.saturating_sub(memory_guard_min_available_bytes)
|
||||
>= estimated_total_memory_bytes
|
||||
});
|
||||
|
||||
@@ -2663,6 +2723,7 @@ pub fn estimate_full_reload_auto_qdisc_budget(
|
||||
hard_limit: HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE,
|
||||
estimated_total_memory_bytes,
|
||||
memory_snapshot,
|
||||
memory_guard_min_available_bytes,
|
||||
memory_ok,
|
||||
}
|
||||
}
|
||||
@@ -3889,16 +3950,26 @@ fn maybe_emit_mapped_circuit_limit_urgent(stats: &MappedLimitStats) {
|
||||
});
|
||||
}
|
||||
|
||||
fn maybe_emit_memory_guard_urgent(summary: &str) {
|
||||
fn maybe_emit_memory_guard_urgent(
|
||||
summary: &str,
|
||||
memory_guard_required_available_bytes: u64,
|
||||
estimated_qdisc_memory_bytes: u64,
|
||||
) {
|
||||
if !summary.contains("Bakery memory guard stopped") {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = summary.to_string();
|
||||
let context = read_memory_snapshot().ok().map(|snapshot| {
|
||||
let memory_guard_floor_bytes =
|
||||
bakery_memory_guard_min_available_bytes(snapshot.total_bytes);
|
||||
format!(
|
||||
"{{\"available_bytes\":{},\"total_bytes\":{},\"memory_guard_floor_bytes\":{}}}",
|
||||
snapshot.available_bytes, snapshot.total_bytes, BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES
|
||||
"{{\"available_bytes\":{},\"total_bytes\":{},\"memory_guard_floor_bytes\":{},\"estimated_qdisc_memory_bytes\":{},\"memory_guard_required_available_bytes\":{}}}",
|
||||
snapshot.available_bytes,
|
||||
snapshot.total_bytes,
|
||||
memory_guard_floor_bytes,
|
||||
estimated_qdisc_memory_bytes,
|
||||
memory_guard_required_available_bytes
|
||||
)
|
||||
});
|
||||
|
||||
@@ -9059,6 +9130,34 @@ fn site_commands_observed_live(
|
||||
})
|
||||
}
|
||||
|
||||
fn read_optional_restore_class_snapshots(
|
||||
config: &Arc<Config>,
|
||||
site_label: &str,
|
||||
) -> Option<(
|
||||
HashMap<TcHandle, LiveTcClassEntry>,
|
||||
HashMap<TcHandle, LiveTcClassEntry>,
|
||||
)> {
|
||||
let down_snapshot = match read_live_class_snapshot(&config.isp_interface()) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(summary) => {
|
||||
debug!(
|
||||
"Bakery: skipping live restore shortcut for {site_label}; unable to snapshot downlink classes: {summary}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let up_snapshot = match read_live_class_snapshot(&config.internet_interface()) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(summary) => {
|
||||
debug!(
|
||||
"Bakery: skipping live restore shortcut for {site_label}; unable to snapshot uplink classes: {summary}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some((down_snapshot, up_snapshot))
|
||||
}
|
||||
|
||||
fn live_parent_matches(observed: Option<TcHandle>, expected: TcHandle) -> bool {
|
||||
if observed == Some(expected) {
|
||||
return true;
|
||||
@@ -10593,28 +10692,27 @@ fn handle_treeguard_set_node_virtual_live(
|
||||
}
|
||||
|
||||
let reversible_standby = saved_state.active_branch_hides_original_site();
|
||||
let live_restore_snapshots = if reversible_standby {
|
||||
Some((
|
||||
read_live_class_snapshot(&config.isp_interface())?,
|
||||
read_live_class_snapshot(&config.internet_interface())?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut live_restore_snapshots = None;
|
||||
|
||||
if !saved_state.pending_prune
|
||||
&& let Some(cmds) = saved_state
|
||||
.site
|
||||
.to_commands(&config, ExecutionMode::Builder)
|
||||
{
|
||||
let root_already_live =
|
||||
let root_already_live = if reversible_standby {
|
||||
live_restore_snapshots
|
||||
.get_or_insert_with(|| {
|
||||
read_optional_restore_class_snapshots(&config, &site_label)
|
||||
})
|
||||
.as_ref()
|
||||
.is_some_and(|(down_snapshot, up_snapshot)| {
|
||||
let mut only_root = HashMap::new();
|
||||
only_root.insert(site_hash, saved_state.site.clone());
|
||||
site_commands_observed_live(&only_root, down_snapshot, up_snapshot)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !root_already_live {
|
||||
let result =
|
||||
execute_and_record_live_change(&cmds, "TreeGuard runtime hidden site restore");
|
||||
@@ -10643,8 +10741,9 @@ fn handle_treeguard_set_node_virtual_live(
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let originals_already_live =
|
||||
let originals_already_live = if reversible_standby && !saved_state.saved_sites.is_empty() {
|
||||
live_restore_snapshots
|
||||
.get_or_insert_with(|| read_optional_restore_class_snapshots(&config, &site_label))
|
||||
.as_ref()
|
||||
.is_some_and(|(down_snapshot, up_snapshot)| {
|
||||
site_commands_observed_live(
|
||||
@@ -10652,7 +10751,10 @@ fn handle_treeguard_set_node_virtual_live(
|
||||
down_snapshot,
|
||||
up_snapshot,
|
||||
)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if reversible_standby && originals_already_live {
|
||||
for (hash, command) in &saved_state.saved_sites {
|
||||
sites.insert(*hash, Arc::clone(command));
|
||||
@@ -11037,7 +11139,12 @@ fn process_batch(
|
||||
let path = config.debug_state_file_path("linux_tc_rust.txt");
|
||||
write_command_file(&path, &commands);
|
||||
let build_duration_ms = build_started.elapsed().as_millis() as u64;
|
||||
let (total_tc_commands, class_commands, qdisc_commands) = count_tc_command_types(&commands);
|
||||
let (total_tc_commands, class_commands, qdisc_commands, estimated_qdisc_memory_bytes) =
|
||||
count_tc_command_types_and_estimate_qdisc_memory(&commands);
|
||||
let memory_guard_required_available_bytes = read_memory_snapshot()
|
||||
.map(|snapshot| bakery_memory_guard_min_available_bytes(snapshot.total_bytes))
|
||||
.unwrap_or(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES)
|
||||
.saturating_add(estimated_qdisc_memory_bytes);
|
||||
let total_chunks = if total_tc_commands == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -11054,7 +11161,7 @@ fn process_batch(
|
||||
&commands,
|
||||
"processing batch",
|
||||
FULL_RELOAD_TC_CHUNK_SIZE,
|
||||
Some(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES),
|
||||
Some(memory_guard_required_available_bytes),
|
||||
|completed_tc_commands, total_tc_commands, completed_chunks, total_chunks| {
|
||||
update_bakery_apply_progress(
|
||||
Some("Applying tc command chunks"),
|
||||
@@ -11066,7 +11173,11 @@ fn process_batch(
|
||||
},
|
||||
);
|
||||
let summary = summarize_apply_result("processing batch", &result);
|
||||
maybe_emit_memory_guard_urgent(&summary);
|
||||
maybe_emit_memory_guard_urgent(
|
||||
&summary,
|
||||
memory_guard_required_available_bytes,
|
||||
estimated_qdisc_memory_bytes,
|
||||
);
|
||||
BakeryFullReloadBatchResult {
|
||||
result,
|
||||
summary,
|
||||
@@ -11183,6 +11294,7 @@ mod tests {
|
||||
use_xdp_bridge: false,
|
||||
to_internet: "lo".to_string(),
|
||||
to_network: "lo".to_string(),
|
||||
mtu: None,
|
||||
}),
|
||||
..lqos_config::Config::default()
|
||||
};
|
||||
@@ -13031,6 +13143,7 @@ mod tests {
|
||||
use_xdp_bridge: false,
|
||||
to_internet: "__bakery-missing-wan__".to_string(),
|
||||
to_network: "__bakery-missing-lan__".to_string(),
|
||||
mtu: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -13363,6 +13476,65 @@ mod tests {
|
||||
assert!(estimate.ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bakery_memory_guard_floor_scales_with_host_ram() {
|
||||
assert_eq!(
|
||||
bakery_memory_guard_min_available_bytes(8 * 1024 * 1024 * 1024),
|
||||
BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
bakery_memory_guard_min_available_bytes(64 * 1024 * 1024 * 1024),
|
||||
8 * 1024 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_qdisc_memory_estimate_deduplicates_qdisc_identity() {
|
||||
let commands = vec![
|
||||
vec![
|
||||
"qdisc".to_string(),
|
||||
"add".to_string(),
|
||||
"dev".to_string(),
|
||||
"eth0".to_string(),
|
||||
"parent".to_string(),
|
||||
"1:10".to_string(),
|
||||
"cake".to_string(),
|
||||
],
|
||||
vec![
|
||||
"qdisc".to_string(),
|
||||
"replace".to_string(),
|
||||
"dev".to_string(),
|
||||
"eth0".to_string(),
|
||||
"parent".to_string(),
|
||||
"1:10".to_string(),
|
||||
"cake".to_string(),
|
||||
],
|
||||
vec![
|
||||
"qdisc".to_string(),
|
||||
"add".to_string(),
|
||||
"dev".to_string(),
|
||||
"eth1".to_string(),
|
||||
"parent".to_string(),
|
||||
"1:10".to_string(),
|
||||
"cake".to_string(),
|
||||
],
|
||||
vec![
|
||||
"qdisc".to_string(),
|
||||
"add".to_string(),
|
||||
"dev".to_string(),
|
||||
"eth0".to_string(),
|
||||
"parent".to_string(),
|
||||
"1:11".to_string(),
|
||||
"fq_codel".to_string(),
|
||||
],
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
count_tc_command_types_and_estimate_qdisc_memory(&commands).3,
|
||||
(CAKE_QDISC_ESTIMATED_MEMORY_BYTES * 2) + FQ_CODEL_QDISC_ESTIMATED_MEMORY_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qdisc_budget_estimate_counts_only_root_mq_in_observe_mode() {
|
||||
let _guard = bakery_test_lock().lock().expect("lock");
|
||||
|
||||
@@ -269,7 +269,7 @@ fn memory_guard_failure_summary(
|
||||
phase: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"Bakery memory guard stopped {purpose} {phase} chunk {}/{}: available memory {} bytes is below safety floor {} bytes (total RAM {} bytes).",
|
||||
"Bakery memory guard stopped {purpose} {phase} chunk {}/{}: available memory {} bytes is below required available memory {} bytes (total RAM {} bytes).",
|
||||
chunk_number,
|
||||
total_chunks,
|
||||
snapshot.available_bytes,
|
||||
|
||||
@@ -171,6 +171,19 @@ mod tests {
|
||||
assert_eq!(decoded.requests, session.requests);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbor_round_trip_clear_urgent_issue_by_identity_request() {
|
||||
let session = BusSession {
|
||||
requests: vec![BusRequest::ClearUrgentIssueByIdentity {
|
||||
code: "XDP_IP_MAPPING_APPLY_FAILED".to_string(),
|
||||
dedupe_key: "XDP_IP_MAPPING_APPLY_FAILED".to_string(),
|
||||
}],
|
||||
};
|
||||
let bytes = encode_session_cbor(&session).expect("encode_session_cbor");
|
||||
let decoded = decode_session_cbor(&bytes).expect("decode_session_cbor");
|
||||
assert_eq!(decoded.requests, session.requests);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbor_round_trip_reply() {
|
||||
let reply = BusReply {
|
||||
|
||||
@@ -539,6 +539,14 @@ pub enum BusRequest {
|
||||
/// Clear a specific urgent issue by ID
|
||||
ClearUrgentIssue(u64),
|
||||
|
||||
/// Clear urgent issues matching a code and dedupe key
|
||||
ClearUrgentIssueByIdentity {
|
||||
/// Machine-readable issue code
|
||||
code: String,
|
||||
/// Dedupe key used when the issue was submitted
|
||||
dedupe_key: String,
|
||||
},
|
||||
|
||||
/// Clear all urgent issues
|
||||
ClearAllUrgentIssues,
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ fn migrate_bridge(
|
||||
interface: python_config.interface_a.clone(),
|
||||
internet_vlan: python_config.stick_vlan_a as u32,
|
||||
network_vlan: python_config.stick_vlan_b as u32,
|
||||
mtu: None,
|
||||
});
|
||||
} else {
|
||||
new_config.single_interface = None;
|
||||
@@ -193,6 +194,7 @@ fn migrate_bridge(
|
||||
.use_xdp_bridge,
|
||||
to_internet: python_config.interface_b.clone(),
|
||||
to_network: python_config.interface_a.clone(),
|
||||
mtu: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct BridgeConfig {
|
||||
|
||||
/// The name of the second interface, facing the LAN
|
||||
pub to_network: String,
|
||||
|
||||
/// Optional MTU for LibreQoS-managed Linux bridge interfaces.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mtu: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for BridgeConfig {
|
||||
@@ -24,6 +28,7 @@ impl Default for BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
to_internet: "eth0".to_string(),
|
||||
to_network: "eth1".to_string(),
|
||||
mtu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +44,10 @@ pub struct SingleInterfaceConfig {
|
||||
|
||||
/// The VLAN ID facing the LAN
|
||||
pub network_vlan: u32,
|
||||
|
||||
/// Optional MTU for the LibreQoS-managed trunk interface.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mtu: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SingleInterfaceConfig {
|
||||
@@ -47,6 +56,7 @@ impl Default for SingleInterfaceConfig {
|
||||
interface: "eth0".to_string(),
|
||||
internet_vlan: 2,
|
||||
network_vlan: 3,
|
||||
mtu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,14 @@ disable_offload = [ "gso", "tso", "lro", "sg", "gro" ]
|
||||
use_xdp_bridge = true
|
||||
to_internet = "eth0"
|
||||
to_network = "eth1"
|
||||
# mtu = 1500
|
||||
|
||||
# OR:
|
||||
#[single_interface]
|
||||
#interface = "eth0"
|
||||
#internet_vlan = 2
|
||||
#network_vlan = 3
|
||||
# mtu = 1500
|
||||
|
||||
[queues]
|
||||
default_sqm = "cake diffserv4"
|
||||
|
||||
@@ -15,6 +15,9 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const MIN_INTERFACE_MTU: u32 = 576;
|
||||
const MAX_INTERFACE_MTU: u32 = 9216;
|
||||
|
||||
fn default_rtt_green_ms() -> u32 {
|
||||
0
|
||||
}
|
||||
@@ -27,6 +30,17 @@ fn default_rtt_red_ms() -> u32 {
|
||||
200
|
||||
}
|
||||
|
||||
fn validate_interface_mtu(field_name: &str, mtu: Option<u32>) -> Result<(), String> {
|
||||
if let Some(mtu) = mtu
|
||||
&& !(MIN_INTERFACE_MTU..=MAX_INTERFACE_MTU).contains(&mtu)
|
||||
{
|
||||
return Err(format!(
|
||||
"{field_name} must be between {MIN_INTERFACE_MTU} and {MAX_INTERFACE_MTU}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RTT color scale thresholds (milliseconds) used by the web UI.
|
||||
///
|
||||
/// These represent the color "anchor points" for RTT:
|
||||
@@ -225,6 +239,12 @@ impl Config {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(bridge) = &self.bridge {
|
||||
validate_interface_mtu("bridge.mtu", bridge.mtu)?;
|
||||
}
|
||||
if let Some(single_interface) = &self.single_interface {
|
||||
validate_interface_mtu("single_interface.mtu", single_interface.mtu)?;
|
||||
}
|
||||
if self.version.trim() != "1.5" {
|
||||
return Err(format!(
|
||||
"Configuration file is at version [{}], but this version of lqos only supports version 1.5.0",
|
||||
@@ -569,6 +589,7 @@ impl Config {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::super::bridge::{BridgeConfig, SingleInterfaceConfig};
|
||||
use super::{Config, RttThresholds};
|
||||
|
||||
fn remove_sections(raw: &str, sections: &[&str]) -> String {
|
||||
@@ -772,6 +793,86 @@ mod test {
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_mtu_defaults_to_none_when_omitted() {
|
||||
let config =
|
||||
Config::load_from_string(include_str!("example.toml")).expect("example should load");
|
||||
assert_eq!(config.bridge.as_ref().and_then(|bridge| bridge.mtu), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_mtu_values_deserialize() {
|
||||
let bridge_raw = include_str!("example.toml")
|
||||
.replace("to_network = \"eth1\"", "to_network = \"eth1\"\nmtu = 9000");
|
||||
let bridge_config = Config::load_from_string(&bridge_raw).expect("bridge MTU should load");
|
||||
assert_eq!(
|
||||
bridge_config.bridge.as_ref().and_then(|bridge| bridge.mtu),
|
||||
Some(9000)
|
||||
);
|
||||
|
||||
let single_raw = remove_sections(include_str!("example.toml"), &["bridge"])
|
||||
+ r#"
|
||||
[single_interface]
|
||||
interface = "eth0"
|
||||
internet_vlan = 2
|
||||
network_vlan = 3
|
||||
mtu = 1500
|
||||
"#;
|
||||
let single_config =
|
||||
Config::load_from_string(&single_raw).expect("single-interface MTU should load");
|
||||
assert_eq!(
|
||||
single_config
|
||||
.single_interface
|
||||
.as_ref()
|
||||
.and_then(|single| single.mtu),
|
||||
Some(1500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_mtu_validation_rejects_out_of_range_values() {
|
||||
let mut bridge_config = Config {
|
||||
bridge: Some(BridgeConfig {
|
||||
mtu: Some(575),
|
||||
..BridgeConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
let bridge_error = bridge_config
|
||||
.validate()
|
||||
.expect_err("bridge.mtu below range should fail");
|
||||
assert!(bridge_error.contains("bridge.mtu"));
|
||||
|
||||
bridge_config.bridge.as_mut().expect("bridge").mtu = Some(9217);
|
||||
let bridge_error = bridge_config
|
||||
.validate()
|
||||
.expect_err("bridge.mtu above range should fail");
|
||||
assert!(bridge_error.contains("bridge.mtu"));
|
||||
|
||||
let mut single_config = Config {
|
||||
bridge: None,
|
||||
single_interface: Some(SingleInterfaceConfig {
|
||||
mtu: Some(575),
|
||||
..SingleInterfaceConfig::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
let single_error = single_config
|
||||
.validate()
|
||||
.expect_err("single_interface.mtu below range should fail");
|
||||
assert!(single_error.contains("single_interface.mtu"));
|
||||
|
||||
single_config
|
||||
.single_interface
|
||||
.as_mut()
|
||||
.expect("single interface")
|
||||
.mtu = Some(9217);
|
||||
let single_error = single_config
|
||||
.validate()
|
||||
.expect_err("single_interface.mtu above range should fail");
|
||||
assert!(single_error.contains("single_interface.mtu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treeguard_defaults_match_default_on_rollout() {
|
||||
let cfg = Config::default();
|
||||
|
||||
@@ -93,13 +93,15 @@ pub use topology_runtime_state::{
|
||||
TOPOLOGY_IMPORT_FILENAME, TOPOLOGY_RUNTIME_STATUS_FILENAME, TOPOLOGY_SHAPING_INPUTS_FILENAME,
|
||||
TopologyAttachmentEndpointStatus, TopologyAttachmentHealthEntry,
|
||||
TopologyAttachmentHealthStateFile, TopologyEffectiveAttachmentState,
|
||||
TopologyEffectiveNodeState, TopologyEffectiveStateFile, TopologyRuntimeStateError,
|
||||
TopologyRuntimeStatusFile, TopologyShapingCircuitInput, TopologyShapingDeviceInput,
|
||||
TopologyShapingInputsFile, TopologyShapingResolutionSource, active_runtime_shaping_inputs_path,
|
||||
compute_effective_network_generation, compute_topology_source_generation,
|
||||
load_active_runtime_shaping_inputs, topology_attachment_health_state_path,
|
||||
topology_compiled_shaping_path, topology_effective_network_path, topology_effective_state_path,
|
||||
topology_import_path, topology_runtime_status_path, topology_shaping_inputs_path,
|
||||
TopologyEffectiveNodeState, TopologyEffectiveStateFile, TopologyRuntimeShapingPayloadIdentity,
|
||||
TopologyRuntimeStateError, TopologyRuntimeStatusFile, TopologyShapingCircuitInput,
|
||||
TopologyShapingDeviceInput, TopologyShapingInputsFile, TopologyShapingResolutionSource,
|
||||
active_runtime_shaping_inputs_path, compute_effective_network_generation,
|
||||
active_runtime_shaping_inputs_path_from_status, compute_topology_source_generation,
|
||||
load_active_runtime_shaping_inputs, load_active_runtime_shaping_inputs_from_status,
|
||||
topology_attachment_health_state_path, topology_compiled_shaping_path,
|
||||
topology_effective_network_path, topology_effective_state_path, topology_import_path,
|
||||
topology_runtime_status_path, topology_shaping_inputs_path,
|
||||
};
|
||||
|
||||
/// Returns whether an integration-backed topology ingress is enabled in the
|
||||
|
||||
@@ -356,6 +356,20 @@ pub struct TopologyRuntimeStatusFile {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Shaping-relevant runtime status identity used by consumers that only need
|
||||
/// to know whether the active shaping payload changed.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TopologyRuntimeShapingPayloadIdentity {
|
||||
/// Whether runtime outputs are ready.
|
||||
pub ready: bool,
|
||||
/// Stable generation hash of the source topology inputs.
|
||||
pub source_generation: String,
|
||||
/// Stable generation hash of the shaping payload.
|
||||
pub shaping_generation: String,
|
||||
/// Path to the active shaping inputs payload.
|
||||
pub shaping_inputs_path: String,
|
||||
}
|
||||
|
||||
fn default_runtime_schema_version() -> u32 {
|
||||
1
|
||||
}
|
||||
@@ -428,20 +442,33 @@ pub fn active_runtime_shaping_inputs_path(
|
||||
) -> Result<Option<PathBuf>, TopologyRuntimeStateError> {
|
||||
let current_generation = compute_topology_source_generation(config)?;
|
||||
let status = TopologyRuntimeStatusFile::load(config)?;
|
||||
Ok(active_runtime_shaping_inputs_path_from_status(
|
||||
&status,
|
||||
¤t_generation,
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the active runtime shaping-inputs path from an already-loaded status file.
|
||||
///
|
||||
/// This function is pure: it has no side effects.
|
||||
pub fn active_runtime_shaping_inputs_path_from_status(
|
||||
status: &TopologyRuntimeStatusFile,
|
||||
current_generation: &str,
|
||||
) -> Option<PathBuf> {
|
||||
if !status.ready {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
if status.shaping_generation.trim().is_empty() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
if status.source_generation.trim() != current_generation {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let path = status.shaping_inputs_path.trim();
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
Ok(Some(PathBuf::from(path)))
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Loads the currently active runtime shaping-inputs file when the topology
|
||||
@@ -456,6 +483,20 @@ pub fn load_active_runtime_shaping_inputs(
|
||||
Ok(Some(serde_json::from_str(&raw)?))
|
||||
}
|
||||
|
||||
/// Loads the active runtime shaping-inputs file from an already-loaded status file.
|
||||
pub fn load_active_runtime_shaping_inputs_from_status(
|
||||
config: &Config,
|
||||
status: &TopologyRuntimeStatusFile,
|
||||
) -> Result<Option<TopologyShapingInputsFile>, TopologyRuntimeStateError> {
|
||||
let current_generation = compute_topology_source_generation(config)?;
|
||||
let Some(path) = active_runtime_shaping_inputs_path_from_status(status, ¤t_generation)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
Ok(Some(serde_json::from_str(&raw)?))
|
||||
}
|
||||
|
||||
/// Returns the path of the topology runtime readiness status file.
|
||||
pub fn topology_runtime_status_path(config: &Config) -> PathBuf {
|
||||
config.topology_state_read_path(TOPOLOGY_RUNTIME_STATUS_FILENAME)
|
||||
@@ -742,6 +783,29 @@ impl TopologyRuntimeStatusFile {
|
||||
self,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true when runtime status is equal except for the generated timestamp.
|
||||
pub fn semantic_equals_for_publish(&self, other: &Self) -> bool {
|
||||
self.schema_version == other.schema_version
|
||||
&& self.source_generation == other.source_generation
|
||||
&& self.shaping_generation == other.shaping_generation
|
||||
&& self.effective_generation == other.effective_generation
|
||||
&& self.ready == other.ready
|
||||
&& self.effective_state_path == other.effective_state_path
|
||||
&& self.effective_network_path == other.effective_network_path
|
||||
&& self.shaping_inputs_path == other.shaping_inputs_path
|
||||
&& self.error == other.error
|
||||
}
|
||||
|
||||
/// Returns the shaping payload identity used by cache consumers.
|
||||
pub fn shaping_payload_identity(&self) -> TopologyRuntimeShapingPayloadIdentity {
|
||||
TopologyRuntimeShapingPayloadIdentity {
|
||||
ready: self.ready,
|
||||
source_generation: self.source_generation.clone(),
|
||||
shaping_generation: self.shaping_generation.clone(),
|
||||
shaping_inputs_path: self.shaping_inputs_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1300,6 +1364,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_runtime_status_publish_compare_ignores_generated_unix() {
|
||||
let first = TopologyRuntimeStatusFile {
|
||||
schema_version: 1,
|
||||
source_generation: "source-1".to_string(),
|
||||
shaping_generation: "shape-1".to_string(),
|
||||
effective_generation: "effective-1".to_string(),
|
||||
ready: true,
|
||||
generated_unix: Some(1),
|
||||
effective_state_path: "/tmp/effective.json".to_string(),
|
||||
effective_network_path: "/tmp/network.effective.json".to_string(),
|
||||
shaping_inputs_path: "/tmp/shaping_inputs.json".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let second = TopologyRuntimeStatusFile {
|
||||
generated_unix: Some(2),
|
||||
..first.clone()
|
||||
};
|
||||
let changed = TopologyRuntimeStatusFile {
|
||||
shaping_generation: "shape-2".to_string(),
|
||||
..first.clone()
|
||||
};
|
||||
|
||||
assert!(first.semantic_equals_for_publish(&second));
|
||||
assert!(!first.semantic_equals_for_publish(&changed));
|
||||
assert_eq!(
|
||||
first.shaping_payload_identity(),
|
||||
second.shaping_payload_identity()
|
||||
);
|
||||
assert_ne!(
|
||||
first.shaping_payload_identity(),
|
||||
changed.shaping_payload_identity()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaping_generation_ignores_timestamp_only_changes() {
|
||||
let first = TopologyShapingInputsFile {
|
||||
|
||||
@@ -68,6 +68,7 @@ enum RequestedMode {
|
||||
LinuxBridge {
|
||||
to_internet: String,
|
||||
to_network: String,
|
||||
mtu: Option<u32>,
|
||||
},
|
||||
XdpBridge {
|
||||
to_internet: String,
|
||||
@@ -75,6 +76,7 @@ enum RequestedMode {
|
||||
},
|
||||
SingleInterface {
|
||||
interface: String,
|
||||
mtu: Option<u32>,
|
||||
},
|
||||
Unknown,
|
||||
}
|
||||
@@ -307,11 +309,13 @@ fn requested_mode(config: &Config) -> RequestedMode {
|
||||
RequestedMode::LinuxBridge {
|
||||
to_internet: bridge.to_internet.trim().to_string(),
|
||||
to_network: bridge.to_network.trim().to_string(),
|
||||
mtu: bridge.mtu,
|
||||
}
|
||||
}
|
||||
} else if let Some(single) = &config.single_interface {
|
||||
RequestedMode::SingleInterface {
|
||||
interface: single.interface.trim().to_string(),
|
||||
mtu: single.mtu,
|
||||
}
|
||||
} else {
|
||||
RequestedMode::Unknown
|
||||
@@ -332,6 +336,7 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
|
||||
RequestedMode::LinuxBridge {
|
||||
to_internet,
|
||||
to_network,
|
||||
..
|
||||
}
|
||||
| RequestedMode::XdpBridge {
|
||||
to_internet,
|
||||
@@ -340,7 +345,7 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
|
||||
.into_iter()
|
||||
.filter(|iface| !iface.is_empty())
|
||||
.collect(),
|
||||
RequestedMode::SingleInterface { interface } => {
|
||||
RequestedMode::SingleInterface { interface, .. } => {
|
||||
if interface.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -351,21 +356,38 @@ fn selected_interfaces(mode: &RequestedMode) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn mtu_yaml_line(mtu: Option<u32>, indent: &str) -> String {
|
||||
mtu.map(|value| format!("{indent}mtu: {value}\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn managed_linux_bridge_yaml(to_internet: &str, to_network: &str, mtu: Option<u32>) -> String {
|
||||
let ethernet_mtu = mtu_yaml_line(mtu, " ");
|
||||
let bridge_mtu = mtu_yaml_line(mtu, " ");
|
||||
format!(
|
||||
"network:\n version: 2\n ethernets:\n {to_internet}:\n dhcp4: false\n dhcp6: false\n{ethernet_mtu} {to_network}:\n dhcp4: false\n dhcp6: false\n{ethernet_mtu} bridges:\n br0:\n{bridge_mtu} interfaces:\n - {to_internet}\n - {to_network}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn managed_single_interface_yaml(interface: &str, mtu: Option<u32>) -> String {
|
||||
let mtu_line = mtu_yaml_line(mtu, " ");
|
||||
format!(
|
||||
"network:\n version: 2\n ethernets:\n {interface}:\n dhcp4: false\n dhcp6: false\n{mtu_line}"
|
||||
)
|
||||
}
|
||||
|
||||
fn managed_preview_yaml(mode: &RequestedMode) -> (Option<String>, Option<String>) {
|
||||
match mode {
|
||||
RequestedMode::LinuxBridge {
|
||||
to_internet,
|
||||
to_network,
|
||||
mtu,
|
||||
} if !to_internet.is_empty() && !to_network.is_empty() => (
|
||||
Some(format!(
|
||||
"network:\n version: 2\n ethernets:\n {to_internet}:\n dhcp4: false\n dhcp6: false\n {to_network}:\n dhcp4: false\n dhcp6: false\n bridges:\n br0:\n interfaces:\n - {to_internet}\n - {to_network}\n"
|
||||
)),
|
||||
Some(managed_linux_bridge_yaml(to_internet, to_network, *mtu)),
|
||||
None,
|
||||
),
|
||||
RequestedMode::SingleInterface { interface } if !interface.is_empty() => (
|
||||
Some(format!(
|
||||
"network:\n version: 2\n ethernets:\n {interface}:\n dhcp4: false\n dhcp6: false\n"
|
||||
)),
|
||||
RequestedMode::SingleInterface { interface, mtu } if !interface.is_empty() => (
|
||||
Some(managed_single_interface_yaml(interface, *mtu)),
|
||||
None,
|
||||
),
|
||||
RequestedMode::XdpBridge { .. } => (
|
||||
@@ -598,6 +620,7 @@ pub(crate) fn adoption_rewrite_for_path(path: &Path, config: &Config) -> Result<
|
||||
RequestedMode::LinuxBridge {
|
||||
to_internet,
|
||||
to_network,
|
||||
..
|
||||
} => {
|
||||
let mut removed_bridge = false;
|
||||
doc.network.ethernets.remove(&to_internet);
|
||||
@@ -618,7 +641,7 @@ pub(crate) fn adoption_rewrite_for_path(path: &Path, config: &Config) -> Result<
|
||||
));
|
||||
}
|
||||
}
|
||||
RequestedMode::SingleInterface { interface } => {
|
||||
RequestedMode::SingleInterface { interface, .. } => {
|
||||
if doc.network.ethernets.remove(&interface).is_none() {
|
||||
return Err(format!(
|
||||
"Unable to find interface {interface} in {} for adoption.",
|
||||
@@ -658,6 +681,7 @@ fn assess_file(path: &Path, doc: &NetplanDocument, mode: &RequestedMode) -> File
|
||||
RequestedMode::LinuxBridge {
|
||||
to_internet,
|
||||
to_network,
|
||||
..
|
||||
} => {
|
||||
for iface in [to_internet, to_network] {
|
||||
if let Some(cfg) = doc.network.ethernets.get(iface) {
|
||||
@@ -724,7 +748,7 @@ fn assess_file(path: &Path, doc: &NetplanDocument, mode: &RequestedMode) -> File
|
||||
}
|
||||
}
|
||||
}
|
||||
RequestedMode::SingleInterface { interface } => {
|
||||
RequestedMode::SingleInterface { interface, .. } => {
|
||||
if let Some(cfg) = doc.network.ethernets.get(interface) {
|
||||
relevant.insert(interface.clone());
|
||||
if cfg.dhcp_disabled() && !cfg.has_l3_config() {
|
||||
@@ -1113,6 +1137,7 @@ mod tests {
|
||||
use_xdp_bridge: false,
|
||||
to_internet: "ens19".to_string(),
|
||||
to_network: "ens20".to_string(),
|
||||
mtu: None,
|
||||
}),
|
||||
single_interface: None,
|
||||
..lqos_config::Config::default()
|
||||
@@ -1126,6 +1151,7 @@ mod tests {
|
||||
interface: "ens19".to_string(),
|
||||
internet_vlan: 2,
|
||||
network_vlan: 3,
|
||||
mtu: None,
|
||||
}),
|
||||
..lqos_config::Config::default()
|
||||
}
|
||||
@@ -1144,6 +1170,102 @@ mod tests {
|
||||
BTreeMap::from([("ens19".to_string(), true), ("ens20".to_string(), true)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_linux_bridge_preview_includes_mtu_on_members_and_bridge() {
|
||||
let mut config = linux_bridge_config();
|
||||
config.bridge.as_mut().expect("bridge").mtu = Some(9000);
|
||||
let root = test_env("bridge-mtu-preview");
|
||||
|
||||
let inspection = inspect_network_mode_with_paths(
|
||||
&config,
|
||||
&root.join("netplan"),
|
||||
&root.join("pending"),
|
||||
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
|
||||
&queue_caps(),
|
||||
);
|
||||
let preview = inspection
|
||||
.managed_preview_yaml
|
||||
.expect("managed preview should exist");
|
||||
|
||||
assert_eq!(
|
||||
preview,
|
||||
"network:\n version: 2\n ethernets:\n ens19:\n dhcp4: false\n dhcp6: false\n mtu: 9000\n ens20:\n dhcp4: false\n dhcp6: false\n mtu: 9000\n bridges:\n br0:\n mtu: 9000\n interfaces:\n - ens19\n - ens20\n"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_single_interface_preview_includes_mtu_on_trunk() {
|
||||
let mut config = single_interface_config();
|
||||
config
|
||||
.single_interface
|
||||
.as_mut()
|
||||
.expect("single interface")
|
||||
.mtu = Some(1500);
|
||||
|
||||
let root = test_env("single-mtu-preview");
|
||||
let inspection = inspect_network_mode_with_paths(
|
||||
&config,
|
||||
&root.join("netplan"),
|
||||
&root.join("pending"),
|
||||
&BTreeSet::from(["ens19".to_string()]),
|
||||
&BTreeMap::from([("ens19".to_string(), true)]),
|
||||
);
|
||||
let preview = inspection
|
||||
.managed_preview_yaml
|
||||
.expect("managed preview should exist");
|
||||
|
||||
assert_eq!(
|
||||
preview,
|
||||
"network:\n version: 2\n ethernets:\n ens19:\n dhcp4: false\n dhcp6: false\n mtu: 1500\n"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_previews_omit_mtu_when_unset() {
|
||||
let root = test_env("unset-mtu-preview");
|
||||
let inspection = inspect_network_mode_with_paths(
|
||||
&linux_bridge_config(),
|
||||
&root.join("netplan"),
|
||||
&root.join("pending"),
|
||||
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
|
||||
&queue_caps(),
|
||||
);
|
||||
let preview = inspection
|
||||
.managed_preview_yaml
|
||||
.expect("managed preview should exist");
|
||||
|
||||
assert!(!preview.contains("mtu:"));
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdp_bridge_mode_keeps_managed_preview_disabled() {
|
||||
let mut config = linux_bridge_config();
|
||||
let bridge = config.bridge.as_mut().expect("bridge");
|
||||
bridge.use_xdp_bridge = true;
|
||||
bridge.mtu = Some(9000);
|
||||
|
||||
let root = test_env("xdp-mtu-preview");
|
||||
let inspection = inspect_network_mode_with_paths(
|
||||
&config,
|
||||
&root.join("netplan"),
|
||||
&root.join("pending"),
|
||||
&BTreeSet::from(["ens19".to_string(), "ens20".to_string()]),
|
||||
&queue_caps(),
|
||||
);
|
||||
|
||||
assert!(inspection.managed_preview_yaml.is_none());
|
||||
assert!(
|
||||
inspection
|
||||
.preview_note
|
||||
.as_deref()
|
||||
.is_some_and(|note| note.contains("manual workflow"))
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_external_compatible_linux_bridge() {
|
||||
let root = test_env("external-compatible");
|
||||
|
||||
@@ -170,7 +170,7 @@ fn system_interfaces() -> BTreeSet<String> {
|
||||
default_net::get_interfaces()
|
||||
.into_iter()
|
||||
.map(|iface| iface.name)
|
||||
.collect()
|
||||
.collect::<BTreeSet<_>>()
|
||||
}
|
||||
|
||||
fn supports_multi_queue(interface: &str) -> bool {
|
||||
@@ -195,7 +195,14 @@ fn supports_multi_queue(interface: &str) -> bool {
|
||||
|
||||
/// Inspect the requested network mode using the helper's configured paths.
|
||||
pub fn inspect_with_paths(paths: &HelperPaths, config: &Config) -> NetworkModeInspection {
|
||||
let system_ifaces = system_interfaces();
|
||||
inspect_with_paths_and_interfaces(paths, config, system_interfaces())
|
||||
}
|
||||
|
||||
fn inspect_with_paths_and_interfaces(
|
||||
paths: &HelperPaths,
|
||||
config: &Config,
|
||||
system_ifaces: BTreeSet<String>,
|
||||
) -> NetworkModeInspection {
|
||||
let queue_caps = system_ifaces
|
||||
.iter()
|
||||
.map(|iface| (iface.clone(), supports_multi_queue(iface)))
|
||||
@@ -750,6 +757,15 @@ pub fn apply_transaction(
|
||||
paths: &HelperPaths,
|
||||
pending_children: &mut PendingChildren,
|
||||
request: ApplyRequest,
|
||||
) -> Result<ApplyResponse> {
|
||||
apply_transaction_with_interfaces(paths, pending_children, request, system_interfaces())
|
||||
}
|
||||
|
||||
fn apply_transaction_with_interfaces(
|
||||
paths: &HelperPaths,
|
||||
pending_children: &mut PendingChildren,
|
||||
request: ApplyRequest,
|
||||
system_ifaces: BTreeSet<String>,
|
||||
) -> Result<ApplyResponse> {
|
||||
normalize_pending_children(paths, pending_children)?;
|
||||
if !list_pending_records(paths)?.is_empty() {
|
||||
@@ -761,7 +777,7 @@ pub fn apply_transaction(
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
let inspection = inspect_with_paths(paths, &request.config);
|
||||
let inspection = inspect_with_paths_and_interfaces(paths, &request.config, system_ifaces);
|
||||
validate_apply_request(&request, &inspection, &previous_config)?;
|
||||
|
||||
let preview_yaml = inspection.managed_preview_yaml.as_ref().ok_or_else(|| {
|
||||
@@ -1013,7 +1029,7 @@ esac
|
||||
.find(|iface| iface.as_str() != "lo")
|
||||
.cloned()
|
||||
.or_else(|| interfaces.iter().next().cloned())
|
||||
.expect("test host should expose at least one interface");
|
||||
.unwrap_or_else(|| "ens19".to_string());
|
||||
let secondary = interfaces
|
||||
.iter()
|
||||
.find(|iface| iface.as_str() != primary && iface.as_str() != "lo")
|
||||
@@ -1024,10 +1040,19 @@ esac
|
||||
.find(|iface| iface.as_str() != primary)
|
||||
.cloned()
|
||||
})
|
||||
.expect("test host should expose a second distinct interface");
|
||||
.unwrap_or_else(|| "ens20".to_string());
|
||||
(primary, secondary)
|
||||
}
|
||||
|
||||
fn test_system_interfaces() -> BTreeSet<String> {
|
||||
let interfaces = system_interfaces();
|
||||
if interfaces.is_empty() {
|
||||
BTreeSet::from(["ens19".to_string(), "ens20".to_string()])
|
||||
} else {
|
||||
interfaces
|
||||
}
|
||||
}
|
||||
|
||||
fn linux_bridge_config() -> Config {
|
||||
let (to_internet, to_network) = test_interfaces();
|
||||
Config {
|
||||
@@ -1035,6 +1060,7 @@ esac
|
||||
use_xdp_bridge: false,
|
||||
to_internet,
|
||||
to_network,
|
||||
mtu: None,
|
||||
}),
|
||||
single_interface: None,
|
||||
..Config::default()
|
||||
@@ -1058,7 +1084,13 @@ esac
|
||||
confirm_dangerous_changes: true,
|
||||
};
|
||||
|
||||
let apply = apply_transaction(&paths, &mut pending, request).expect("apply should succeed");
|
||||
let apply = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
request,
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect("apply should succeed");
|
||||
assert!(apply.ok);
|
||||
assert!(apply.operation.is_some());
|
||||
|
||||
@@ -1082,7 +1114,13 @@ esac
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
};
|
||||
let apply = apply_transaction(&paths, &mut pending, request).expect("second apply");
|
||||
let apply = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
request,
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect("second apply");
|
||||
let operation_id = apply
|
||||
.operation
|
||||
.as_ref()
|
||||
@@ -1112,10 +1150,11 @@ esac
|
||||
interface: test_interfaces().0,
|
||||
internet_vlan: 2,
|
||||
network_vlan: 3,
|
||||
mtu: None,
|
||||
});
|
||||
next.bridge = None;
|
||||
|
||||
let apply = apply_transaction(
|
||||
let apply = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
ApplyRequest {
|
||||
@@ -1125,6 +1164,7 @@ esac
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
},
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect("apply should succeed");
|
||||
let backup_id = apply
|
||||
@@ -1147,6 +1187,7 @@ esac
|
||||
interface: test_interfaces().1,
|
||||
internet_vlan: 10,
|
||||
network_vlan: 11,
|
||||
mtu: None,
|
||||
}),
|
||||
bridge: None,
|
||||
..Config::default()
|
||||
@@ -1181,7 +1222,7 @@ esac
|
||||
let existing = linux_bridge_config();
|
||||
write_config(&paths.config_path, &existing);
|
||||
|
||||
let apply = apply_transaction(
|
||||
let apply = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
ApplyRequest {
|
||||
@@ -1191,6 +1232,7 @@ esac
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
},
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect("apply should succeed");
|
||||
let operation_id = apply
|
||||
@@ -1228,10 +1270,11 @@ esac
|
||||
interface: test_interfaces().0,
|
||||
internet_vlan: 2,
|
||||
network_vlan: 3,
|
||||
mtu: None,
|
||||
});
|
||||
next.bridge = None;
|
||||
|
||||
let apply = apply_transaction(
|
||||
let apply = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
ApplyRequest {
|
||||
@@ -1241,6 +1284,7 @@ esac
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
},
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect("apply should succeed");
|
||||
let operation_id = apply
|
||||
@@ -1295,7 +1339,7 @@ esac
|
||||
let existing = linux_bridge_config();
|
||||
write_config(&paths.config_path, &existing);
|
||||
|
||||
let err = apply_transaction(
|
||||
let err = apply_transaction_with_interfaces(
|
||||
&paths,
|
||||
&mut pending,
|
||||
ApplyRequest {
|
||||
@@ -1305,6 +1349,7 @@ esac
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
},
|
||||
test_system_interfaces(),
|
||||
)
|
||||
.expect_err("apply should fail");
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Centralized, shared access to LibreQoS topology inputs:
|
||||
In daemon mode, this crate starts:
|
||||
|
||||
- A single-thread actor that owns reload/apply commands.
|
||||
- Watchers for the config directory and topology-state directory that coalesce
|
||||
filesystem events and request reloads.
|
||||
- Watchers for the config directory, topology-state directory, and shaping-state
|
||||
directory that coalesce filesystem events and request reloads.
|
||||
|
||||
Callers access state through public accessor functions; the underlying channel sender is kept in a
|
||||
private static.
|
||||
@@ -27,7 +27,7 @@ private static.
|
||||
- `with_network_json_write(|net_json| ...)`: mutable access to in-memory `NetworkJson` (used by runtime counters).
|
||||
- `resolve_parent_node_reference(parent_node, parent_node_id)`: canonicalize shaped-device parent references against `network.json`.
|
||||
- `request_reload_shaped_devices(reason)` / `request_reload_network_json(reason)`: ask actor to reload from disk.
|
||||
- `apply_shaped_devices_snapshot(reason, shaped)`: publish a caller-provided shaped-devices snapshot via actor.
|
||||
- `apply_shaped_devices_snapshot(reason, shaped, provenance)`: publish a caller-provided shaped-devices snapshot via actor.
|
||||
- `swap_shaped_devices_snapshot(reason, shaped_arc)`: replace shaped-devices snapshot without actor (intended for tests).
|
||||
|
||||
## `ShapedDevicesCatalog` helpers
|
||||
@@ -48,16 +48,18 @@ Runtime throughput tracking mutates the in-memory `NetworkJson` via `with_networ
|
||||
|
||||
## Access & update catalog
|
||||
|
||||
Current (2026-04) call sites and update patterns:
|
||||
Call sites and update patterns:
|
||||
|
||||
- Daemon startup: `lqosd` calls `start_daemon_mode()` once at boot and provides `DaemonHooks`.
|
||||
- Shaped-device updates:
|
||||
- Directory watcher requests reload when `topology_runtime_status.json`,
|
||||
`ShapedDevices.csv`, or `ShapedDevices.insight.csv` changes.
|
||||
- Reload precedence is: ready runtime shaping inputs, then `topology_import.json`,
|
||||
then `ShapedDevices.csv`.
|
||||
- Directory watcher requests reload when `shaping_inputs.json`,
|
||||
`topology_import.json`, `ShapedDevices.csv`, or `ShapedDevices.insight.csv` changes.
|
||||
- `topology_runtime_status.json` is readiness metadata and does not trigger a shaped-device reload by itself.
|
||||
- In integration-ingress mode, reload precedence is: ready runtime shaping inputs,
|
||||
then `topology_import.json`, then an empty snapshot until the topology runtime publishes.
|
||||
- In manual mode, reloads use `ShapedDevices.csv` or `ShapedDevices.insight.csv`.
|
||||
- Node manager admin edits write `ShapedDevices.csv` and immediately publish the new snapshot via
|
||||
`apply_shaped_devices_snapshot()`.
|
||||
`apply_shaped_devices_snapshot()` with external-snapshot provenance.
|
||||
- Network topology updates:
|
||||
- Directory watcher requests reload when `network.json`, `network.insight.json`, or
|
||||
`network.effective.json` changes, including the runtime topology-state copy.
|
||||
|
||||
@@ -3,12 +3,17 @@ use crate::dynamic::{
|
||||
};
|
||||
use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk};
|
||||
use crate::state;
|
||||
use crate::{DaemonHooks, ShapedDevicesCatalog, load_network_json, load_shaped_devices};
|
||||
use crate::{
|
||||
DaemonHooks, LoadedShapedDevices, LoadedShapedDevicesSource, ShapedDevicesCatalog,
|
||||
ShapedDevicesSnapshotProvenance, load_network_json, load_shaped_devices_with_source,
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded};
|
||||
use ip_network_table::IpNetworkTable;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -94,12 +99,14 @@ pub(crate) fn request_reload_network_json(reason: &str) -> Result<()> {
|
||||
pub(crate) fn apply_shaped_devices_snapshot(
|
||||
reason: &str,
|
||||
shaped: lqos_config::ConfigShapedDevices,
|
||||
provenance: ShapedDevicesSnapshotProvenance,
|
||||
) -> Result<()> {
|
||||
let (reply_tx, reply_rx) = bounded(1);
|
||||
send_actor_command(
|
||||
NetworkDevicesCommand::ApplyShapedDevicesSnapshot {
|
||||
reason: reason.to_string(),
|
||||
shaped: Box::new(shaped),
|
||||
provenance,
|
||||
reply: reply_tx,
|
||||
},
|
||||
"Apply shaped devices",
|
||||
@@ -156,6 +163,7 @@ enum NetworkDevicesCommand {
|
||||
ApplyShapedDevicesSnapshot {
|
||||
reason: String,
|
||||
shaped: Box<lqos_config::ConfigShapedDevices>,
|
||||
provenance: ShapedDevicesSnapshotProvenance,
|
||||
reply: Sender<Result<()>>,
|
||||
},
|
||||
ReportObservations {
|
||||
@@ -173,8 +181,11 @@ enum NetworkDevicesCommand {
|
||||
|
||||
fn actor_loop(rx: Receiver<NetworkDevicesCommand>, hooks: Option<Arc<dyn DaemonHooks>>) {
|
||||
debug!("lqos_network_devices actor starting");
|
||||
let mut active_shaped_key = None;
|
||||
|
||||
if let Err(err) = reload_shaped_devices_inner("startup", hooks.as_deref()) {
|
||||
if let Err(err) =
|
||||
reload_shaped_devices_inner("startup", hooks.as_deref(), &mut active_shaped_key)
|
||||
{
|
||||
warn!("Initial shaped-devices load failed: {err}");
|
||||
}
|
||||
if let Err(err) = reload_network_json_inner("startup", hooks.as_deref()) {
|
||||
@@ -197,7 +208,11 @@ fn actor_loop(rx: Receiver<NetworkDevicesCommand>, hooks: Option<Arc<dyn DaemonH
|
||||
|
||||
match command {
|
||||
NetworkDevicesCommand::ReloadShapedDevices { reason, reply } => {
|
||||
let result = reload_shaped_devices_inner(&reason, hooks.as_deref());
|
||||
let result = reload_shaped_devices_inner(
|
||||
&reason,
|
||||
hooks.as_deref(),
|
||||
&mut active_shaped_key,
|
||||
);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => {
|
||||
@@ -207,15 +222,18 @@ fn actor_loop(rx: Receiver<NetworkDevicesCommand>, hooks: Option<Arc<dyn DaemonH
|
||||
NetworkDevicesCommand::ApplyShapedDevicesSnapshot {
|
||||
reason,
|
||||
shaped,
|
||||
provenance,
|
||||
reply,
|
||||
} => {
|
||||
debug!("Publishing shaped-devices snapshot reason={reason}");
|
||||
state::publish_shaped_devices(*shaped);
|
||||
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref());
|
||||
if let Some(hooks) = &hooks {
|
||||
hooks.on_shaped_devices_updated();
|
||||
}
|
||||
let _ = reply.send(Ok(()));
|
||||
let key = shaped_devices_key_for_snapshot(provenance, &shaped);
|
||||
let result = publish_shaped_devices_if_changed(
|
||||
&reason,
|
||||
*shaped,
|
||||
key,
|
||||
hooks.as_deref(),
|
||||
&mut active_shaped_key,
|
||||
);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
NetworkDevicesCommand::ReportObservations { observations } => {
|
||||
handle_observations(&observations, hooks.as_deref());
|
||||
@@ -252,6 +270,119 @@ fn recompute_hashes(device: &mut lqos_config::ShapedDevice) {
|
||||
device.parent_hash = lqos_utils::hash_to_i64(&device.parent_node);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum ShapedDevicesProvenanceKey {
|
||||
RuntimeTopology {
|
||||
shaping_generation: String,
|
||||
},
|
||||
TopologyImport {
|
||||
devices_fingerprint: u64,
|
||||
},
|
||||
Csv {
|
||||
devices_fingerprint: u64,
|
||||
},
|
||||
ExternalSnapshot {
|
||||
devices_fingerprint: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
struct ShapedDeviceSemanticKey {
|
||||
circuit_id: String,
|
||||
circuit_name: String,
|
||||
device_id: String,
|
||||
device_name: String,
|
||||
parent_node: String,
|
||||
parent_node_id: Option<String>,
|
||||
anchor_node_id: Option<String>,
|
||||
mac: String,
|
||||
ipv4: Vec<(std::net::Ipv4Addr, u32)>,
|
||||
ipv6: Vec<(std::net::Ipv6Addr, u32)>,
|
||||
download_min_mbps_bits: u32,
|
||||
upload_min_mbps_bits: u32,
|
||||
download_max_mbps_bits: u32,
|
||||
upload_max_mbps_bits: u32,
|
||||
comment: String,
|
||||
sqm_override: Option<String>,
|
||||
circuit_hash: i64,
|
||||
device_hash: i64,
|
||||
parent_hash: i64,
|
||||
}
|
||||
|
||||
fn shaped_device_semantic_key(device: &lqos_config::ShapedDevice) -> ShapedDeviceSemanticKey {
|
||||
let mut ipv4 = device.ipv4.clone();
|
||||
ipv4.sort_unstable();
|
||||
let mut ipv6 = device.ipv6.clone();
|
||||
ipv6.sort_unstable();
|
||||
|
||||
ShapedDeviceSemanticKey {
|
||||
circuit_id: device.circuit_id.clone(),
|
||||
circuit_name: device.circuit_name.clone(),
|
||||
device_id: device.device_id.clone(),
|
||||
device_name: device.device_name.clone(),
|
||||
parent_node: device.parent_node.clone(),
|
||||
parent_node_id: device.parent_node_id.clone(),
|
||||
anchor_node_id: device.anchor_node_id.clone(),
|
||||
mac: device.mac.clone(),
|
||||
ipv4,
|
||||
ipv6,
|
||||
download_min_mbps_bits: device.download_min_mbps.to_bits(),
|
||||
upload_min_mbps_bits: device.upload_min_mbps.to_bits(),
|
||||
download_max_mbps_bits: device.download_max_mbps.to_bits(),
|
||||
upload_max_mbps_bits: device.upload_max_mbps.to_bits(),
|
||||
comment: device.comment.clone(),
|
||||
sqm_override: device.sqm_override.clone(),
|
||||
circuit_hash: device.circuit_hash,
|
||||
device_hash: device.device_hash,
|
||||
parent_hash: device.parent_hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn shaped_devices_fingerprint(devices: &[lqos_config::ShapedDevice]) -> u64 {
|
||||
let mut sorted = devices
|
||||
.iter()
|
||||
.map(shaped_device_semantic_key)
|
||||
.collect::<Vec<_>>();
|
||||
sorted.sort_unstable();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for key in sorted {
|
||||
key.hash(&mut hasher);
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn shaped_devices_key_for_loaded(loaded: &LoadedShapedDevices) -> ShapedDevicesProvenanceKey {
|
||||
match &loaded.source {
|
||||
LoadedShapedDevicesSource::RuntimeTopology {
|
||||
shaping_generation,
|
||||
..
|
||||
} => ShapedDevicesProvenanceKey::RuntimeTopology {
|
||||
shaping_generation: shaping_generation.clone(),
|
||||
},
|
||||
LoadedShapedDevicesSource::TopologyImport => {
|
||||
ShapedDevicesProvenanceKey::TopologyImport {
|
||||
devices_fingerprint: shaped_devices_fingerprint(&loaded.shaped.devices),
|
||||
}
|
||||
}
|
||||
LoadedShapedDevicesSource::Csv => ShapedDevicesProvenanceKey::Csv {
|
||||
devices_fingerprint: shaped_devices_fingerprint(&loaded.shaped.devices),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn shaped_devices_key_for_snapshot(
|
||||
provenance: ShapedDevicesSnapshotProvenance,
|
||||
shaped: &lqos_config::ConfigShapedDevices,
|
||||
) -> ShapedDevicesProvenanceKey {
|
||||
match provenance {
|
||||
ShapedDevicesSnapshotProvenance::ExternalSnapshot => {
|
||||
ShapedDevicesProvenanceKey::ExternalSnapshot {
|
||||
devices_fingerprint: shaped_devices_fingerprint(&shaped.devices),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_dynamic_circuits_inner(reason: &str) {
|
||||
let circuits = load_dynamic_circuits_from_disk();
|
||||
debug!("Loaded {} dynamic circuits reason={reason}", circuits.len());
|
||||
@@ -590,17 +721,45 @@ fn shaped_device_from_unknown_ip(
|
||||
device
|
||||
}
|
||||
|
||||
fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> {
|
||||
fn publish_shaped_devices_if_changed(
|
||||
reason: &str,
|
||||
shaped: lqos_config::ConfigShapedDevices,
|
||||
key: ShapedDevicesProvenanceKey,
|
||||
hooks: Option<&dyn DaemonHooks>,
|
||||
active_key: &mut Option<ShapedDevicesProvenanceKey>,
|
||||
) -> Result<()> {
|
||||
if active_key.as_ref() == Some(&key) {
|
||||
debug!("Skipping unchanged shaped-devices snapshot reason={reason}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Publishing shaped-devices snapshot reason={reason}");
|
||||
state::publish_shaped_devices(shaped);
|
||||
*active_key = Some(key);
|
||||
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks);
|
||||
if let Some(hooks) = hooks {
|
||||
hooks.on_shaped_devices_updated();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reload_shaped_devices_inner(
|
||||
reason: &str,
|
||||
hooks: Option<&dyn DaemonHooks>,
|
||||
active_key: &mut Option<ShapedDevicesProvenanceKey>,
|
||||
) -> Result<()> {
|
||||
for attempt in 1..=RELOAD_ATTEMPTS {
|
||||
match load_shaped_devices() {
|
||||
Ok(shaped) => {
|
||||
match load_shaped_devices_with_source() {
|
||||
Ok(loaded) => {
|
||||
debug!("Loaded shaped devices reason={reason}");
|
||||
state::publish_shaped_devices(shaped);
|
||||
prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks);
|
||||
if let Some(hooks) = hooks {
|
||||
hooks.on_shaped_devices_updated();
|
||||
}
|
||||
return Ok(());
|
||||
let key = shaped_devices_key_for_loaded(&loaded);
|
||||
return publish_shaped_devices_if_changed(
|
||||
reason,
|
||||
loaded.shaped,
|
||||
key,
|
||||
hooks,
|
||||
active_key,
|
||||
);
|
||||
}
|
||||
Err(err) if attempt < RELOAD_ATTEMPTS => {
|
||||
warn!(
|
||||
@@ -710,3 +869,85 @@ fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> R
|
||||
}
|
||||
unreachable!("reload loop must return");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shaped_devices_fingerprint;
|
||||
|
||||
fn shaped_device(circuit_id: &str, device_id: &str) -> lqos_config::ShapedDevice {
|
||||
let mut device = lqos_config::ShapedDevice {
|
||||
circuit_id: circuit_id.to_string(),
|
||||
circuit_name: circuit_id.to_string(),
|
||||
device_id: device_id.to_string(),
|
||||
device_name: device_id.to_string(),
|
||||
parent_node: "Tower A".to_string(),
|
||||
ipv4: vec![("192.0.2.1".parse().expect("valid IP"), 32)],
|
||||
download_min_mbps: 10.0,
|
||||
upload_min_mbps: 10.0,
|
||||
download_max_mbps: 100.0,
|
||||
upload_max_mbps: 100.0,
|
||||
..lqos_config::ShapedDevice::default()
|
||||
};
|
||||
device.circuit_hash = lqos_utils::hash_to_i64(&device.circuit_id);
|
||||
device.device_hash = lqos_utils::hash_to_i64(&device.device_id);
|
||||
device.parent_hash = lqos_utils::hash_to_i64(&device.parent_node);
|
||||
device
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_devices_fingerprint_ignores_row_order() {
|
||||
let first = vec![
|
||||
shaped_device("circuit-1", "device-1"),
|
||||
shaped_device("circuit-2", "device-2"),
|
||||
];
|
||||
let second = vec![
|
||||
shaped_device("circuit-2", "device-2"),
|
||||
shaped_device("circuit-1", "device-1"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
shaped_devices_fingerprint(&first),
|
||||
shaped_devices_fingerprint(&second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_devices_fingerprint_ignores_address_order() {
|
||||
let first = vec![shaped_device("circuit-1", "device-1")];
|
||||
let mut second = vec![shaped_device("circuit-1", "device-1")];
|
||||
second[0].ipv4 = vec![
|
||||
("192.0.2.2".parse().expect("valid IPv4"), 32),
|
||||
("192.0.2.1".parse().expect("valid IPv4"), 32),
|
||||
];
|
||||
second[0].ipv6 = vec![
|
||||
("2001:db8::2".parse().expect("valid IPv6"), 128),
|
||||
("2001:db8::1".parse().expect("valid IPv6"), 128),
|
||||
];
|
||||
let mut first = first;
|
||||
first[0].ipv4 = vec![
|
||||
("192.0.2.1".parse().expect("valid IPv4"), 32),
|
||||
("192.0.2.2".parse().expect("valid IPv4"), 32),
|
||||
];
|
||||
first[0].ipv6 = vec![
|
||||
("2001:db8::1".parse().expect("valid IPv6"), 128),
|
||||
("2001:db8::2".parse().expect("valid IPv6"), 128),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
shaped_devices_fingerprint(&first),
|
||||
shaped_devices_fingerprint(&second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_devices_fingerprint_detects_payload_changes() {
|
||||
let first = vec![shaped_device("circuit-1", "device-1")];
|
||||
let mut second = vec![shaped_device("circuit-1", "device-1")];
|
||||
second[0].download_max_mbps = 200.0;
|
||||
|
||||
assert_ne!(
|
||||
shaped_devices_fingerprint(&first),
|
||||
shaped_devices_fingerprint(&second)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ pub(crate) fn start_network_devices_directory_watch() -> Result<()> {
|
||||
"LibreQoS topology state directory",
|
||||
config.resolved_state_directory().join("topology"),
|
||||
)?;
|
||||
spawn_directory_watch_thread(
|
||||
"Network Devices Shaping Dir Watcher",
|
||||
"LibreQoS shaping state directory",
|
||||
config.resolved_state_directory().join("shaping"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -88,7 +93,7 @@ fn classify_changed_path(path: &Path) -> Option<DirectoryReloadEvent> {
|
||||
"ShapedDevices.csv"
|
||||
| "ShapedDevices.insight.csv"
|
||||
| "topology_import.json"
|
||||
| "topology_runtime_status.json" => Some(DirectoryReloadEvent::ShapedDevices),
|
||||
| "shaping_inputs.json" => Some(DirectoryReloadEvent::ShapedDevices),
|
||||
"network.effective.json" | "network.insight.json" | "network.json" => {
|
||||
Some(DirectoryReloadEvent::NetworkJson)
|
||||
}
|
||||
@@ -118,8 +123,14 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn topology_runtime_status_triggers_shaped_device_reload() {
|
||||
fn topology_runtime_status_does_not_trigger_shaped_device_reload() {
|
||||
let event = classify_changed_path(&PathBuf::from("/tmp/topology_runtime_status.json"));
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaping_inputs_triggers_shaped_device_reload() {
|
||||
let event = classify_changed_path(&PathBuf::from("/tmp/shaping_inputs.json"));
|
||||
assert_eq!(event, Some(DirectoryReloadEvent::ShapedDevices));
|
||||
}
|
||||
|
||||
@@ -134,7 +145,7 @@ mod tests {
|
||||
let events = classify_changed_paths(&[
|
||||
PathBuf::from("/tmp/network.json"),
|
||||
PathBuf::from("/tmp/network.effective.json"),
|
||||
PathBuf::from("/tmp/topology_runtime_status.json"),
|
||||
PathBuf::from("/tmp/shaping_inputs.json"),
|
||||
]);
|
||||
assert_eq!(
|
||||
events,
|
||||
|
||||
@@ -20,7 +20,9 @@ mod topology;
|
||||
mod tests;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use lqos_config::{Config, ConfigShapedDevices, NetworkJson, TopologyShapingInputsFile};
|
||||
use lqos_config::{
|
||||
Config, ConfigShapedDevices, NetworkJson, TopologyShapingInputsFile,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -30,6 +32,27 @@ pub use dynamic::{CircuitObservation, DynamicCircuit};
|
||||
pub use hash_cache::ShapedDeviceHashCache;
|
||||
pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference};
|
||||
|
||||
/// Provenance for caller-provided shaped-device snapshots.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ShapedDevicesSnapshotProvenance {
|
||||
/// Snapshot came from an external caller that already constructed a complete payload.
|
||||
ExternalSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum LoadedShapedDevicesSource {
|
||||
RuntimeTopology {
|
||||
shaping_generation: String,
|
||||
},
|
||||
TopologyImport,
|
||||
Csv,
|
||||
}
|
||||
|
||||
pub(crate) struct LoadedShapedDevices {
|
||||
pub(crate) shaped: ConfigShapedDevices,
|
||||
pub(crate) source: LoadedShapedDevicesSource,
|
||||
}
|
||||
|
||||
/// Hooks that `lqosd` can provide to run side effects after updates.
|
||||
pub trait DaemonHooks: Send + Sync + 'static {
|
||||
/// Called after a new `ShapedDevices.csv` snapshot is published.
|
||||
@@ -67,13 +90,18 @@ pub fn load_runtime_shaping_inputs_for_config(
|
||||
runtime_inputs::load_ready_runtime_shaping_inputs(config)
|
||||
}
|
||||
|
||||
fn load_shaped_devices_for_config(config: &lqos_config::Config) -> Result<ConfigShapedDevices> {
|
||||
fn load_shaped_devices_for_config(config: &lqos_config::Config) -> Result<LoadedShapedDevices> {
|
||||
if lqos_config::integration_ingress_enabled(config) {
|
||||
if let Some(shaping_inputs) = load_runtime_shaping_inputs_for_config(config)? {
|
||||
let shaped_devices =
|
||||
runtime_inputs::shaped_devices_from_runtime_inputs(&shaping_inputs);
|
||||
if !shaped_devices.devices.is_empty() {
|
||||
return Ok(shaped_devices);
|
||||
return Ok(LoadedShapedDevices {
|
||||
shaped: shaped_devices,
|
||||
source: LoadedShapedDevicesSource::RuntimeTopology {
|
||||
shaping_generation: shaping_inputs.shaping_generation,
|
||||
},
|
||||
});
|
||||
}
|
||||
debug!(
|
||||
"Active runtime shaping inputs contained 0 shaped devices; checking other integration sources"
|
||||
@@ -83,13 +111,19 @@ fn load_shaped_devices_for_config(config: &lqos_config::Config) -> Result<Config
|
||||
debug!(
|
||||
"topology_import.json is missing or contains 0 shaped devices; keeping integration mode empty until topology is published"
|
||||
);
|
||||
return Ok(ConfigShapedDevices::default());
|
||||
return Ok(LoadedShapedDevices {
|
||||
shaped: ConfigShapedDevices::default(),
|
||||
source: LoadedShapedDevicesSource::TopologyImport,
|
||||
});
|
||||
}
|
||||
match lqos_topology_compile::TopologyImportFile::load(config) {
|
||||
Ok(Some(topology_import)) => {
|
||||
let shaped_devices = topology_import.into_imported_bundle().shaped_devices;
|
||||
if !shaped_devices.devices.is_empty() {
|
||||
return Ok(shaped_devices);
|
||||
return Ok(LoadedShapedDevices {
|
||||
shaped: shaped_devices,
|
||||
source: LoadedShapedDevicesSource::TopologyImport,
|
||||
});
|
||||
}
|
||||
debug!(
|
||||
"topology_import.json advertised shaped devices but loaded empty; keeping integration mode empty until topology is published"
|
||||
@@ -106,16 +140,28 @@ fn load_shaped_devices_for_config(config: &lqos_config::Config) -> Result<Config
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(ConfigShapedDevices::default());
|
||||
return Ok(LoadedShapedDevices {
|
||||
shaped: ConfigShapedDevices::default(),
|
||||
source: LoadedShapedDevicesSource::TopologyImport,
|
||||
});
|
||||
}
|
||||
ConfigShapedDevices::load_for_config(config).context("Unable to load ShapedDevices.csv")
|
||||
Ok(LoadedShapedDevices {
|
||||
shaped: ConfigShapedDevices::load_for_config(config)
|
||||
.context("Unable to load ShapedDevices.csv")?,
|
||||
source: LoadedShapedDevicesSource::Csv,
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads shaped-device configuration from disk.
|
||||
///
|
||||
/// When an integration-backed topology ingress is enabled, this loads shaped devices from the
|
||||
/// active `topology_import.json` bundle. Otherwise, it loads `ShapedDevices.csv`.
|
||||
/// When integration-backed topology ingress is enabled, this prefers ready runtime shaping inputs,
|
||||
/// then `topology_import.json`, then an empty snapshot until the topology runtime publishes.
|
||||
/// Manual mode loads `ShapedDevices.csv`.
|
||||
pub fn load_shaped_devices() -> Result<ConfigShapedDevices> {
|
||||
Ok(load_shaped_devices_with_source()?.shaped)
|
||||
}
|
||||
|
||||
pub(crate) fn load_shaped_devices_with_source() -> Result<LoadedShapedDevices> {
|
||||
let config = lqos_config::load_config().context("Unable to load /etc/lqos.conf")?;
|
||||
load_shaped_devices_for_config(config.as_ref())
|
||||
}
|
||||
@@ -183,8 +229,12 @@ pub fn request_reload_network_json(reason: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Publishes a caller-provided shaped-devices snapshot through the runtime actor.
|
||||
pub fn apply_shaped_devices_snapshot(reason: &str, shaped: ConfigShapedDevices) -> Result<()> {
|
||||
actor::apply_shaped_devices_snapshot(reason, shaped)
|
||||
pub fn apply_shaped_devices_snapshot(
|
||||
reason: &str,
|
||||
shaped: ConfigShapedDevices,
|
||||
provenance: ShapedDevicesSnapshotProvenance,
|
||||
) -> Result<()> {
|
||||
actor::apply_shaped_devices_snapshot(reason, shaped, provenance)
|
||||
}
|
||||
|
||||
/// Reports kernel observations that may indicate dynamic circuit activity.
|
||||
|
||||
@@ -431,8 +431,8 @@ fn load_shaped_devices_uses_topology_import_when_runtime_inputs_are_empty() {
|
||||
|
||||
let loaded = load_shaped_devices_for_config(&config)
|
||||
.expect("preferred shaped-device source should load");
|
||||
assert_eq!(loaded.devices.len(), 1);
|
||||
assert_eq!(loaded.devices[0].circuit_id, "import-circuit");
|
||||
assert_eq!(loaded.shaped.devices.len(), 1);
|
||||
assert_eq!(loaded.shaped.devices[0].circuit_id, "import-circuit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -493,8 +493,8 @@ fn load_shaped_devices_uses_topology_import_when_runtime_is_not_ready() {
|
||||
|
||||
let loaded =
|
||||
load_shaped_devices_for_config(&config).expect("topology import fallback should load");
|
||||
assert_eq!(loaded.devices.len(), 1);
|
||||
assert_eq!(loaded.devices[0].circuit_id, "import-circuit");
|
||||
assert_eq!(loaded.shaped.devices.len(), 1);
|
||||
assert_eq!(loaded.shaped.devices[0].circuit_id, "import-circuit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -534,7 +534,7 @@ fn load_shaped_devices_stays_empty_when_topology_import_is_empty() {
|
||||
|
||||
let loaded =
|
||||
load_shaped_devices_for_config(&config).expect("integration mode should stay empty");
|
||||
assert!(loaded.devices.is_empty());
|
||||
assert!(loaded.shaped.devices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -582,8 +582,8 @@ fn load_shaped_devices_ignores_stale_runtime_inputs_in_manual_mode() {
|
||||
|
||||
let loaded =
|
||||
load_shaped_devices_for_config(&config).expect("manual mode should use shaped devices csv");
|
||||
assert_eq!(loaded.devices.len(), 1);
|
||||
assert_eq!(loaded.devices[0].circuit_id, "csv-circuit");
|
||||
assert_eq!(loaded.shaped.devices.len(), 1);
|
||||
assert_eq!(loaded.shaped.devices[0].circuit_id, "csv-circuit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -610,5 +610,5 @@ fn load_shaped_devices_stays_empty_when_runtime_status_is_malformed() {
|
||||
|
||||
let loaded =
|
||||
load_shaped_devices_for_config(&config).expect("malformed status should stay empty");
|
||||
assert!(loaded.devices.is_empty());
|
||||
assert!(loaded.shaped.devices.is_empty());
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl FileLock {
|
||||
}
|
||||
let unix_path = CString::new(LOCK_PATH)?;
|
||||
unsafe {
|
||||
nix::libc::chmod(unix_path.as_ptr(), mode_t::from_le(666));
|
||||
nix::libc::chmod(unix_path.as_ptr(), mode_t::from_le(0o666));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -77,7 +77,7 @@ impl FileLock {
|
||||
std::fs::create_dir(dir_path)?;
|
||||
let unix_path = CString::new(LOCK_DIR_PERMS)?;
|
||||
unsafe {
|
||||
nix::libc::chmod(unix_path.as_ptr(), 777);
|
||||
nix::libc::chmod(unix_path.as_ptr(), 0o777);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
fs::read_to_string,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -201,6 +202,9 @@ fn treeguard_read_path(config: &lqos_config::Config) -> PathBuf {
|
||||
|
||||
fn load_from_path(path: &Path) -> Result<OverrideFile> {
|
||||
let raw = read_to_string(path)?;
|
||||
if raw.trim().is_empty() {
|
||||
anyhow::bail!("Overrides file '{}' was empty", path.display());
|
||||
}
|
||||
let as_json = serde_json::from_str(&raw)?;
|
||||
Ok(as_json)
|
||||
}
|
||||
@@ -212,13 +216,40 @@ fn ensure_exists_default(path: &Path) -> Result<()> {
|
||||
// Create a default empty file
|
||||
let new_override_file = OverrideFile::default();
|
||||
let as_json = serde_json::to_string(&new_override_file)?;
|
||||
std::fs::write(path, as_json.as_bytes())?;
|
||||
write_atomic(path, as_json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_to_path(path: &Path, overrides: &OverrideFile) -> Result<()> {
|
||||
let as_json = serde_json::to_string_pretty(overrides)?;
|
||||
std::fs::write(path, as_json.as_bytes())?;
|
||||
write_atomic(path, as_json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
anyhow::anyhow!("Overrides path '{}' has no parent directory", path.display())
|
||||
})?;
|
||||
let file_name = path.file_name().ok_or_else(|| {
|
||||
anyhow::anyhow!("Overrides path '{}' has no file name", path.display())
|
||||
})?;
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp_name = format!("{}.tmp.{}.{}", file_name.to_string_lossy(), pid, nanos);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&tmp_path)?;
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1089,7 +1120,8 @@ impl OverrideStore {
|
||||
pub fn load_effective(apply_stormguard: bool, apply_treeguard: bool) -> Result<OverrideFile> {
|
||||
let lock = FileLock::new()?;
|
||||
let config = lqos_config::load_config()?;
|
||||
let merged = Self::load_effective_for_config(&config, apply_stormguard, apply_treeguard)?;
|
||||
let merged =
|
||||
Self::load_effective_for_config_inner(&config, apply_stormguard, apply_treeguard)?;
|
||||
drop(lock);
|
||||
Ok(merged)
|
||||
}
|
||||
@@ -1098,10 +1130,21 @@ impl OverrideStore {
|
||||
///
|
||||
/// This function reads override files from the config's LibreQoS directory without
|
||||
/// reloading `/etc/lqos.conf`.
|
||||
///
|
||||
/// Side effects: reads override files from disk and may create the operator
|
||||
/// overrides file if it does not exist.
|
||||
pub fn load_effective_for_config(
|
||||
config: &lqos_config::Config,
|
||||
apply_stormguard: bool,
|
||||
apply_treeguard: bool,
|
||||
) -> Result<OverrideFile> {
|
||||
Self::load_effective_for_config_inner(config, apply_stormguard, apply_treeguard)
|
||||
}
|
||||
|
||||
fn load_effective_for_config_inner(
|
||||
config: &lqos_config::Config,
|
||||
apply_stormguard: bool,
|
||||
apply_treeguard: bool,
|
||||
) -> Result<OverrideFile> {
|
||||
let operator_path = overrides_path(config, OverrideLayer::Operator);
|
||||
ensure_exists_default(&operator_path)?;
|
||||
|
||||
@@ -16,7 +16,13 @@ use lqos_bus::{BusRequest, BusResponse};
|
||||
use lqos_config::{ShapedDevice, load_config};
|
||||
use pyo3::pyclass;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const REMOTE_WEIGHT_SUCCESS_TTL: Duration = Duration::from_secs(300);
|
||||
const REMOTE_WEIGHT_FAILURE_BACKOFF: Duration = Duration::from_secs(300);
|
||||
|
||||
/// This struct is used to send a request to the Long Term Stats API
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -30,7 +36,7 @@ pub struct DeviceWeightRequest {
|
||||
|
||||
/// This struct is used to receive a response from the Long Term Stats API
|
||||
/// It contains the circuit_id and the weight of the device
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[pyclass]
|
||||
pub struct DeviceWeightResponse {
|
||||
#[pyo3(get)]
|
||||
@@ -39,6 +45,57 @@ pub struct DeviceWeightResponse {
|
||||
pub weight: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedRemoteWeights {
|
||||
fetched_at: Instant,
|
||||
weights: Vec<DeviceWeightResponse>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RemoteWeightCache {
|
||||
last_success: Option<CachedRemoteWeights>,
|
||||
last_failure: Option<Instant>,
|
||||
}
|
||||
|
||||
fn remote_weight_cache() -> &'static Mutex<RemoteWeightCache> {
|
||||
static CACHE: OnceLock<Mutex<RemoteWeightCache>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(RemoteWeightCache::default()))
|
||||
}
|
||||
|
||||
fn cached_remote_weights() -> Option<Vec<DeviceWeightResponse>> {
|
||||
let cache = remote_weight_cache().lock().ok()?;
|
||||
let cached = cache.last_success.as_ref()?;
|
||||
if cached.fetched_at.elapsed() <= REMOTE_WEIGHT_SUCCESS_TTL {
|
||||
return Some(cached.weights.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn recent_remote_weight_failure() -> bool {
|
||||
let Ok(cache) = remote_weight_cache().lock() else {
|
||||
return false;
|
||||
};
|
||||
cache
|
||||
.last_failure
|
||||
.is_some_and(|failed_at| failed_at.elapsed() <= REMOTE_WEIGHT_FAILURE_BACKOFF)
|
||||
}
|
||||
|
||||
fn remember_remote_weight_success(weights: &[DeviceWeightResponse]) {
|
||||
if let Ok(mut cache) = remote_weight_cache().lock() {
|
||||
cache.last_success = Some(CachedRemoteWeights {
|
||||
fetched_at: Instant::now(),
|
||||
weights: weights.to_vec(),
|
||||
});
|
||||
cache.last_failure = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_remote_weight_failure() {
|
||||
if let Ok(mut cache) = remote_weight_cache().lock() {
|
||||
cache.last_failure = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// This function is used to get the device weights from the Long Term Stats API
|
||||
fn get_weights_from_lts(
|
||||
org_key: &str,
|
||||
@@ -147,10 +204,28 @@ pub(crate) fn get_weights_rust() -> Result<Vec<DeviceWeightResponse>> {
|
||||
let duration_seconds = 60 * 60 * 24; // 1 day
|
||||
let percentile = 0.95;
|
||||
|
||||
eprintln!("Getting weights from Insight");
|
||||
let weights = get_weights_from_lts(&org_key, &node_id, start, duration_seconds, percentile);
|
||||
if let Ok(weights) = weights {
|
||||
eprintln!("Retrieved {} weights from Insight", weights.len());
|
||||
let remote_weights = if let Some(weights) = cached_remote_weights() {
|
||||
eprintln!("Using cached Insight weights");
|
||||
Some(weights)
|
||||
} else if recent_remote_weight_failure() {
|
||||
eprintln!("Skipping Insight weight fetch; recent request failed");
|
||||
None
|
||||
} else {
|
||||
eprintln!("Getting weights from Insight");
|
||||
match get_weights_from_lts(&org_key, &node_id, start, duration_seconds, percentile) {
|
||||
Ok(weights) => {
|
||||
eprintln!("Retrieved {} weights from Insight", weights.len());
|
||||
remember_remote_weight_success(&weights);
|
||||
Some(weights)
|
||||
}
|
||||
Err(err) => {
|
||||
remember_remote_weight_failure();
|
||||
eprintln!("Failed to get weights from Insight: {:?}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(weights) = remote_weights {
|
||||
// Merge them
|
||||
for weight in weights.iter() {
|
||||
if let Some(existing) = shaped_devices_weights
|
||||
@@ -160,8 +235,6 @@ pub(crate) fn get_weights_rust() -> Result<Vec<DeviceWeightResponse>> {
|
||||
existing.weight = weight.weight;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Failed to get weights from Insight: {:?}", weights);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -942,6 +942,7 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(add_ip_mapping, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(validate_shaped_devices, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(wait_for_bus_ready, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(xdp_ip_mapping_ready, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(is_libre_already_running, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(create_lock_file, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(free_lock_file, m)?)?;
|
||||
@@ -1084,6 +1085,7 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(scheduler_error, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(scheduler_output, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(submit_urgent_issue, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(clear_urgent_issue_by_identity, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(xdp_ip_mapping_capacity, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(is_insight_enabled, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(log_info, m)?)?;
|
||||
@@ -1379,6 +1381,13 @@ fn xdp_ip_mapping_capacity() -> PyResult<usize> {
|
||||
Ok(lqos_sys::ip_mapping_capacity())
|
||||
}
|
||||
|
||||
/// Returns whether the pinned BPF maps required for XDP IP mapping updates are ready.
|
||||
#[pyfunction]
|
||||
fn xdp_ip_mapping_ready() -> PyResult<bool> {
|
||||
lqos_sys::ip_mapping_subsystem_ready()
|
||||
.map_err(|e| PyOSError::new_err(format!("Unable to inspect XDP IP mapping maps: {e}")))
|
||||
}
|
||||
|
||||
/// Requests Rust-side validation of `ShapedDevices.csv`
|
||||
#[pyfunction]
|
||||
fn validate_shaped_devices() -> PyResult<String> {
|
||||
@@ -3571,15 +3580,15 @@ impl Bakery {
|
||||
(false, false) => "exceeds qdisc-count preflight and failed memory preflight",
|
||||
};
|
||||
let memory_summary = if let Some(snapshot) = estimate.memory_snapshot.as_ref() {
|
||||
let memory_floor = lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES;
|
||||
let required_available_bytes =
|
||||
memory_floor.saturating_add(estimate.estimated_total_memory_bytes);
|
||||
let required_available_bytes = estimate
|
||||
.memory_guard_min_available_bytes
|
||||
.saturating_add(estimate.estimated_total_memory_bytes);
|
||||
format!(
|
||||
"estimated qdisc memory {} bytes; available memory {} bytes; required minimum {} bytes (safety floor {} bytes plus estimate)",
|
||||
estimate.estimated_total_memory_bytes,
|
||||
snapshot.available_bytes,
|
||||
required_available_bytes,
|
||||
memory_floor
|
||||
estimate.memory_guard_min_available_bytes
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
@@ -3625,7 +3634,7 @@ impl Bakery {
|
||||
.memory_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.available_bytes),
|
||||
memory_guard_min_available_bytes: lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES,
|
||||
memory_guard_min_available_bytes: estimate.memory_guard_min_available_bytes,
|
||||
memory_ok: estimate.memory_ok,
|
||||
interfaces: interface_reports,
|
||||
}]);
|
||||
@@ -3658,7 +3667,7 @@ impl Bakery {
|
||||
result.set_item("memory_ok", estimate.memory_ok)?;
|
||||
result.set_item(
|
||||
"memory_guard_min_available_bytes",
|
||||
lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES,
|
||||
estimate.memory_guard_min_available_bytes,
|
||||
)?;
|
||||
if let Some(snapshot) = estimate.memory_snapshot {
|
||||
result.set_item("memory_total_bytes", snapshot.total_bytes)?;
|
||||
@@ -3865,6 +3874,52 @@ fn submit_urgent_issue(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Clear urgent issues matching a code and dedupe key.
|
||||
#[pyfunction]
|
||||
fn clear_urgent_issue_by_identity(_py: Python, code: String, dedupe_key: String) -> PyResult<bool> {
|
||||
if let Ok(reply) = run_query(vec![BusRequest::ClearUrgentIssueByIdentity {
|
||||
code: code.clone(),
|
||||
dedupe_key: dedupe_key.clone(),
|
||||
}]) {
|
||||
for resp in reply.iter() {
|
||||
if let BusResponse::Ack = resp {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(reply) = run_query(vec![BusRequest::GetUrgentIssues]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut ids_to_clear = Vec::new();
|
||||
for resp in reply.iter() {
|
||||
if let BusResponse::UrgentIssues(issues) = resp {
|
||||
ids_to_clear.extend(
|
||||
issues
|
||||
.iter()
|
||||
.filter(|issue| {
|
||||
issue.code == code
|
||||
&& issue.dedupe_key.as_deref().unwrap_or(&issue.code) == dedupe_key
|
||||
})
|
||||
.map(|issue| issue.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ids_to_clear.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let requests = ids_to_clear
|
||||
.into_iter()
|
||||
.map(BusRequest::ClearUrgentIssue)
|
||||
.collect::<Vec<_>>();
|
||||
let Ok(reply) = run_query(requests) else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(reply.iter().any(|resp| matches!(resp, BusResponse::Ack)))
|
||||
}
|
||||
|
||||
/// Log an informational message via the lqosd bus (appears in lqosd logs).
|
||||
#[pyfunction]
|
||||
fn log_info(_py: Python, message: String) -> PyResult<bool> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::queue_types::QueueType;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum QueueDiff {
|
||||
@@ -21,27 +21,46 @@ pub(crate) fn make_queue_diff(
|
||||
QueueType::FqCodel(..) => match current {
|
||||
QueueType::FqCodel(..) => Ok(fq_codel_diff(previous, current)?),
|
||||
_ => {
|
||||
error!(
|
||||
"Queue diffs are not implemented for FqCodel to {:?}",
|
||||
current
|
||||
warn!(
|
||||
previous = queue_kind(previous),
|
||||
current = queue_kind(current),
|
||||
"Queue type changed; resetting queue diff sample"
|
||||
);
|
||||
Err(QueueDiffError::NotImplemented)
|
||||
Ok(QueueDiff::None)
|
||||
}
|
||||
},
|
||||
QueueType::Cake(..) => match current {
|
||||
QueueType::Cake(..) => Ok(cake_diff(previous, current)?),
|
||||
_ => {
|
||||
error!("Queue diffs are not implemented for Cake to {:?}", current);
|
||||
Err(QueueDiffError::NotImplemented)
|
||||
warn!(
|
||||
previous = queue_kind(previous),
|
||||
current = queue_kind(current),
|
||||
"Queue type changed; resetting queue diff sample"
|
||||
);
|
||||
Ok(QueueDiff::None)
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
error!("Queue diffs are not implemented for {:?}", current);
|
||||
Err(QueueDiffError::NotImplemented)
|
||||
warn!(
|
||||
previous = queue_kind(previous),
|
||||
current = queue_kind(current),
|
||||
"Queue diff unavailable for queue type; resetting queue diff sample"
|
||||
);
|
||||
Ok(QueueDiff::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_kind(queue: &QueueType) -> &'static str {
|
||||
match queue {
|
||||
QueueType::FqCodel(_) => "fq_codel",
|
||||
QueueType::Cake(_) => "cake",
|
||||
QueueType::Mq(_) => "mq",
|
||||
QueueType::Htb(_) => "htb",
|
||||
QueueType::ClsAct => "clsact",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct CakeDiff {
|
||||
pub bytes: u64,
|
||||
@@ -116,3 +135,42 @@ fn fq_codel_diff(previous: &QueueType, current: &QueueType) -> Result<QueueDiff,
|
||||
}
|
||||
Err(QueueDiffError::NotImplemented)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::deserialize_tc_tree;
|
||||
|
||||
#[test]
|
||||
fn queue_type_change_resets_diff_sample() {
|
||||
let fq_codel = deserialize_tc_tree(
|
||||
r#"[{
|
||||
"kind":"fq_codel",
|
||||
"handle":"9000:",
|
||||
"parent":"1:2",
|
||||
"options":{},
|
||||
"bytes":1000,
|
||||
"packets":10
|
||||
}]"#,
|
||||
)
|
||||
.expect("fq_codel qdisc should parse")
|
||||
.remove(0);
|
||||
let cake = deserialize_tc_tree(
|
||||
r#"[{
|
||||
"kind":"cake",
|
||||
"handle":"9001:",
|
||||
"parent":"1:2",
|
||||
"options":{},
|
||||
"bytes":2000,
|
||||
"packets":20,
|
||||
"tins":[]
|
||||
}]"#,
|
||||
)
|
||||
.expect("cake qdisc should parse")
|
||||
.remove(0);
|
||||
|
||||
let diff = make_queue_diff(&fq_codel, &cake).expect("type changes should not error");
|
||||
|
||||
assert!(matches!(diff, QueueDiff::None));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ pub(crate) enum CommitOutcome {
|
||||
|
||||
pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> lqos_config::Config {
|
||||
let mut config = existing.unwrap_or_default();
|
||||
let existing_bridge_mtu = config.bridge.as_ref().and_then(|bridge| bridge.mtu);
|
||||
let existing_single_interface_mtu = config
|
||||
.single_interface
|
||||
.as_ref()
|
||||
.and_then(|single| single.mtu);
|
||||
let new_config = CURRENT_CONFIG.lock();
|
||||
config.node_name = new_config.node_name.clone();
|
||||
config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet;
|
||||
@@ -55,6 +60,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
|
||||
use_xdp_bridge: false,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
mtu: existing_bridge_mtu,
|
||||
});
|
||||
}
|
||||
BridgeMode::XDP => {
|
||||
@@ -63,6 +69,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
|
||||
use_xdp_bridge: true,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
mtu: existing_bridge_mtu,
|
||||
});
|
||||
}
|
||||
BridgeMode::Single => {
|
||||
@@ -70,6 +77,7 @@ pub(crate) fn build_candidate_config(existing: Option<lqos_config::Config>) -> l
|
||||
interface: new_config.to_internet.clone(),
|
||||
internet_vlan: new_config.internet_vlan,
|
||||
network_vlan: new_config.network_vlan,
|
||||
mtu: existing_single_interface_mtu,
|
||||
});
|
||||
config.bridge = None;
|
||||
}
|
||||
@@ -245,7 +253,8 @@ fn load_existing_or_default(event_log: &mut Vec<String>) -> Result<lqos_config::
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::load_existing_or_default;
|
||||
use super::{build_candidate_config, load_existing_or_default};
|
||||
use crate::config_builder::{BridgeMode, CURRENT_CONFIG};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use std::fs;
|
||||
@@ -282,4 +291,32 @@ mod tests {
|
||||
lqos_config::clear_cached_config();
|
||||
fs::remove_file(path).expect("remove temp config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidate_config_preserves_existing_mtu_for_same_mode() {
|
||||
let _guard = CONFIG_ENV_LOCK.lock();
|
||||
let previous_builder = {
|
||||
let mut builder = CURRENT_CONFIG.lock();
|
||||
let previous = builder.clone();
|
||||
builder.bridge_mode = BridgeMode::Linux;
|
||||
builder.to_internet = "wan0".to_string();
|
||||
builder.to_network = "lan0".to_string();
|
||||
previous
|
||||
};
|
||||
|
||||
let existing = lqos_config::Config {
|
||||
bridge: Some(lqos_config::BridgeConfig {
|
||||
mtu: Some(9000),
|
||||
..lqos_config::BridgeConfig::default()
|
||||
}),
|
||||
..lqos_config::Config::default()
|
||||
};
|
||||
let candidate = build_candidate_config(Some(existing));
|
||||
assert_eq!(
|
||||
candidate.bridge.as_ref().and_then(|bridge| bridge.mtu),
|
||||
Some(9000)
|
||||
);
|
||||
|
||||
*CURRENT_CONFIG.lock() = previous_builder;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub use kernel_wrapper::LibreQoSKernels;
|
||||
pub use linux::num_possible_cpus;
|
||||
pub use lqos_kernel::interface_name_to_index;
|
||||
pub use lqos_kernel::ip_mapping_capacity;
|
||||
pub use lqos_kernel::ip_mapping_subsystem_ready;
|
||||
pub use lqos_kernel::max_tracked_ips;
|
||||
pub use lqos_kernel::unload_xdp_from_interface;
|
||||
pub use tc_classify_control::{initialize_tc_classify_bypass, set_tc_classify_bypass};
|
||||
|
||||
@@ -31,6 +31,13 @@ pub(crate) mod bpf {
|
||||
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PinnedMapAbiSpec {
|
||||
path: &'static str,
|
||||
expected_key_size: u32,
|
||||
expected_value_size: u32,
|
||||
}
|
||||
|
||||
/// Returns the value set in the C XDP system's MAX_TRACKED_IPS
|
||||
/// constant.
|
||||
pub fn max_tracked_ips() -> usize {
|
||||
@@ -42,27 +49,7 @@ fn pinned_map_max_entries(path: &str) -> Result<Option<u32>> {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let path_c = CString::new(path)?;
|
||||
let fd = unsafe { bpf_obj_get(path_c.as_ptr()) };
|
||||
if fd < 0 {
|
||||
warn!("Unable to open pinned BPF map '{path}' for capacity check (fd={fd})");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut info = MaybeUninit::<bpf_map_info>::zeroed();
|
||||
let mut len: u32 = std::mem::size_of::<bpf_map_info>() as u32;
|
||||
let err = unsafe { bpf_obj_get_info_by_fd(fd, info.as_mut_ptr() as *mut c_void, &mut len) };
|
||||
unsafe {
|
||||
close(fd);
|
||||
}
|
||||
if err != 0 {
|
||||
return Err(Error::msg(format!(
|
||||
"Unable to query pinned BPF map '{path}' capacity (err={err})."
|
||||
)));
|
||||
}
|
||||
|
||||
let info = unsafe { info.assume_init() };
|
||||
Ok(Some(info.max_entries))
|
||||
Ok(pinned_map_info(path)?.map(|info| info.max_entries))
|
||||
}
|
||||
|
||||
/// Returns the currently available IP-mapping capacity.
|
||||
@@ -80,6 +67,155 @@ pub fn ip_mapping_capacity() -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_mapping_pinned_map_abi_specs() -> [PinnedMapAbiSpec; 3] {
|
||||
let expected_key_size = std::mem::size_of::<crate::ip_mapping::IpHashKey>() as u32;
|
||||
let expected_value_size = std::mem::size_of::<crate::ip_mapping::IpHashData>() as u32;
|
||||
[
|
||||
PinnedMapAbiSpec {
|
||||
path: "/sys/fs/bpf/map_ip_to_cpu_and_tc",
|
||||
expected_key_size,
|
||||
expected_value_size,
|
||||
},
|
||||
PinnedMapAbiSpec {
|
||||
path: "/sys/fs/bpf/ip_to_cpu_and_tc_hotcache",
|
||||
expected_key_size: std::mem::size_of::<XdpIpAddress>() as u32,
|
||||
expected_value_size,
|
||||
},
|
||||
PinnedMapAbiSpec {
|
||||
path: "/sys/fs/bpf/ip_mapping_epoch",
|
||||
expected_key_size: std::mem::size_of::<u32>() as u32,
|
||||
expected_value_size: std::mem::size_of::<u32>() as u32,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn pinned_map_info(path: &str) -> Result<Option<bpf_map_info>> {
|
||||
let path_c = CString::new(path)?;
|
||||
let fd = unsafe { bpf_obj_get(path_c.as_ptr()) };
|
||||
if fd < 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut info = MaybeUninit::<bpf_map_info>::zeroed();
|
||||
let mut len: u32 = std::mem::size_of::<bpf_map_info>() as u32;
|
||||
let err = unsafe { bpf_obj_get_info_by_fd(fd, info.as_mut_ptr() as *mut c_void, &mut len) };
|
||||
unsafe {
|
||||
close(fd);
|
||||
}
|
||||
if err != 0 {
|
||||
return Err(Error::msg(format!(
|
||||
"Unable to query pinned BPF map '{path}' info (err={err})."
|
||||
)));
|
||||
}
|
||||
let info = unsafe { info.assume_init() };
|
||||
Ok(Some(info))
|
||||
}
|
||||
|
||||
fn pinned_map_key_value_sizes(path: &str) -> Result<Option<(u32, u32)>> {
|
||||
Ok(pinned_map_info(path)?.map(|info| (info.key_size, info.value_size)))
|
||||
}
|
||||
|
||||
fn ip_mapping_subsystem_ready_with<F>(mut map_sizes: F) -> Result<bool>
|
||||
where
|
||||
F: FnMut(&str) -> Result<Option<(u32, u32)>>,
|
||||
{
|
||||
for spec in ip_mapping_pinned_map_abi_specs() {
|
||||
let Some((key_size, value_size)) = map_sizes(spec.path)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if key_size != spec.expected_key_size || value_size != spec.expected_value_size {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Returns whether the pinned maps required for XDP IP mapping updates are ready.
|
||||
///
|
||||
/// This function has side effects: it opens the relevant pinned BPF maps and
|
||||
/// queries their ABI metadata. It returns `Ok(false)` for maps that are not yet
|
||||
/// pinned or have an incompatible ABI, and `Err` for unexpected inspection
|
||||
/// failures.
|
||||
pub fn ip_mapping_subsystem_ready() -> Result<bool> {
|
||||
ip_mapping_subsystem_ready_with(pinned_map_key_value_sizes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn ip_mapping_ready_requires_all_expected_maps() {
|
||||
let specs = ip_mapping_pinned_map_abi_specs();
|
||||
let sizes: HashMap<&str, (u32, u32)> = specs
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
(
|
||||
spec.path,
|
||||
(spec.expected_key_size, spec.expected_value_size),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ready = ip_mapping_subsystem_ready_with(|path| Ok(sizes.get(path).copied()))
|
||||
.expect("readiness check should succeed");
|
||||
|
||||
assert!(ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_mapping_ready_is_false_when_a_map_is_missing() {
|
||||
let ready = ip_mapping_subsystem_ready_with(|path| {
|
||||
if path == "/sys/fs/bpf/ip_mapping_epoch" {
|
||||
Ok(None)
|
||||
} else {
|
||||
let spec = ip_mapping_pinned_map_abi_specs()
|
||||
.into_iter()
|
||||
.find(|spec| spec.path == path)
|
||||
.expect("test path should be part of IP mapping ABI specs");
|
||||
Ok(Some((spec.expected_key_size, spec.expected_value_size)))
|
||||
}
|
||||
})
|
||||
.expect("missing map should not be an inspection error");
|
||||
|
||||
assert!(!ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_mapping_ready_is_false_when_a_map_abi_differs() {
|
||||
let ready = ip_mapping_subsystem_ready_with(|path| {
|
||||
let spec = ip_mapping_pinned_map_abi_specs()
|
||||
.into_iter()
|
||||
.find(|spec| spec.path == path)
|
||||
.expect("test path should be part of IP mapping ABI specs");
|
||||
let value_size = if path == "/sys/fs/bpf/map_ip_to_cpu_and_tc" {
|
||||
spec.expected_value_size + 1
|
||||
} else {
|
||||
spec.expected_value_size
|
||||
};
|
||||
Ok(Some((spec.expected_key_size, value_size)))
|
||||
})
|
||||
.expect("ABI mismatch should not be an inspection error");
|
||||
|
||||
assert!(!ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_mapping_ready_returns_inspection_error() {
|
||||
let err = ip_mapping_subsystem_ready_with(|path| {
|
||||
if path == "/sys/fs/bpf/map_ip_to_cpu_and_tc" {
|
||||
Err(Error::msg("map info failed"))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.expect_err("inspection errors should be returned to the caller");
|
||||
|
||||
assert!(err.to_string().contains("map info failed"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_root() -> Result<()> {
|
||||
unsafe {
|
||||
if geteuid() == 0 {
|
||||
@@ -99,25 +235,10 @@ fn remove_incompatible_pinned_map(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path_c = CString::new(path)?;
|
||||
let fd = unsafe { bpf_obj_get(path_c.as_ptr()) };
|
||||
if fd < 0 {
|
||||
warn!("Unable to open pinned BPF map '{path}' for ABI check (fd={fd})");
|
||||
let Some(info) = pinned_map_info(path)? else {
|
||||
warn!("Unable to open pinned BPF map '{path}' for ABI check");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut info = MaybeUninit::<bpf_map_info>::zeroed();
|
||||
let mut len: u32 = std::mem::size_of::<bpf_map_info>() as u32;
|
||||
let err = unsafe { bpf_obj_get_info_by_fd(fd, info.as_mut_ptr() as *mut c_void, &mut len) };
|
||||
unsafe {
|
||||
close(fd);
|
||||
}
|
||||
if err != 0 {
|
||||
return Err(Error::msg(format!(
|
||||
"Unable to query pinned BPF map '{path}' info (err={err})."
|
||||
)));
|
||||
}
|
||||
let info = unsafe { info.assume_init() };
|
||||
};
|
||||
|
||||
if info.key_size != expected_key_size || info.value_size != expected_value_size {
|
||||
warn!(
|
||||
@@ -135,23 +256,13 @@ fn remove_incompatible_pinned_map(
|
||||
}
|
||||
|
||||
fn ensure_ip_mapping_maps_abi() -> Result<()> {
|
||||
let expected_key_size = std::mem::size_of::<crate::ip_mapping::IpHashKey>() as u32;
|
||||
let expected_value_size = std::mem::size_of::<crate::ip_mapping::IpHashData>() as u32;
|
||||
remove_incompatible_pinned_map(
|
||||
"/sys/fs/bpf/map_ip_to_cpu_and_tc",
|
||||
expected_key_size,
|
||||
expected_value_size,
|
||||
)?;
|
||||
remove_incompatible_pinned_map(
|
||||
"/sys/fs/bpf/ip_to_cpu_and_tc_hotcache",
|
||||
std::mem::size_of::<XdpIpAddress>() as u32,
|
||||
expected_value_size,
|
||||
)?;
|
||||
remove_incompatible_pinned_map(
|
||||
"/sys/fs/bpf/ip_mapping_epoch",
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)?;
|
||||
for spec in ip_mapping_pinned_map_abi_specs() {
|
||||
remove_incompatible_pinned_map(
|
||||
spec.path,
|
||||
spec.expected_key_size,
|
||||
spec.expected_value_size,
|
||||
)?;
|
||||
}
|
||||
remove_incompatible_pinned_map(
|
||||
"/sys/fs/bpf/tc_classify_control",
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
|
||||
@@ -33,10 +33,134 @@ use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const TOPOLOGY_EFFECTIVE_PUBLISH_LOCK_FILENAME: &str = "topology_effective_publish.lock";
|
||||
const TOPOLOGY_WARNING_EXAMPLE_LIMIT: usize = 5;
|
||||
type EffectiveQueueAliasMap = HashMap<String, (String, String)>;
|
||||
|
||||
pub use runtime::start_topology;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TopologyFallbackWarningSummary {
|
||||
unresolved_exported_anchor_count: usize,
|
||||
unresolved_exported_anchor_examples: Vec<String>,
|
||||
missing_anchor_count: usize,
|
||||
missing_anchor_examples: Vec<String>,
|
||||
missing_parent_count: usize,
|
||||
missing_parent_examples: Vec<String>,
|
||||
non_exported_parent_count: usize,
|
||||
non_exported_parent_examples: Vec<String>,
|
||||
}
|
||||
|
||||
impl TopologyFallbackWarningSummary {
|
||||
fn record_unresolved_exported_anchor(
|
||||
&mut self,
|
||||
circuit_id: &str,
|
||||
anchor_id: &str,
|
||||
anchor_name: &str,
|
||||
) {
|
||||
self.unresolved_exported_anchor_count += 1;
|
||||
push_capped_warning_example(
|
||||
&mut self.unresolved_exported_anchor_examples,
|
||||
format!("circuit '{circuit_id}' -> anchor '{anchor_id}' ('{anchor_name}')"),
|
||||
);
|
||||
}
|
||||
|
||||
fn record_missing_anchor(
|
||||
&mut self,
|
||||
circuit_id: &str,
|
||||
anchor_id: &str,
|
||||
anchor_name: Option<&str>,
|
||||
) {
|
||||
self.missing_anchor_count += 1;
|
||||
let example = if let Some(anchor_name) = anchor_name.filter(|value| !value.is_empty()) {
|
||||
format!("circuit '{circuit_id}' -> anchor '{anchor_id}' ('{anchor_name}')")
|
||||
} else {
|
||||
format!("circuit '{circuit_id}' -> anchor '{anchor_id}'")
|
||||
};
|
||||
push_capped_warning_example(&mut self.missing_anchor_examples, example);
|
||||
}
|
||||
|
||||
fn record_missing_parent(&mut self, circuit_id: &str, parent_name: &str, parent_id: &str) {
|
||||
self.missing_parent_count += 1;
|
||||
push_capped_warning_example(
|
||||
&mut self.missing_parent_examples,
|
||||
format!("circuit '{circuit_id}' -> parent '{parent_name}' ({parent_id})"),
|
||||
);
|
||||
}
|
||||
|
||||
fn record_non_exported_parent(&mut self, circuit_id: &str, parent_name: &str, parent_id: &str) {
|
||||
self.non_exported_parent_count += 1;
|
||||
push_capped_warning_example(
|
||||
&mut self.non_exported_parent_examples,
|
||||
format!("circuit '{circuit_id}' -> parent '{parent_name}' ({parent_id})"),
|
||||
);
|
||||
}
|
||||
|
||||
fn append_to(self, warnings: &mut Vec<String>) {
|
||||
push_fallback_summary_warning(
|
||||
warnings,
|
||||
self.unresolved_exported_anchor_count,
|
||||
"anchor(s) that did not resolve to exported effective queue nodes",
|
||||
&self.unresolved_exported_anchor_examples,
|
||||
);
|
||||
push_fallback_summary_warning(
|
||||
warnings,
|
||||
self.missing_anchor_count,
|
||||
"anchor(s) that were not found in the effective topology",
|
||||
&self.missing_anchor_examples,
|
||||
);
|
||||
push_fallback_summary_warning(
|
||||
warnings,
|
||||
self.missing_parent_count,
|
||||
"parent reference(s) that were not found in the exported effective topology",
|
||||
&self.missing_parent_examples,
|
||||
);
|
||||
push_fallback_summary_warning(
|
||||
warnings,
|
||||
self.non_exported_parent_count,
|
||||
"resolved parent(s) that were not exported effective queue nodes",
|
||||
&self.non_exported_parent_examples,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_capped_warning_example(examples: &mut Vec<String>, example: String) {
|
||||
if examples.len() < TOPOLOGY_WARNING_EXAMPLE_LIMIT {
|
||||
examples.push(example);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_fallback_summary_warning(
|
||||
warnings: &mut Vec<String>,
|
||||
count: usize,
|
||||
reason: &str,
|
||||
examples: &[String],
|
||||
) {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let mut warning = format!(
|
||||
"{count} circuit(s) referenced {reason}. Falling back to generated parent-node shaping."
|
||||
);
|
||||
if !examples.is_empty() {
|
||||
warning.push_str(" Examples: ");
|
||||
warning.push_str(&examples.join("; "));
|
||||
warning.push('.');
|
||||
let omitted_count = count.saturating_sub(examples.len());
|
||||
if omitted_count > 0 {
|
||||
warning.push_str(&format!(" {omitted_count} more omitted."));
|
||||
}
|
||||
}
|
||||
warnings.push(warning);
|
||||
}
|
||||
|
||||
fn push_unresolved_runtime_fallback_summary(warnings: &mut Vec<String>, count: usize) {
|
||||
if count > 0 {
|
||||
warnings.push(format!(
|
||||
"{count} circuit(s) are unresolved in runtime topology and will be shaped under generated parent nodes."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// One unique probe pair emitted from topology state plus operator intent.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AttachmentProbeSpec {
|
||||
@@ -547,6 +671,7 @@ fn build_shaping_inputs(
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut fallback_warning_summary = TopologyFallbackWarningSummary::default();
|
||||
let mut circuits = Vec::<TopologyShapingCircuitInput>::new();
|
||||
let mut circuits_by_id = HashMap::<String, usize>::new();
|
||||
|
||||
@@ -612,12 +737,11 @@ fn build_shaping_inputs(
|
||||
) {
|
||||
(Some(ui_node), Some(_effective_node), _) => {
|
||||
anchor_node_name = Some(ui_node.node_name.clone());
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' anchor '{}' ('{}') did not resolve to an exported effective queue node. Falling back to generated parent-node shaping.",
|
||||
device.circuit_id,
|
||||
fallback_warning_summary.record_unresolved_exported_anchor(
|
||||
&device.circuit_id,
|
||||
anchor_id,
|
||||
ui_node.node_name
|
||||
));
|
||||
&ui_node.node_name,
|
||||
);
|
||||
(
|
||||
String::new(),
|
||||
String::new(),
|
||||
@@ -627,15 +751,11 @@ fn build_shaping_inputs(
|
||||
)
|
||||
}
|
||||
(None, None, Some(anchor)) => {
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' anchor '{}' ('{}') was not found in the effective topology. Falling back to generated parent-node shaping.",
|
||||
device.circuit_id,
|
||||
fallback_warning_summary.record_missing_anchor(
|
||||
&device.circuit_id,
|
||||
anchor_id,
|
||||
anchor
|
||||
.anchor_node_name
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
));
|
||||
anchor.anchor_node_name.as_deref(),
|
||||
);
|
||||
(
|
||||
String::new(),
|
||||
String::new(),
|
||||
@@ -645,10 +765,11 @@ fn build_shaping_inputs(
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' anchor '{}' was not found in the effective topology. Falling back to generated parent-node shaping.",
|
||||
device.circuit_id, anchor_id
|
||||
));
|
||||
fallback_warning_summary.record_missing_anchor(
|
||||
&device.circuit_id,
|
||||
anchor_id,
|
||||
None,
|
||||
);
|
||||
(
|
||||
String::new(),
|
||||
String::new(),
|
||||
@@ -681,12 +802,11 @@ fn build_shaping_inputs(
|
||||
if optional_non_empty(&device.parent_node).is_some()
|
||||
|| optional_non_empty_owned(device.parent_node_id.clone()).is_some()
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' parent reference '{}' ({}) was not found in the exported effective topology. Falling back to generated parent-node shaping.",
|
||||
device.circuit_id,
|
||||
fallback_warning_summary.record_missing_parent(
|
||||
&device.circuit_id,
|
||||
device.parent_node.trim(),
|
||||
device.parent_node_id.clone().unwrap_or_default().trim()
|
||||
));
|
||||
device.parent_node_id.clone().unwrap_or_default().trim(),
|
||||
);
|
||||
}
|
||||
(
|
||||
String::new(),
|
||||
@@ -707,10 +827,11 @@ fn build_shaping_inputs(
|
||||
&exported_effective_nodes,
|
||||
&exported_effective_names,
|
||||
) {
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' resolved to non-exported effective parent '{}' ({}). Falling back to generated parent-node shaping.",
|
||||
device.circuit_id, effective_parent_node_name, effective_parent_node_id
|
||||
));
|
||||
fallback_warning_summary.record_non_exported_parent(
|
||||
&device.circuit_id,
|
||||
&effective_parent_node_name,
|
||||
&effective_parent_node_id,
|
||||
);
|
||||
effective_parent_node_id.clear();
|
||||
effective_parent_node_name.clear();
|
||||
effective_attachment_id = None;
|
||||
@@ -781,6 +902,7 @@ fn build_shaping_inputs(
|
||||
.devices
|
||||
.sort_unstable_by(|left, right| left.device_id.cmp(&right.device_id));
|
||||
}
|
||||
fallback_warning_summary.append_to(&mut warnings);
|
||||
|
||||
let unresolved_runtime_fallbacks = circuits
|
||||
.iter()
|
||||
@@ -795,16 +917,7 @@ fn build_shaping_inputs(
|
||||
"Flat topology mode assigned {unresolved_runtime_fallbacks} circuit(s) to generated parent nodes during queue construction."
|
||||
));
|
||||
} else {
|
||||
for circuit in &circuits {
|
||||
if circuit.effective_parent_node_id.trim().is_empty()
|
||||
&& circuit.resolution_source == TopologyShapingResolutionSource::RuntimeFallback
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Circuit '{}' is unresolved in runtime topology and will be shaped under generated parent nodes.",
|
||||
circuit.circuit_id
|
||||
));
|
||||
}
|
||||
}
|
||||
push_unresolved_runtime_fallback_summary(&mut warnings, unresolved_runtime_fallbacks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1583,6 +1696,13 @@ pub fn publish_topology_runtime_status(
|
||||
ready,
|
||||
error,
|
||||
);
|
||||
if TopologyRuntimeStatusFile::load(config)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.semantic_equals_for_publish(&status))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
status.save(config).with_context(|| {
|
||||
format!(
|
||||
"Unable to publish topology runtime status at {:?}",
|
||||
@@ -5399,6 +5519,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaping_inputs_aggregate_missing_parent_warnings() {
|
||||
let lqos_directory = unique_temp_dir("lqos-topology-missing-parent-summary");
|
||||
let config = Config {
|
||||
lqos_directory: lqos_directory.to_string_lossy().to_string(),
|
||||
state_directory: None,
|
||||
..Config::default()
|
||||
};
|
||||
let mut csv = String::from(
|
||||
"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\n",
|
||||
);
|
||||
for circuit_number in 1..=12 {
|
||||
csv.push_str(&format!(
|
||||
"\"circuit-{circuit_number}\",\"Circuit {circuit_number}\",\"device-{circuit_number}\",\"Device {circuit_number}\",\"Missing Parent\",\"missing-parent\",\"\",\"aa:bb:cc:dd:ee:{circuit_number:02x}\",\"192.0.2.{circuit_number}/32\",\"\",\"10\",\"10\",\"100\",\"100\",\"\"\n",
|
||||
));
|
||||
}
|
||||
fs::write(lqos_directory.join("ShapedDevices.csv"), csv)
|
||||
.expect("ShapedDevices.csv should write");
|
||||
|
||||
let artifacts = EffectiveTopologyArtifacts {
|
||||
effective: TopologyEffectiveStateFile {
|
||||
schema_version: 1,
|
||||
generated_unix: Some(1),
|
||||
canonical_generated_unix: Some(1),
|
||||
health_generated_unix: Some(1),
|
||||
nodes: Vec::new(),
|
||||
},
|
||||
ui_state: TopologyEditorStateFile {
|
||||
schema_version: 1,
|
||||
source: "test".to_string(),
|
||||
generated_unix: Some(1),
|
||||
ingress_identity: None,
|
||||
nodes: Vec::new(),
|
||||
},
|
||||
effective_network: Some(json!({})),
|
||||
};
|
||||
|
||||
let shaping_inputs = build_shaping_inputs(&config, &artifacts)
|
||||
.expect("shaping inputs should build")
|
||||
.expect("shaping inputs should be present");
|
||||
assert_eq!(shaping_inputs.circuits.len(), 12);
|
||||
assert!(shaping_inputs.circuits.iter().all(|circuit| {
|
||||
circuit.resolution_source
|
||||
== lqos_config::TopologyShapingResolutionSource::RuntimeFallback
|
||||
}));
|
||||
|
||||
let parent_warnings = shaping_inputs
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|warning| warning.contains("parent reference(s)"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(parent_warnings.len(), 1);
|
||||
assert!(parent_warnings[0].contains("12 circuit(s)"));
|
||||
assert!(parent_warnings[0].contains("circuit-1"));
|
||||
assert!(parent_warnings[0].contains("circuit-5"));
|
||||
assert!(!parent_warnings[0].contains("circuit-6"));
|
||||
assert!(parent_warnings[0].contains("7 more omitted"));
|
||||
assert_eq!(
|
||||
shaping_inputs
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|warning| warning.contains("unresolved in runtime topology"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_mode_assigns_explicit_generated_parent_buckets() {
|
||||
let lqos_directory = unique_temp_dir("lqos-topology-flat-summary");
|
||||
|
||||
@@ -2,12 +2,14 @@ use anyhow::{Context, Result};
|
||||
use lqos_bus::{BusRequest, BusResponse, bus_request_with_timeout};
|
||||
use lqos_config::{
|
||||
TopologyAttachmentEndpointStatus, TopologyAttachmentHealthEntry,
|
||||
TopologyAttachmentHealthStateFile, TopologyAttachmentHealthStatus,
|
||||
TopologyAttachmentHealthStateFile, TopologyAttachmentHealthStatus, TopologyCanonicalStateFile,
|
||||
compute_topology_source_generation, load_config,
|
||||
};
|
||||
use lqos_overrides::TopologyOverridesFile;
|
||||
use lqos_probe::{ProbeClass, ProbeRequest};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tracing::{info, warn};
|
||||
@@ -317,6 +319,61 @@ fn refresh_health_state(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn hash_health_status(status: TopologyAttachmentHealthStatus, hasher: &mut impl Hasher) {
|
||||
match status {
|
||||
TopologyAttachmentHealthStatus::Healthy => 0_u8.hash(hasher),
|
||||
TopologyAttachmentHealthStatus::Suppressed => 1_u8.hash(hasher),
|
||||
TopologyAttachmentHealthStatus::ProbeUnavailable => 2_u8.hash(hasher),
|
||||
TopologyAttachmentHealthStatus::Disabled => 3_u8.hash(hasher),
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_health_effective_entry(entry: &TopologyAttachmentHealthEntry, hasher: &mut impl Hasher) {
|
||||
entry.attachment_pair_id.hash(hasher);
|
||||
entry.attachment_id.hash(hasher);
|
||||
entry.child_node_id.hash(hasher);
|
||||
entry.parent_node_id.hash(hasher);
|
||||
entry.local_probe_ip.hash(hasher);
|
||||
entry.remote_probe_ip.hash(hasher);
|
||||
hash_health_status(entry.status, hasher);
|
||||
entry.probeable.hash(hasher);
|
||||
entry.enabled.hash(hasher);
|
||||
entry.suppressed_until_unix.hash(hasher);
|
||||
}
|
||||
|
||||
fn health_effective_signature(health_state: &TopologyAttachmentHealthStateFile) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for entry in &health_state.attachments {
|
||||
hash_health_effective_entry(entry, &mut hasher);
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct RuntimeBuildGate {
|
||||
last_source_generation: Option<String>,
|
||||
last_overrides_generation: Option<u64>,
|
||||
last_health_effective_signature: Option<u64>,
|
||||
cached_probe_specs: Vec<AttachmentProbeSpec>,
|
||||
publish_completed: bool,
|
||||
next_error_retry_after_unix: Option<u64>,
|
||||
}
|
||||
|
||||
fn topology_overrides_generation(config: &lqos_config::Config) -> u64 {
|
||||
let path = TopologyOverridesFile::path_for_config(config);
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
if let Ok(contents) = std::fs::read(&path) {
|
||||
match serde_json::from_slice::<TopologyOverridesFile>(&contents)
|
||||
.and_then(|overrides| serde_json::to_vec(&overrides))
|
||||
{
|
||||
Ok(canonical) => canonical.hash(&mut hasher),
|
||||
Err(_) => contents.hash(&mut hasher),
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct RoundHints {
|
||||
probes_enabled: bool,
|
||||
@@ -325,29 +382,62 @@ struct RoundHints {
|
||||
async fn run_round(
|
||||
health_state: &mut TopologyAttachmentHealthStateFile,
|
||||
last_effective: &mut HashMap<String, Option<String>>,
|
||||
gate: &mut RuntimeBuildGate,
|
||||
) -> Result<RoundHints> {
|
||||
let config = load_config().context("Unable to load config for topology runtime")?;
|
||||
let source_generation = compute_topology_source_generation(config.as_ref())
|
||||
.context("Unable to compute topology source generation")?;
|
||||
let canonical = load_canonical_topology_state(config.as_ref());
|
||||
let overrides =
|
||||
TopologyOverridesFile::load().context("Unable to load topology overrides file")?;
|
||||
let prepared = prepare_runtime_topology_editor_state_from_canonical(&canonical, &overrides);
|
||||
let specs = probe_specs_from_state(&prepared, &overrides);
|
||||
let overrides_generation = topology_overrides_generation(config.as_ref());
|
||||
let source_changed = gate.last_source_generation.as_deref() != Some(source_generation.as_str());
|
||||
let overrides_changed = gate.last_overrides_generation != Some(overrides_generation);
|
||||
let mut loaded_inputs: Option<(TopologyCanonicalStateFile, TopologyOverridesFile)> = None;
|
||||
if source_changed || overrides_changed || gate.cached_probe_specs.is_empty() {
|
||||
let canonical = load_canonical_topology_state(config.as_ref());
|
||||
let overrides =
|
||||
TopologyOverridesFile::load().context("Unable to load topology overrides file")?;
|
||||
let prepared = prepare_runtime_topology_editor_state_from_canonical(&canonical, &overrides);
|
||||
gate.cached_probe_specs = probe_specs_from_state(&prepared, &overrides);
|
||||
loaded_inputs = Some((canonical, overrides));
|
||||
}
|
||||
let specs = &gate.cached_probe_specs;
|
||||
let probes_enabled = specs.iter().any(|spec| spec.enabled);
|
||||
if probes_enabled {
|
||||
match probe_specs(&specs, Duration::from_millis(750)).await {
|
||||
match probe_specs(specs, Duration::from_millis(750)).await {
|
||||
Ok(probe_results) => {
|
||||
refresh_health_state(config.as_ref(), health_state, &specs, &probe_results)?;
|
||||
refresh_health_state(config.as_ref(), health_state, specs, &probe_results)?;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Topology probe round could not query shared probe manager: {err:#}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
refresh_health_state(config.as_ref(), health_state, &specs, &HashMap::new())?;
|
||||
refresh_health_state(config.as_ref(), health_state, specs, &HashMap::new())?;
|
||||
}
|
||||
|
||||
let next_signature = health_effective_signature(health_state);
|
||||
let retry_due = gate
|
||||
.next_error_retry_after_unix
|
||||
.is_some_and(|deadline| now_unix().is_some_and(|now| now >= deadline));
|
||||
let retry_pending = gate.next_error_retry_after_unix.is_some() && !retry_due;
|
||||
let source_or_health_changed = source_changed
|
||||
|| overrides_changed
|
||||
|| gate.last_health_effective_signature.as_ref() != Some(&next_signature);
|
||||
let should_publish =
|
||||
source_or_health_changed || (!retry_pending && (!gate.publish_completed || retry_due));
|
||||
if !should_publish {
|
||||
return Ok(RoundHints { probes_enabled });
|
||||
}
|
||||
|
||||
let (canonical, overrides) = match loaded_inputs {
|
||||
Some(inputs) => inputs,
|
||||
None => {
|
||||
let canonical = load_canonical_topology_state(config.as_ref());
|
||||
let overrides =
|
||||
TopologyOverridesFile::load().context("Unable to load topology overrides file")?;
|
||||
(canonical, overrides)
|
||||
}
|
||||
};
|
||||
|
||||
let artifacts = build_effective_topology_artifacts_from_canonical_with_runtime_queue_context(
|
||||
config.as_ref(),
|
||||
&canonical,
|
||||
@@ -372,8 +462,21 @@ async fn run_round(
|
||||
"Unable to publish failed topology runtime status after publish error: {status_err:#}"
|
||||
);
|
||||
}
|
||||
let health = &config.integration_common.topology_attachment_health;
|
||||
let retry_delay = health
|
||||
.refresh_debounce_seconds
|
||||
.max(health.probe_interval_seconds.max(1));
|
||||
gate.last_source_generation = Some(source_generation.clone());
|
||||
gate.last_overrides_generation = Some(overrides_generation);
|
||||
gate.last_health_effective_signature = Some(next_signature);
|
||||
gate.next_error_retry_after_unix = now_unix().map(|now| now.saturating_add(retry_delay));
|
||||
return Err(err);
|
||||
}
|
||||
gate.last_source_generation = Some(source_generation.clone());
|
||||
gate.last_overrides_generation = Some(overrides_generation);
|
||||
gate.last_health_effective_signature = Some(next_signature);
|
||||
gate.publish_completed = true;
|
||||
gate.next_error_retry_after_unix = None;
|
||||
|
||||
for node in &artifacts.effective.nodes {
|
||||
let next = node.effective_attachment_id.clone();
|
||||
@@ -398,9 +501,12 @@ async fn run_round(
|
||||
pub async fn start_topology() {
|
||||
let mut health_state = load_starting_health();
|
||||
let mut last_effective = HashMap::<String, Option<String>>::new();
|
||||
let mut build_gate = RuntimeBuildGate::default();
|
||||
|
||||
loop {
|
||||
let round_hints = match run_round(&mut health_state, &mut last_effective).await {
|
||||
let round_hints = match run_round(&mut health_state, &mut last_effective, &mut build_gate)
|
||||
.await
|
||||
{
|
||||
Ok(hints) => hints,
|
||||
Err(err) => {
|
||||
if let Ok(config) = load_config()
|
||||
@@ -438,3 +544,102 @@ pub async fn start_topology() {
|
||||
tokio::time::sleep(Duration::from_secs(sleep_seconds)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
TopologyAttachmentHealthEntry, health_effective_signature, topology_overrides_generation,
|
||||
};
|
||||
use lqos_config::{Config, TopologyAttachmentHealthStateFile, TopologyAttachmentHealthStatus};
|
||||
use lqos_overrides::TopologyOverridesFile;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be valid")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
|
||||
}
|
||||
|
||||
fn health_entry() -> TopologyAttachmentHealthEntry {
|
||||
TopologyAttachmentHealthEntry {
|
||||
attachment_pair_id: "pair-1".to_string(),
|
||||
attachment_id: Some("attachment-1".to_string()),
|
||||
child_node_id: Some("child-1".to_string()),
|
||||
parent_node_id: Some("parent-1".to_string()),
|
||||
local_probe_ip: Some("192.0.2.1".to_string()),
|
||||
remote_probe_ip: Some("192.0.2.2".to_string()),
|
||||
status: TopologyAttachmentHealthStatus::Healthy,
|
||||
probeable: true,
|
||||
enabled: true,
|
||||
consecutive_misses: 1,
|
||||
consecutive_successes: 2,
|
||||
last_success_unix: Some(10),
|
||||
endpoint_status: vec![],
|
||||
..TopologyAttachmentHealthEntry::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_effective_signature_ignores_probe_counters_and_timestamps() {
|
||||
let first = TopologyAttachmentHealthStateFile {
|
||||
generated_unix: Some(1),
|
||||
attachments: vec![health_entry()],
|
||||
..TopologyAttachmentHealthStateFile::default()
|
||||
};
|
||||
let mut second_entry = health_entry();
|
||||
second_entry.consecutive_misses = 4;
|
||||
second_entry.consecutive_successes = 5;
|
||||
second_entry.last_success_unix = Some(20);
|
||||
second_entry.last_failure_unix = Some(21);
|
||||
let second = TopologyAttachmentHealthStateFile {
|
||||
generated_unix: Some(2),
|
||||
attachments: vec![second_entry],
|
||||
..TopologyAttachmentHealthStateFile::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
health_effective_signature(&first),
|
||||
health_effective_signature(&second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_effective_signature_tracks_suppression_status() {
|
||||
let first = TopologyAttachmentHealthStateFile {
|
||||
attachments: vec![health_entry()],
|
||||
..TopologyAttachmentHealthStateFile::default()
|
||||
};
|
||||
let mut suppressed = health_entry();
|
||||
suppressed.status = TopologyAttachmentHealthStatus::Suppressed;
|
||||
suppressed.suppressed_until_unix = Some(123);
|
||||
let second = TopologyAttachmentHealthStateFile {
|
||||
attachments: vec![suppressed],
|
||||
..TopologyAttachmentHealthStateFile::default()
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
health_effective_signature(&first),
|
||||
health_effective_signature(&second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_overrides_generation_tracks_manual_override_file_changes() {
|
||||
let lqos_directory = unique_temp_dir("lqos-topology-runtime-overrides");
|
||||
fs::create_dir_all(&lqos_directory).expect("temp lqos directory should exist");
|
||||
let config = Config {
|
||||
lqos_directory: lqos_directory.to_string_lossy().to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
let path = TopologyOverridesFile::path_for_config(&config);
|
||||
|
||||
let before = topology_overrides_generation(&config);
|
||||
fs::write(&path, "{\"schemaVersion\":1}\n").expect("override file should write");
|
||||
let after = topology_overrides_generation(&config);
|
||||
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod ip_mapping;
|
||||
#[cfg(feature = "equinix_tests")]
|
||||
mod lqos_daht_test;
|
||||
pub mod lts2_sys;
|
||||
mod memory_watchdog;
|
||||
mod network_devices_hooks;
|
||||
mod node_manager;
|
||||
mod preflight_checks;
|
||||
@@ -248,6 +249,7 @@ fn main() -> Result<()> {
|
||||
|
||||
// Memory Debugging
|
||||
memory_debug();
|
||||
memory_watchdog::start_memory_watchdog();
|
||||
|
||||
let control_tx_for_webserver = control_tx_for_web.clone();
|
||||
let system_usage_tx_for_web = system_usage_tx.clone();
|
||||
@@ -381,7 +383,7 @@ fn main() -> Result<()> {
|
||||
let _ = throughput_tracker::flow_data::setup_flow_analysis();
|
||||
start_heimdall()?;
|
||||
spawn_queue_structure_monitor()?;
|
||||
shaped_devices_tracker::shaping_inputs_watcher()?;
|
||||
shaped_devices_tracker::topology_runtime_status_watcher()?;
|
||||
throughput_tracker::spawn_throughput_monitor(
|
||||
flow_tx,
|
||||
system_usage_tx.clone(),
|
||||
@@ -1045,6 +1047,10 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
urgent::clear(*id);
|
||||
BusResponse::Ack
|
||||
}
|
||||
BusRequest::ClearUrgentIssueByIdentity { code, dedupe_key } => {
|
||||
urgent::clear_by_identity(code, dedupe_key);
|
||||
BusResponse::Ack
|
||||
}
|
||||
BusRequest::ClearAllUrgentIssues => {
|
||||
urgent::clear_all();
|
||||
BusResponse::Ack
|
||||
@@ -1388,3 +1394,33 @@ fn search_result_to_bus(entry: node_manager::SearchResult) -> lqos_bus::SearchRe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lqos_bus::{UrgentSeverity, UrgentSource};
|
||||
|
||||
#[test]
|
||||
fn bus_clear_urgent_issue_by_identity_reaches_urgent_store() {
|
||||
let code = "TEST_BUS_XDP_IP_MAPPING_APPLY_FAILED";
|
||||
urgent::clear_by_identity(code, code);
|
||||
urgent::submit(
|
||||
UrgentSource::LibreQoS,
|
||||
UrgentSeverity::Error,
|
||||
code.to_string(),
|
||||
"mapping failed".to_string(),
|
||||
None,
|
||||
Some(code.to_string()),
|
||||
);
|
||||
|
||||
let requests = [BusRequest::ClearUrgentIssueByIdentity {
|
||||
code: code.to_string(),
|
||||
dedupe_key: code.to_string(),
|
||||
}];
|
||||
let mut responses = Vec::new();
|
||||
handle_bus_requests(&requests, &mut responses);
|
||||
|
||||
assert!(matches!(responses.as_slice(), [BusResponse::Ack]));
|
||||
assert!(!urgent::list().iter().any(|issue| issue.code == code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Memory pressure watchdog for controlled `lqosd` restarts.
|
||||
//!
|
||||
//! The watchdog samples `/proc` and exits the daemon before the kernel OOM
|
||||
//! path has to choose a victim. `lqosd.service` is configured with
|
||||
//! `Restart=always`, so this releases process memory while preserving logs that
|
||||
//! explain why the restart happened.
|
||||
|
||||
use crate::stats::{BUS_REQUESTS, FLOWS_TRACKED, HIGH_WATERMARK, TIME_TO_POLL_HOSTS};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const CHECK_INTERVAL_SECONDS: u64 = 15;
|
||||
const PRESSURE_WARNING_AVAILABLE_BYTES: u64 = mib(3_072);
|
||||
const DEFAULT_MIN_AVAILABLE_BYTES: u64 = mib(2_304);
|
||||
const DEFAULT_MIN_PROCESS_BYTES: u64 = mib(1_024);
|
||||
const DEFAULT_MAX_PROCESS_BYTES: u64 = gib(4);
|
||||
const DEFAULT_MAX_SWAP_BYTES: u64 = gib(1);
|
||||
const WATCHDOG_EXIT_CODE: i32 = 75;
|
||||
|
||||
/// Spawns the memory watchdog thread.
|
||||
///
|
||||
/// Side effects: this function starts a background thread. The thread reads
|
||||
/// `/proc/meminfo`, `/proc/self/status`, and exits the process when memory
|
||||
/// pressure reaches the configured critical threshold.
|
||||
pub fn start_memory_watchdog() {
|
||||
if env_flag_enabled("LQOSD_MEMORY_WATCHDOG_DISABLED") {
|
||||
warn!("lqosd memory watchdog disabled by LQOSD_MEMORY_WATCHDOG_DISABLED");
|
||||
return;
|
||||
}
|
||||
|
||||
match std::thread::Builder::new()
|
||||
.name("Memory Watchdog".to_string())
|
||||
.spawn(memory_watchdog_loop)
|
||||
{
|
||||
Ok(_) => info!("lqosd memory watchdog started"),
|
||||
Err(err) => warn!("Failed to start lqosd memory watchdog: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_watchdog_loop() {
|
||||
let config = MemoryWatchdogConfig::from_env();
|
||||
let mut warned_about_pressure = false;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_SECONDS));
|
||||
|
||||
let Ok(snapshot) = MemorySnapshot::read() else {
|
||||
warn!("Unable to sample lqosd memory state from /proc");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(reason) = config.critical_reason(&snapshot) {
|
||||
log_critical_memory_snapshot(&snapshot, &config, &reason);
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
std::process::exit(WATCHDOG_EXIT_CODE);
|
||||
}
|
||||
|
||||
if snapshot.mem_available_bytes < PRESSURE_WARNING_AVAILABLE_BYTES {
|
||||
if !warned_about_pressure {
|
||||
warn!(
|
||||
"lqosd memory pressure warning: mem_available={} process_rss={} process_swap={} process_total={} threads={}",
|
||||
format_bytes(snapshot.mem_available_bytes),
|
||||
format_bytes(snapshot.process_rss_bytes),
|
||||
format_bytes(snapshot.process_swap_bytes),
|
||||
format_bytes(snapshot.process_total_bytes()),
|
||||
snapshot.thread_count,
|
||||
);
|
||||
warned_about_pressure = true;
|
||||
}
|
||||
} else {
|
||||
warned_about_pressure = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_critical_memory_snapshot(
|
||||
snapshot: &MemorySnapshot,
|
||||
config: &MemoryWatchdogConfig,
|
||||
reason: &str,
|
||||
) {
|
||||
error!(
|
||||
"lqosd memory watchdog restarting daemon: reason={} mem_available={} mem_total={} process_rss={} process_swap={} process_total={} threads={} thresholds=min_available:{} min_process:{} max_process:{} max_swap:{}",
|
||||
reason,
|
||||
format_bytes(snapshot.mem_available_bytes),
|
||||
format_bytes(snapshot.mem_total_bytes),
|
||||
format_bytes(snapshot.process_rss_bytes),
|
||||
format_bytes(snapshot.process_swap_bytes),
|
||||
format_bytes(snapshot.process_total_bytes()),
|
||||
snapshot.thread_count,
|
||||
format_bytes(config.min_available_bytes),
|
||||
format_bytes(config.min_process_bytes),
|
||||
format_bytes(config.max_process_bytes),
|
||||
format_bytes(config.max_swap_bytes),
|
||||
);
|
||||
error!(
|
||||
"lqosd memory watchdog diagnostics: flows_tracked={} bus_requests={} time_to_poll_hosts_us={} high_watermark_down={} high_watermark_up={}",
|
||||
FLOWS_TRACKED.load(Ordering::Relaxed),
|
||||
BUS_REQUESTS.load(Ordering::Relaxed),
|
||||
TIME_TO_POLL_HOSTS.load(Ordering::Relaxed),
|
||||
HIGH_WATERMARK.get_down(),
|
||||
HIGH_WATERMARK.get_up(),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MemoryWatchdogConfig {
|
||||
min_available_bytes: u64,
|
||||
min_process_bytes: u64,
|
||||
max_process_bytes: u64,
|
||||
max_swap_bytes: u64,
|
||||
}
|
||||
|
||||
impl MemoryWatchdogConfig {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
min_available_bytes: env_mib(
|
||||
"LQOSD_MEMORY_WATCHDOG_MIN_AVAILABLE_MB",
|
||||
DEFAULT_MIN_AVAILABLE_BYTES,
|
||||
),
|
||||
min_process_bytes: env_mib(
|
||||
"LQOSD_MEMORY_WATCHDOG_MIN_PROCESS_MB",
|
||||
DEFAULT_MIN_PROCESS_BYTES,
|
||||
),
|
||||
max_process_bytes: env_mib(
|
||||
"LQOSD_MEMORY_WATCHDOG_MAX_PROCESS_MB",
|
||||
DEFAULT_MAX_PROCESS_BYTES,
|
||||
),
|
||||
max_swap_bytes: env_mib("LQOSD_MEMORY_WATCHDOG_MAX_SWAP_MB", DEFAULT_MAX_SWAP_BYTES),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_reason(&self, snapshot: &MemorySnapshot) -> Option<String> {
|
||||
if snapshot.process_swap_bytes >= self.max_swap_bytes {
|
||||
return Some(format!(
|
||||
"process swap {} reached limit {}",
|
||||
format_bytes(snapshot.process_swap_bytes),
|
||||
format_bytes(self.max_swap_bytes)
|
||||
));
|
||||
}
|
||||
|
||||
if snapshot.process_total_bytes() >= self.max_process_bytes {
|
||||
return Some(format!(
|
||||
"process rss+swap {} reached limit {}",
|
||||
format_bytes(snapshot.process_total_bytes()),
|
||||
format_bytes(self.max_process_bytes)
|
||||
));
|
||||
}
|
||||
|
||||
if snapshot.mem_available_bytes < self.min_available_bytes
|
||||
&& snapshot.process_total_bytes() >= self.min_process_bytes
|
||||
{
|
||||
return Some(format!(
|
||||
"available memory {} below limit {} while lqosd uses {}",
|
||||
format_bytes(snapshot.mem_available_bytes),
|
||||
format_bytes(self.min_available_bytes),
|
||||
format_bytes(snapshot.process_total_bytes())
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct MemorySnapshot {
|
||||
mem_total_bytes: u64,
|
||||
mem_available_bytes: u64,
|
||||
process_rss_bytes: u64,
|
||||
process_swap_bytes: u64,
|
||||
thread_count: u64,
|
||||
}
|
||||
|
||||
impl MemorySnapshot {
|
||||
fn read() -> anyhow::Result<Self> {
|
||||
let meminfo = parse_proc_key_values(&std::fs::read_to_string("/proc/meminfo")?);
|
||||
let status = parse_proc_key_values(&std::fs::read_to_string("/proc/self/status")?);
|
||||
|
||||
Ok(Self {
|
||||
mem_total_bytes: read_required_kb(&meminfo, "MemTotal")?,
|
||||
mem_available_bytes: read_required_kb(&meminfo, "MemAvailable")?,
|
||||
process_rss_bytes: read_required_kb(&status, "VmRSS")?,
|
||||
process_swap_bytes: read_optional_kb(&status, "VmSwap"),
|
||||
thread_count: read_optional_raw(&status, "Threads"),
|
||||
})
|
||||
}
|
||||
|
||||
fn process_total_bytes(&self) -> u64 {
|
||||
self.process_rss_bytes
|
||||
.saturating_add(self.process_swap_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_proc_key_values(input: &str) -> HashMap<String, u64> {
|
||||
input
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, rest) = line.split_once(':')?;
|
||||
let value = rest.split_whitespace().next()?.parse::<u64>().ok()?;
|
||||
Some((key.to_string(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_required_kb(values: &HashMap<String, u64>, key: &str) -> anyhow::Result<u64> {
|
||||
values
|
||||
.get(key)
|
||||
.copied()
|
||||
.map(kib)
|
||||
.ok_or_else(|| anyhow::anyhow!("{key} missing from /proc memory data"))
|
||||
}
|
||||
|
||||
fn read_optional_kb(values: &HashMap<String, u64>, key: &str) -> u64 {
|
||||
values.get(key).copied().map(kib).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn read_optional_raw(values: &HashMap<String, u64>, key: &str) -> u64 {
|
||||
values.get(key).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn env_flag_enabled(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env_mib(name: &str, default_bytes: u64) -> u64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(mib)
|
||||
.unwrap_or(default_bytes)
|
||||
}
|
||||
|
||||
const fn kib(value: u64) -> u64 {
|
||||
value * 1024
|
||||
}
|
||||
|
||||
const fn mib(value: u64) -> u64 {
|
||||
value * 1024 * 1024
|
||||
}
|
||||
|
||||
const fn gib(value: u64) -> u64 {
|
||||
value * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
let mib_value = bytes as f64 / mib(1) as f64;
|
||||
format!("{mib_value:.1} MiB")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proc_parser_reads_kb_and_raw_values() {
|
||||
let parsed = parse_proc_key_values(
|
||||
"MemTotal: 15458992 kB\nMemAvailable: 12843176 kB\nThreads:\t44\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
read_required_kb(&parsed, "MemTotal").unwrap(),
|
||||
15_458_992 * 1024
|
||||
);
|
||||
assert_eq!(read_optional_raw(&parsed, "Threads"), 44);
|
||||
assert_eq!(read_optional_kb(&parsed, "VmSwap"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_restarts_on_process_swap_limit() {
|
||||
let config = MemoryWatchdogConfig {
|
||||
min_available_bytes: mib(2_304),
|
||||
min_process_bytes: mib(1_024),
|
||||
max_process_bytes: gib(4),
|
||||
max_swap_bytes: gib(1),
|
||||
};
|
||||
let snapshot = MemorySnapshot {
|
||||
mem_total_bytes: gib(16),
|
||||
mem_available_bytes: gib(12),
|
||||
process_rss_bytes: mib(900),
|
||||
process_swap_bytes: gib(2),
|
||||
thread_count: 44,
|
||||
};
|
||||
|
||||
assert!(
|
||||
config
|
||||
.critical_reason(&snapshot)
|
||||
.unwrap()
|
||||
.contains("process swap")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_restarts_on_low_available_memory_with_large_lqosd() {
|
||||
let config = MemoryWatchdogConfig {
|
||||
min_available_bytes: mib(2_304),
|
||||
min_process_bytes: mib(1_024),
|
||||
max_process_bytes: gib(4),
|
||||
max_swap_bytes: gib(1),
|
||||
};
|
||||
let snapshot = MemorySnapshot {
|
||||
mem_total_bytes: gib(16),
|
||||
mem_available_bytes: mib(1_900),
|
||||
process_rss_bytes: mib(1_500),
|
||||
process_swap_bytes: 0,
|
||||
thread_count: 44,
|
||||
};
|
||||
|
||||
assert!(
|
||||
config
|
||||
.critical_reason(&snapshot)
|
||||
.unwrap()
|
||||
.contains("available memory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_ignores_low_memory_when_lqosd_is_small() {
|
||||
let config = MemoryWatchdogConfig {
|
||||
min_available_bytes: mib(2_304),
|
||||
min_process_bytes: mib(1_024),
|
||||
max_process_bytes: gib(4),
|
||||
max_swap_bytes: gib(1),
|
||||
};
|
||||
let snapshot = MemorySnapshot {
|
||||
mem_total_bytes: gib(16),
|
||||
mem_available_bytes: mib(1_900),
|
||||
process_rss_bytes: mib(256),
|
||||
process_swap_bytes: 0,
|
||||
thread_count: 44,
|
||||
};
|
||||
|
||||
assert!(config.critical_reason(&snapshot).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {hydrateDraftMtu, parseOptionalMtu} from "./network_mode_mtu.mjs";
|
||||
|
||||
test("accepts blank and valid MTU values", () => {
|
||||
assert.deepEqual(parseOptionalMtu(""), { ok: true, value: null, error: "" });
|
||||
assert.deepEqual(parseOptionalMtu(" "), { ok: true, value: null, error: "" });
|
||||
assert.deepEqual(parseOptionalMtu("1500"), { ok: true, value: 1500, error: "" });
|
||||
assert.deepEqual(parseOptionalMtu("9000"), { ok: true, value: 9000, error: "" });
|
||||
});
|
||||
|
||||
test("rejects invalid MTU values", () => {
|
||||
assert.equal(parseOptionalMtu("abc").ok, false);
|
||||
assert.equal(parseOptionalMtu("1500.5").ok, false);
|
||||
assert.equal(parseOptionalMtu("575").ok, false);
|
||||
assert.equal(parseOptionalMtu("9217").ok, false);
|
||||
});
|
||||
|
||||
test("returns normalized candidate values", () => {
|
||||
assert.equal(parseOptionalMtu("9000").value, 9000);
|
||||
assert.equal(parseOptionalMtu("").value, null);
|
||||
});
|
||||
|
||||
test("hydrates pre-MTU drafts from live config", () => {
|
||||
const liveConfig = {
|
||||
bridge: { mtu: 9000 },
|
||||
single_interface: { mtu: 1500 },
|
||||
};
|
||||
|
||||
assert.equal(hydrateDraftMtu({ bridge: { to_internet: "eth0" } }, liveConfig).bridge.mtu, 9000);
|
||||
assert.equal(
|
||||
hydrateDraftMtu({ single_interface: { interface: "eth0" } }, liveConfig).single_interface.mtu,
|
||||
1500
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps explicit draft MTU values", () => {
|
||||
const liveConfig = {
|
||||
bridge: { mtu: 9000 },
|
||||
single_interface: { mtu: 1500 },
|
||||
};
|
||||
|
||||
assert.equal(hydrateDraftMtu({ bridge: { mtu: null } }, liveConfig).bridge.mtu, null);
|
||||
assert.equal(hydrateDraftMtu({ single_interface: { mtu: 1400 } }, liveConfig).single_interface.mtu, 1400);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export const MTU_MIN = 576;
|
||||
export const MTU_MAX = 9216;
|
||||
|
||||
export function parseOptionalMtu(rawValue) {
|
||||
const trimmed = String(rawValue ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return { ok: true, value: null, error: "" };
|
||||
}
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return { ok: false, value: null, error: `MTU must be a whole number from ${MTU_MIN} through ${MTU_MAX}.` };
|
||||
}
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (value < MTU_MIN || value > MTU_MAX) {
|
||||
return { ok: false, value: null, error: `MTU must be from ${MTU_MIN} through ${MTU_MAX}.` };
|
||||
}
|
||||
return { ok: true, value, error: "" };
|
||||
}
|
||||
|
||||
export function hydrateDraftMtu(draft, liveConfig) {
|
||||
if (!draft) return null;
|
||||
const hydrated = JSON.parse(JSON.stringify(draft));
|
||||
if (hydrated.bridge && hydrated.bridge.mtu === undefined) {
|
||||
hydrated.bridge.mtu = liveConfig?.bridge?.mtu ?? null;
|
||||
}
|
||||
if (hydrated.single_interface && hydrated.single_interface.mtu === undefined) {
|
||||
hydrated.single_interface.mtu = liveConfig?.single_interface?.mtu ?? null;
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import {loadConfig, renderConfigMenu} from "./config/config_helper";
|
||||
import {hydrateDraftMtu, MTU_MAX, MTU_MIN, parseOptionalMtu} from "./config/network_mode_mtu.mjs";
|
||||
|
||||
const DRAFT_KEY = "lqos-network-mode-draft";
|
||||
const PENDING_OPERATION_KEY = "lqos-network-mode-pending";
|
||||
const NETPLAN_TRY_TIMEOUT_MS = 30_000;
|
||||
const RECONNECT_POLL_INTERVAL_MS = 2_000;
|
||||
const BRIDGE_MTU_HELP = "Applies to the bridge members and br0.";
|
||||
const SINGLE_INTERFACE_MTU_HELP = "Applies to the trunk interface.";
|
||||
const MTU_LOCKED_HELP = "MTU cannot be changed while a network-mode operation is pending.";
|
||||
const XDP_MTU_HELP = "XDP mode keeps this value for later, but LibreQoS will not apply it.";
|
||||
let currentHelperStatus = null;
|
||||
let currentInspection = null;
|
||||
let currentPendingOperation = null;
|
||||
@@ -71,7 +76,94 @@ function postJson(url, body) {
|
||||
});
|
||||
}
|
||||
|
||||
function setMtuFieldError(inputId, errorId, message) {
|
||||
const input = document.getElementById(inputId);
|
||||
const error = document.getElementById(errorId);
|
||||
if (!input || !error) return;
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
input.classList.add("is-invalid");
|
||||
error.textContent = message;
|
||||
}
|
||||
|
||||
function clearMtuFieldError(inputId, errorId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const error = document.getElementById(errorId);
|
||||
if (!input || !error) return;
|
||||
input.removeAttribute("aria-invalid");
|
||||
input.classList.remove("is-invalid");
|
||||
error.textContent = "";
|
||||
}
|
||||
|
||||
function clearMtuErrors() {
|
||||
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
|
||||
clearMtuFieldError("singleInterfaceMtu", "singleInterfaceMtuError");
|
||||
}
|
||||
|
||||
function applyMtuBounds() {
|
||||
["bridgeMtu", "singleInterfaceMtu"].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
if (!input) return;
|
||||
input.min = String(MTU_MIN);
|
||||
input.max = String(MTU_MAX);
|
||||
});
|
||||
}
|
||||
|
||||
function validateMtuField(inputId, errorId, parser) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return true;
|
||||
const parsed = parser(input.value);
|
||||
if (parsed.ok) {
|
||||
clearMtuFieldError(inputId, errorId);
|
||||
return true;
|
||||
}
|
||||
setMtuFieldError(inputId, errorId, parsed.error);
|
||||
input.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function currentBridgeMtuValue() {
|
||||
const parsed = parseOptionalMtu(document.getElementById("bridgeMtu").value);
|
||||
if (parsed.ok) return parsed.value;
|
||||
return window.config?.bridge?.mtu ?? null;
|
||||
}
|
||||
|
||||
function updateBridgeMtuState() {
|
||||
const useXdpBridge = document.getElementById("useXdpBridge")?.checked ?? false;
|
||||
const bridgeMtu = document.getElementById("bridgeMtu");
|
||||
const bridgeMtuHelp = document.getElementById("bridgeMtuHelp");
|
||||
if (!bridgeMtu || !bridgeMtuHelp) return;
|
||||
const editingLocked = Boolean(currentInspection?.editing_locked);
|
||||
bridgeMtu.disabled = useXdpBridge || editingLocked;
|
||||
if (editingLocked) {
|
||||
bridgeMtuHelp.textContent = MTU_LOCKED_HELP;
|
||||
return;
|
||||
}
|
||||
if (useXdpBridge) {
|
||||
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
|
||||
bridgeMtuHelp.textContent = XDP_MTU_HELP;
|
||||
} else {
|
||||
bridgeMtuHelp.textContent = BRIDGE_MTU_HELP;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSingleInterfaceMtuState() {
|
||||
const singleInterfaceMtu = document.getElementById("singleInterfaceMtu");
|
||||
const singleInterfaceMtuHelp = document.getElementById("singleInterfaceMtuHelp");
|
||||
if (!singleInterfaceMtu || !singleInterfaceMtuHelp) return;
|
||||
if (currentInspection?.editing_locked) {
|
||||
singleInterfaceMtuHelp.textContent = MTU_LOCKED_HELP;
|
||||
} else {
|
||||
singleInterfaceMtuHelp.textContent = SINGLE_INTERFACE_MTU_HELP;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMtuState() {
|
||||
updateBridgeMtuState();
|
||||
updateSingleInterfaceMtuState();
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
clearMtuErrors();
|
||||
if (document.getElementById("bridgeMode").checked) {
|
||||
const toInternet = document.getElementById("toInternet").value.trim();
|
||||
const toNetwork = document.getElementById("toNetwork").value.trim();
|
||||
@@ -83,6 +175,11 @@ function validateConfig() {
|
||||
alert("Bridge mode requires two different interfaces");
|
||||
return false;
|
||||
}
|
||||
if (document.getElementById("useXdpBridge").checked) {
|
||||
clearMtuFieldError("bridgeMtu", "bridgeMtuError");
|
||||
} else if (!validateMtuField("bridgeMtu", "bridgeMtuError", parseOptionalMtu)) {
|
||||
return false;
|
||||
}
|
||||
} else if (document.getElementById("singleInterfaceMode").checked) {
|
||||
const interfaceName = document.getElementById("interface").value.trim();
|
||||
const internetVlan = parseInt(document.getElementById("internetVlan").value, 10);
|
||||
@@ -99,6 +196,9 @@ function validateConfig() {
|
||||
alert("Network VLAN must be between 1 and 4094");
|
||||
return false;
|
||||
}
|
||||
if (!validateMtuField("singleInterfaceMtu", "singleInterfaceMtuError", parseOptionalMtu)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
alert("Please select either bridge or single interface mode");
|
||||
return false;
|
||||
@@ -116,12 +216,14 @@ function buildCandidateConfig() {
|
||||
use_xdp_bridge: document.getElementById("useXdpBridge").checked,
|
||||
to_internet: document.getElementById("toInternet").value.trim(),
|
||||
to_network: document.getElementById("toNetwork").value.trim(),
|
||||
mtu: currentBridgeMtuValue(),
|
||||
};
|
||||
} else {
|
||||
next.single_interface = {
|
||||
interface: document.getElementById("interface").value.trim(),
|
||||
internet_vlan: parseInt(document.getElementById("internetVlan").value, 10),
|
||||
network_vlan: parseInt(document.getElementById("networkVlan").value, 10),
|
||||
mtu: parseOptionalMtu(document.getElementById("singleInterfaceMtu").value).value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,15 +349,18 @@ function populateFormFromConfig(config) {
|
||||
document.getElementById("useXdpBridge").checked = config.bridge.use_xdp_bridge ?? true;
|
||||
document.getElementById("toInternet").value = config.bridge.to_internet ?? "";
|
||||
document.getElementById("toNetwork").value = config.bridge.to_network ?? "";
|
||||
document.getElementById("bridgeMtu").value = config.bridge.mtu ?? "";
|
||||
} else if (config?.single_interface) {
|
||||
document.getElementById("singleInterfaceMode").checked = true;
|
||||
document.getElementById("interface").value = config.single_interface.interface ?? "";
|
||||
document.getElementById("internetVlan").value = config.single_interface.internet_vlan ?? 2;
|
||||
document.getElementById("networkVlan").value = config.single_interface.network_vlan ?? 3;
|
||||
document.getElementById("singleInterfaceMtu").value = config.single_interface.mtu ?? "";
|
||||
}
|
||||
|
||||
const event = new Event("change");
|
||||
document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event);
|
||||
updateMtuState();
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
@@ -590,9 +695,11 @@ function renderInspection(inspection) {
|
||||
"useXdpBridge",
|
||||
"toInternet",
|
||||
"toNetwork",
|
||||
"bridgeMtu",
|
||||
"interface",
|
||||
"internetVlan",
|
||||
"networkVlan",
|
||||
"singleInterfaceMtu",
|
||||
"saveButton",
|
||||
].forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
@@ -603,6 +710,7 @@ function renderInspection(inspection) {
|
||||
applyButton.disabled = !inspection?.can_apply;
|
||||
adoptButton.disabled = !inspection?.can_adopt;
|
||||
takeoverButton.disabled = !inspection?.can_take_over;
|
||||
updateMtuState();
|
||||
}
|
||||
|
||||
function renderHelperStatus(status) {
|
||||
@@ -820,6 +928,7 @@ function retryShaping() {
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
applyMtuBounds();
|
||||
wireReviewTabs();
|
||||
[
|
||||
"bridgeMode",
|
||||
@@ -832,6 +941,21 @@ function wireActions() {
|
||||
renderInterfaceSelectors();
|
||||
});
|
||||
});
|
||||
document.getElementById("useXdpBridge").addEventListener("change", updateMtuState);
|
||||
[
|
||||
"bridgeMtu",
|
||||
"singleInterfaceMtu",
|
||||
].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("input", () => {
|
||||
if (id === "bridgeMtu") {
|
||||
const parsed = parseOptionalMtu(document.getElementById("bridgeMtu").value);
|
||||
if (parsed.ok) clearMtuFieldError("bridgeMtu", "bridgeMtuError");
|
||||
} else {
|
||||
const parsed = parseOptionalMtu(document.getElementById("singleInterfaceMtu").value);
|
||||
if (parsed.ok) clearMtuFieldError("singleInterfaceMtu", "singleInterfaceMtuError");
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById("saveButton").addEventListener("click", () => {
|
||||
if (!validateConfig()) return;
|
||||
saveDraft();
|
||||
@@ -867,7 +991,7 @@ loadConfig((msg) => {
|
||||
|
||||
renderShapingStatus(payload.shaping_status || {});
|
||||
renderInspection(payload.network_mode_inspection || {});
|
||||
const draft = loadDraft();
|
||||
const draft = hydrateDraftMtu(loadDraft(), window.config);
|
||||
populateFormFromConfig(draft || window.config);
|
||||
renderRecoveryPanel();
|
||||
wireActions();
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import {listenOnceMatchingWithTimeout} from "./pubsub/listeners";
|
||||
import {get_ws_client, subscribeWS} from "./pubsub/ws";
|
||||
|
||||
const wsClient = get_ws_client();
|
||||
const DEFAULT_EXECUTIVE_REFRESH_MS = 5000;
|
||||
let executiveRequestCounter = 0;
|
||||
|
||||
const listenOnce = (eventName, handler) => {
|
||||
const wrapped = (msg) => {
|
||||
wsClient.off(eventName, wrapped);
|
||||
handler(msg);
|
||||
};
|
||||
wsClient.on(eventName, wrapped);
|
||||
};
|
||||
|
||||
function normalizeEntityKinds(entityKinds) {
|
||||
return [...new Set((entityKinds || []).map((kind) => String(kind)))].sort();
|
||||
}
|
||||
@@ -178,7 +171,7 @@ export function getNodeIdMap() {
|
||||
siteIdMap = map;
|
||||
resolve(map);
|
||||
};
|
||||
listenOnce("NetworkTreeLite", (msg) => {
|
||||
listenOnceMatchingWithTimeout(wsClient, "NetworkTreeLite", 5000, () => true, (msg) => {
|
||||
const data = msg && msg.data ? msg.data : [];
|
||||
const map = new Map();
|
||||
(data || []).forEach((entry) => {
|
||||
@@ -190,11 +183,10 @@ export function getNodeIdMap() {
|
||||
}
|
||||
});
|
||||
resolveOnce(map);
|
||||
}, () => {
|
||||
resolveOnce(new Map());
|
||||
});
|
||||
wsClient.send({ NetworkTreeLite: {} });
|
||||
setTimeout(() => {
|
||||
resolveOnce(new Map());
|
||||
}, 5000);
|
||||
});
|
||||
return siteIdMapPromise;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export function listenOnceMatchingWithTimeout(wsClient, eventName, timeoutMs, matches, handler, onTimeout) {
|
||||
let done = false;
|
||||
const cleanup = wsClient.on(eventName, (msg) => {
|
||||
if (done) return;
|
||||
if (!matches(msg)) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
handler(msg);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
onTimeout();
|
||||
}, timeoutMs);
|
||||
return () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,10 @@ export class WsClient {
|
||||
|
||||
connect() {
|
||||
if (this.ws) {
|
||||
return;
|
||||
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this._dropSocket("stale-socket");
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
@@ -197,6 +200,11 @@ export class WsClient {
|
||||
});
|
||||
this.ws = null;
|
||||
this.handshake_done = false;
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_) {
|
||||
// The browser may already have closed the socket.
|
||||
}
|
||||
if (!this.versionReloading) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
@@ -312,7 +320,7 @@ export class WsClient {
|
||||
dashboardWsInteresting("subscribe-wire-interesting", [channel], {
|
||||
desiredChannels: Array.from(this.desiredChannels.keys()),
|
||||
});
|
||||
this.ws.send(encoder.encode({ Subscribe: { channel } }));
|
||||
this._sendControl({ Subscribe: { channel } });
|
||||
}
|
||||
}
|
||||
if (channels.length > 0) {
|
||||
@@ -339,7 +347,7 @@ export class WsClient {
|
||||
dashboardWsInteresting("unsubscribe-wire-interesting", [channel], {
|
||||
desiredChannels: Array.from(this.desiredChannels.keys()),
|
||||
});
|
||||
this.ws.send(encoder.encode({ Unsubscribe: { channel } }));
|
||||
this._sendControl({ Unsubscribe: { channel } });
|
||||
}
|
||||
} else {
|
||||
this.desiredChannels.set(channel, current - 1);
|
||||
@@ -355,11 +363,16 @@ export class WsClient {
|
||||
if (!this.ws) {
|
||||
this.connect();
|
||||
}
|
||||
if (!this.handshake_done) {
|
||||
if (!this.handshake_done || !this._socketIsOpen()) {
|
||||
this.pending.push(normalized);
|
||||
if (this.handshake_done && this.ws && !this._socketIsOpen()) {
|
||||
this._retireSocket();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.ws.send(encoder.encode(normalized));
|
||||
if (!this._sendControl(normalized)) {
|
||||
this.pending.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
on(event_name, handler) {
|
||||
@@ -406,28 +419,58 @@ export class WsClient {
|
||||
dashboardWsDebug("handshake-complete", {
|
||||
desiredChannels: Array.from(this.desiredChannels.keys()),
|
||||
});
|
||||
this.ws.send(
|
||||
encoder.encode({
|
||||
HelloReply: {
|
||||
ack: ACK_TEXT,
|
||||
token: get_user_token(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < this.pending.length; i++) {
|
||||
this.ws.send(encoder.encode(this.pending[i]));
|
||||
if (!this._sendControl({
|
||||
HelloReply: {
|
||||
ack: ACK_TEXT,
|
||||
token: get_user_token(),
|
||||
},
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = [];
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
if (!this._sendControl(pending[i])) {
|
||||
this.pending = pending.slice(i).concat(this.pending);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const channel of this.desiredChannels.keys()) {
|
||||
dashboardWsInteresting("subscribe-wire-interesting", [channel], {
|
||||
desiredChannels: Array.from(this.desiredChannels.keys()),
|
||||
fromHandshake: true,
|
||||
});
|
||||
this.ws.send(encoder.encode({ Subscribe: { channel } }));
|
||||
if (!this._sendControl({ Subscribe: { channel } })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.reconnectDelayMs = 1000;
|
||||
}
|
||||
|
||||
_socketIsOpen() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
_retireSocket() {
|
||||
this._dropSocket("retire-socket");
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
|
||||
_sendControl(request_obj) {
|
||||
if (!this._socketIsOpen()) {
|
||||
this._retireSocket();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.ws.send(encoder.encode(request_obj));
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("Websocket send failed; reconnecting", err);
|
||||
this._retireSocket();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(msg) {
|
||||
if (!msg || !msg.event) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
globalThis.window = {
|
||||
LQOS_UI_VERSION: "test",
|
||||
location: { search: "", protocol: "http:", host: "localhost" },
|
||||
localStorage: { getItem: () => null },
|
||||
sessionStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
},
|
||||
};
|
||||
globalThis.document = { cookie: "" };
|
||||
|
||||
const { listenOnceMatchingWithTimeout } = await import("./listeners.js");
|
||||
|
||||
function makeWsClient() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Set());
|
||||
}
|
||||
handlers.get(eventName).add(handler);
|
||||
return () => this.off(eventName, handler);
|
||||
},
|
||||
off(eventName, handler) {
|
||||
handlers.get(eventName)?.delete(handler);
|
||||
},
|
||||
emit(eventName, message) {
|
||||
for (const handler of Array.from(handlers.get(eventName) || [])) {
|
||||
handler(message);
|
||||
}
|
||||
},
|
||||
handlerCount(eventName) {
|
||||
return handlers.get(eventName)?.size || 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("timeout listener ignores stale responses and resolves matching response", () => {
|
||||
const wsClient = makeWsClient();
|
||||
const seen = [];
|
||||
|
||||
listenOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
"UrgentClearResult",
|
||||
1000,
|
||||
(message) => message.request_id === 2,
|
||||
(message) => seen.push(message.ok),
|
||||
() => seen.push("timeout"),
|
||||
);
|
||||
|
||||
wsClient.emit("UrgentClearResult", { request_id: 1, ok: false });
|
||||
assert.deepEqual(seen, []);
|
||||
assert.equal(wsClient.handlerCount("UrgentClearResult"), 1);
|
||||
|
||||
wsClient.emit("UrgentClearResult", { request_id: 2, ok: true });
|
||||
assert.deepEqual(seen, [true]);
|
||||
assert.equal(wsClient.handlerCount("UrgentClearResult"), 0);
|
||||
});
|
||||
|
||||
test("timeout listener disposer removes pending handler", () => {
|
||||
const wsClient = makeWsClient();
|
||||
const seen = [];
|
||||
|
||||
const dispose = listenOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
"UrgentList",
|
||||
1000,
|
||||
() => true,
|
||||
() => seen.push("response"),
|
||||
() => seen.push("timeout"),
|
||||
);
|
||||
|
||||
assert.equal(wsClient.handlerCount("UrgentList"), 1);
|
||||
dispose();
|
||||
wsClient.emit("UrgentList", { request_id: 1 });
|
||||
|
||||
assert.deepEqual(seen, []);
|
||||
assert.equal(wsClient.handlerCount("UrgentList"), 0);
|
||||
});
|
||||
|
||||
test("timeout listener fires timeout and removes pending handler", async () => {
|
||||
const wsClient = makeWsClient();
|
||||
const seen = [];
|
||||
|
||||
listenOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
"UrgentList",
|
||||
5,
|
||||
() => false,
|
||||
() => seen.push("response"),
|
||||
() => seen.push("timeout"),
|
||||
);
|
||||
|
||||
assert.equal(wsClient.handlerCount("UrgentList"), 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(seen, ["timeout"]);
|
||||
assert.equal(wsClient.handlerCount("UrgentList"), 0);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {clearDiv} from "./helpers/builders";
|
||||
import {initRedact} from "./helpers/redact";
|
||||
import {initDayNightMode} from "./helpers/dark_mode";
|
||||
import {initColorBlind} from "./helpers/colorblind";
|
||||
import {listenOnceMatchingWithTimeout as listenWsOnceMatchingWithTimeout} from "./pubsub/listeners";
|
||||
import {get_ws_client} from "./pubsub/ws";
|
||||
|
||||
const wsClient = get_ws_client();
|
||||
@@ -29,6 +30,7 @@ function escapeHtml(text) {
|
||||
|
||||
const SCHEDULER_STATUS_POLL_MS = 2000;
|
||||
const SCHEDULER_STATUS_TIMEOUT_MS = 2500;
|
||||
const URGENT_ISSUES_TIMEOUT_MS = 2500;
|
||||
const SCHEDULER_STATUS_STARTING_GRACE_MS = 30000;
|
||||
const SCHEDULER_MODAL_IDLE_POLL_MS = 3000;
|
||||
const SCHEDULER_MODAL_ACTIVE_POLL_MS = 1000;
|
||||
@@ -42,24 +44,6 @@ let schedulerModalInstance = null;
|
||||
let cachedSchedulerDetailsData = null;
|
||||
let cachedQueueModeConfig = null;
|
||||
|
||||
function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) {
|
||||
let done = false;
|
||||
const wrapped = (msg) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
wsClient.off(eventName, wrapped);
|
||||
handler(msg);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
wsClient.off(eventName, wrapped);
|
||||
onTimeout();
|
||||
}, timeoutMs);
|
||||
wsClient.on(eventName, wrapped);
|
||||
}
|
||||
|
||||
function clampSchedulerPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
@@ -526,7 +510,7 @@ function loadSchedulerStatus(force = false) {
|
||||
}
|
||||
|
||||
schedulerStatusRequestInFlight = true;
|
||||
listenOnceWithTimeout("SchedulerStatus", SCHEDULER_STATUS_TIMEOUT_MS, (msg) => {
|
||||
listenWsOnceMatchingWithTimeout(wsClient, "SchedulerStatus", SCHEDULER_STATUS_TIMEOUT_MS, () => true, (msg) => {
|
||||
schedulerStatusRequestInFlight = false;
|
||||
if (!msg || !msg.data) {
|
||||
if (cachedSchedulerStatusData) {
|
||||
@@ -685,7 +669,7 @@ function refreshSchedulerModalDetails(showLoading = false) {
|
||||
renderSchedulerModalLoading();
|
||||
}
|
||||
schedulerModalRequestInFlight = true;
|
||||
listenOnceWithTimeout("SchedulerDetails", SCHEDULER_STATUS_TIMEOUT_MS, (msg) => {
|
||||
listenWsOnceMatchingWithTimeout(wsClient, "SchedulerDetails", SCHEDULER_STATUS_TIMEOUT_MS, () => true, (msg) => {
|
||||
schedulerModalRequestInFlight = false;
|
||||
if (!isSchedulerModalOpen()) {
|
||||
stopSchedulerModalPolling();
|
||||
@@ -1108,6 +1092,15 @@ function initUrgentIssues() {
|
||||
const containerId = 'urgentStatus';
|
||||
const linkId = 'urgentStatusLink';
|
||||
const badgeId = 'urgentBadge';
|
||||
let urgentClearState = null;
|
||||
let urgentStatusPending = false;
|
||||
let urgentStatusRefreshQueued = false;
|
||||
let urgentListPending = false;
|
||||
let urgentListActiveRequestId = null;
|
||||
let urgentListCancel = null;
|
||||
let urgentRequestId = 0;
|
||||
let pendingUrgentActionError = null;
|
||||
let pendingUrgentRetryMessage = null;
|
||||
|
||||
function ensurePlaceholder() {
|
||||
return document.getElementById(containerId) !== null;
|
||||
@@ -1131,38 +1124,251 @@ function initUrgentIssues() {
|
||||
|
||||
function poll() {
|
||||
if (!ensurePlaceholder()) return;
|
||||
listenOnce("UrgentStatus", (msg) => {
|
||||
const count = msg && msg.data ? msg.data.count : 0;
|
||||
renderStatus(count || 0);
|
||||
if (urgentStatusPending) {
|
||||
urgentStatusRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
urgentStatusPending = true;
|
||||
const requestId = ++urgentRequestId;
|
||||
listenWsOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
"UrgentStatus",
|
||||
URGENT_ISSUES_TIMEOUT_MS,
|
||||
(msg) => msg.request_id === requestId,
|
||||
(msg) => {
|
||||
urgentStatusPending = false;
|
||||
const count = msg && msg.data ? msg.data.count : 0;
|
||||
renderStatus(count || 0);
|
||||
runQueuedUrgentStatusRefresh();
|
||||
},
|
||||
() => {
|
||||
urgentStatusPending = false;
|
||||
runQueuedUrgentStatusRefresh();
|
||||
}
|
||||
);
|
||||
wsClient.send({ UrgentStatus: { request_id: requestId } });
|
||||
}
|
||||
|
||||
function runQueuedUrgentStatusRefresh() {
|
||||
if (!urgentStatusRefreshQueued) return false;
|
||||
urgentStatusRefreshQueued = false;
|
||||
poll();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearActionInFlight() {
|
||||
return urgentClearState !== null;
|
||||
}
|
||||
|
||||
function clearActionBlocked() {
|
||||
return clearActionInFlight() || urgentListPending;
|
||||
}
|
||||
|
||||
function setClearControlsDisabled(disabled) {
|
||||
document.querySelectorAll('.urgent-clear').forEach((button) => {
|
||||
button.disabled = disabled;
|
||||
});
|
||||
wsClient.send({ UrgentStatus: {} });
|
||||
const clearAllButton = document.getElementById('urgentClearAll');
|
||||
if (clearAllButton) {
|
||||
clearAllButton.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
function showUrgentBusyState(message) {
|
||||
const holder = document.getElementById('urgentListContainer');
|
||||
if (!holder || !urgentModalIsOpen()) return;
|
||||
holder.setAttribute('aria-busy', 'true');
|
||||
holder.querySelector('.urgent-busy-status')?.remove();
|
||||
holder.insertAdjacentHTML('afterbegin', `
|
||||
<div class="alert alert-info mb-2 urgent-busy-status" role="status" aria-live="polite" tabindex="-1">
|
||||
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
${escapeHtml(message)}
|
||||
</div>`);
|
||||
holder.querySelector('.urgent-busy-status')?.focus();
|
||||
}
|
||||
|
||||
function clearUrgentBusyState(holder) {
|
||||
const target = holder || document.getElementById('urgentListContainer');
|
||||
if (!target) return;
|
||||
target.removeAttribute('aria-busy');
|
||||
target.querySelector('.urgent-busy-status')?.remove();
|
||||
}
|
||||
|
||||
function renderUrgentWarning(holder, message, { focus = false, retry = false } = {}) {
|
||||
if (!holder) return;
|
||||
holder.querySelector('.urgent-action-error')?.remove();
|
||||
const retryButton = retry
|
||||
? '<button type="button" class="btn btn-link btn-sm urgent-refresh">Retry</button>'
|
||||
: '';
|
||||
holder.insertAdjacentHTML('afterbegin', `
|
||||
<div class="alert alert-warning mb-2 urgent-action-error" role="alert" tabindex="-1">
|
||||
${escapeHtml(message)}
|
||||
${retryButton}
|
||||
</div>`);
|
||||
if (focus) {
|
||||
holder.querySelector(retry ? '.urgent-refresh' : '.urgent-action-error')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showUrgentActionError(message) {
|
||||
pendingUrgentActionError = message;
|
||||
if (!urgentModalIsOpen()) return;
|
||||
const holder = document.getElementById('urgentListContainer');
|
||||
if (!holder) return;
|
||||
clearUrgentBusyState(holder);
|
||||
renderUrgentWarning(holder, message, { focus: true });
|
||||
pendingUrgentActionError = null;
|
||||
}
|
||||
|
||||
function attachPendingUrgentActionError(holder) {
|
||||
if (!pendingUrgentActionError || !urgentModalIsOpen() || !holder) return;
|
||||
renderUrgentWarning(holder, pendingUrgentActionError);
|
||||
pendingUrgentActionError = null;
|
||||
}
|
||||
|
||||
function showUrgentRetryState(message) {
|
||||
pendingUrgentRetryMessage = message;
|
||||
const holder = document.getElementById('urgentListContainer');
|
||||
if (!holder || !urgentModalIsOpen()) return;
|
||||
pendingUrgentRetryMessage = null;
|
||||
setClearControlsDisabled(true);
|
||||
clearUrgentBusyState(holder);
|
||||
holder.innerHTML = '';
|
||||
renderUrgentWarning(holder, message, { focus: true, retry: true });
|
||||
$(holder).off('click').on('click', '.urgent-refresh', function (e) {
|
||||
e.preventDefault();
|
||||
showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function showPendingUrgentRetryState() {
|
||||
if (!pendingUrgentRetryMessage || !urgentModalIsOpen()) return false;
|
||||
showUrgentRetryState(pendingUrgentRetryMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
function urgentModalIsOpen() {
|
||||
return document.getElementById('urgentModal')?.classList.contains('show') === true;
|
||||
}
|
||||
|
||||
function focusUrgentModalFallback() {
|
||||
const modalEl = document.getElementById('urgentModal');
|
||||
if (!modalEl || !urgentModalIsOpen()) return;
|
||||
modalEl
|
||||
.querySelector('.urgent-clear:not(:disabled), #urgentClearAll:not(:disabled), .btn-close:not(:disabled), [data-bs-dismiss="modal"]:not(:disabled)')
|
||||
?.focus();
|
||||
}
|
||||
|
||||
function refreshUrgentIssues() {
|
||||
showModal();
|
||||
poll();
|
||||
}
|
||||
|
||||
function completeClearAction() {
|
||||
urgentClearState = null;
|
||||
setClearControlsDisabled(clearActionInFlight());
|
||||
refreshUrgentIssues();
|
||||
}
|
||||
|
||||
function startUrgentClear({
|
||||
responseEvent,
|
||||
requestPayload,
|
||||
busyMessage,
|
||||
failureMessage,
|
||||
timeoutMessage,
|
||||
}) {
|
||||
if (clearActionBlocked()) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++urgentRequestId;
|
||||
setClearControlsDisabled(true);
|
||||
showUrgentBusyState(busyMessage);
|
||||
urgentClearState = {
|
||||
busyMessage,
|
||||
cancel: null,
|
||||
};
|
||||
urgentClearState.cancel = listenWsOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
responseEvent,
|
||||
URGENT_ISSUES_TIMEOUT_MS,
|
||||
(msg) => msg.request_id === requestId,
|
||||
(msg) => {
|
||||
urgentClearState = null;
|
||||
if (msg && msg.ok === false) {
|
||||
setClearControlsDisabled(false);
|
||||
showUrgentActionError(failureMessage);
|
||||
return;
|
||||
}
|
||||
completeClearAction();
|
||||
},
|
||||
() => {
|
||||
urgentClearState = null;
|
||||
setClearControlsDisabled(false);
|
||||
showUrgentActionError(timeoutMessage);
|
||||
}
|
||||
);
|
||||
wsClient.send(requestPayload(requestId));
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
const modalEl = document.getElementById('urgentModal');
|
||||
if (!modalEl) return;
|
||||
new bootstrap.Modal(modalEl, { focus: true }).show();
|
||||
if (!modalEl) {
|
||||
return;
|
||||
}
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl, { focus: true }).show();
|
||||
const holder = document.getElementById('urgentListContainer');
|
||||
if (!holder) return;
|
||||
holder.innerHTML = `<div class="text-center text-muted"><i class='fa fa-spinner fa-spin'></i> Loading...</div>`;
|
||||
listenOnce("UrgentList", (msg) => {
|
||||
const items = msg && msg.data ? msg.data.items || [] : [];
|
||||
if (items.length === 0) {
|
||||
holder.innerHTML = '<div class="text-center text-success">No urgent issues.</div>';
|
||||
return;
|
||||
}
|
||||
const table = document.createElement('table');
|
||||
table.className = 'lqos-table lqos-table-compact mb-0';
|
||||
const tbody = document.createElement('tbody');
|
||||
items.forEach((it) => {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
const when = new Date(it.ts * 1000).toLocaleString();
|
||||
const sev = it.severity === 'Error' ? 'danger' : 'warning';
|
||||
const clearControl = it.clearable === false
|
||||
? '<span class="badge bg-secondary-subtle text-secondary border float-end ms-3">Active</span>'
|
||||
: `<button type="button" class="btn btn-link btn-sm text-secondary float-end ms-3 p-0 urgent-clear" data-id="${it.id}" title="Acknowledge issue" aria-label="Acknowledge issue ${escapeAttr(it.code)}"><i class="fa fa-times" aria-hidden="true"></i></button>`;
|
||||
td.innerHTML = `
|
||||
if (!holder) {
|
||||
return;
|
||||
}
|
||||
if (showPendingUrgentRetryState()) {
|
||||
return;
|
||||
}
|
||||
if (clearActionInFlight()) {
|
||||
setClearControlsDisabled(true);
|
||||
showUrgentBusyState(urgentClearState.busyMessage);
|
||||
return;
|
||||
}
|
||||
if (urgentListPending) {
|
||||
setClearControlsDisabled(true);
|
||||
return;
|
||||
}
|
||||
urgentListPending = true;
|
||||
const requestId = ++urgentRequestId;
|
||||
urgentListActiveRequestId = requestId;
|
||||
setClearControlsDisabled(true);
|
||||
clearUrgentBusyState(holder);
|
||||
holder.innerHTML = `<div class="text-center text-muted urgent-loading" role="status" aria-live="polite" tabindex="-1"><i class='fa fa-spinner fa-spin'></i> Loading...</div>`;
|
||||
holder.querySelector('.urgent-loading')?.focus();
|
||||
urgentListCancel = listenWsOnceMatchingWithTimeout(
|
||||
wsClient,
|
||||
"UrgentList",
|
||||
URGENT_ISSUES_TIMEOUT_MS,
|
||||
(msg) => msg.request_id === requestId && urgentListActiveRequestId === requestId,
|
||||
(msg) => {
|
||||
urgentListPending = false;
|
||||
urgentListActiveRequestId = null;
|
||||
urgentListCancel = null;
|
||||
const items = msg && msg.data ? msg.data.items || [] : [];
|
||||
clearUrgentBusyState(holder);
|
||||
if (items.length === 0) {
|
||||
holder.innerHTML = '<div class="text-center text-success" role="status" aria-live="polite" tabindex="-1">No urgent issues.</div>';
|
||||
attachPendingUrgentActionError(holder);
|
||||
setClearControlsDisabled(true);
|
||||
holder.querySelector('[role="status"]')?.focus();
|
||||
return;
|
||||
}
|
||||
const table = document.createElement('table');
|
||||
table.className = 'lqos-table lqos-table-compact mb-0';
|
||||
const tbody = document.createElement('tbody');
|
||||
items.forEach((it) => {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
const when = new Date(it.ts * 1000).toLocaleString();
|
||||
const sev = it.severity === 'Error' ? 'danger' : 'warning';
|
||||
const clearControl = it.clearable === false
|
||||
? '<span class="badge bg-secondary-subtle text-secondary border float-end ms-3">Active</span>'
|
||||
: `<button type="button" class="btn btn-link btn-sm text-secondary float-end ms-3 p-0 urgent-clear" data-id="${it.id}" title="Acknowledge issue" aria-label="Acknowledge issue ${escapeAttr(it.code)}"><i class="fa fa-times" aria-hidden="true"></i></button>`;
|
||||
td.innerHTML = `
|
||||
<div>
|
||||
<span class="badge bg-${sev}">${it.severity}</span>
|
||||
<strong class="ms-2">${it.code}</strong>
|
||||
@@ -1173,26 +1379,44 @@ function initUrgentIssues() {
|
||||
<div class="mt-1" style="white-space: pre-wrap;">${it.message}</div>
|
||||
${it.context ? `<pre class="mt-2">${it.context}</pre>` : ''}
|
||||
`;
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
holder.innerHTML = '';
|
||||
const tableWrap = document.createElement('div');
|
||||
tableWrap.className = 'table-responsive lqos-table-wrap';
|
||||
tableWrap.appendChild(table);
|
||||
holder.appendChild(tableWrap);
|
||||
$(holder).off('click').on('click', '.urgent-clear', function (e) {
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
listenOnce("UrgentClearResult", () => {
|
||||
showModal();
|
||||
poll();
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
wsClient.send({ UrgentClear: { id } });
|
||||
});
|
||||
});
|
||||
wsClient.send({ UrgentList: {} });
|
||||
table.appendChild(tbody);
|
||||
holder.innerHTML = '';
|
||||
const tableWrap = document.createElement('div');
|
||||
tableWrap.className = 'table-responsive lqos-table-wrap';
|
||||
tableWrap.appendChild(table);
|
||||
holder.appendChild(tableWrap);
|
||||
attachPendingUrgentActionError(holder);
|
||||
$(holder).off('click').on('click', '.urgent-clear', function (e) {
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
startUrgentClear({
|
||||
responseEvent: 'UrgentClearResult',
|
||||
requestPayload: (requestId) => ({ UrgentClear: { id, request_id: requestId } }),
|
||||
busyMessage: 'Acknowledging urgent issue...',
|
||||
failureMessage: 'Unable to acknowledge urgent issue.',
|
||||
timeoutMessage: 'Timed out while acknowledging urgent issue.',
|
||||
});
|
||||
});
|
||||
setClearControlsDisabled(clearActionInFlight());
|
||||
focusUrgentModalFallback();
|
||||
},
|
||||
() => {
|
||||
if (urgentListActiveRequestId !== requestId) return;
|
||||
urgentListPending = false;
|
||||
urgentListActiveRequestId = null;
|
||||
urgentListCancel = null;
|
||||
if (!urgentModalIsOpen()) {
|
||||
setClearControlsDisabled(clearActionInFlight());
|
||||
return;
|
||||
}
|
||||
showUrgentRetryState('Unable to refresh urgent issues.');
|
||||
setClearControlsDisabled(true);
|
||||
}
|
||||
);
|
||||
wsClient.send({ UrgentList: { request_id: requestId } });
|
||||
}
|
||||
|
||||
if (!document.getElementById(containerId)) {
|
||||
@@ -1206,11 +1430,31 @@ function initUrgentIssues() {
|
||||
}
|
||||
|
||||
$(document).off('click', '#urgentClearAll').on('click', '#urgentClearAll', () => {
|
||||
listenOnce("UrgentClearAllResult", () => {
|
||||
showModal();
|
||||
poll();
|
||||
startUrgentClear({
|
||||
responseEvent: 'UrgentClearAllResult',
|
||||
requestPayload: (requestId) => ({ UrgentClearAll: { request_id: requestId } }),
|
||||
busyMessage: 'Acknowledging urgent issues...',
|
||||
failureMessage: 'Unable to acknowledge urgent issues.',
|
||||
timeoutMessage: 'Timed out while acknowledging urgent issues.',
|
||||
});
|
||||
wsClient.send({ UrgentClearAll: {} });
|
||||
});
|
||||
|
||||
$(document).off('hidden.bs.modal', '#urgentModal').on('hidden.bs.modal', '#urgentModal', () => {
|
||||
pendingUrgentRetryMessage = null;
|
||||
if (urgentClearState?.cancel) {
|
||||
urgentClearState.cancel();
|
||||
urgentClearState = null;
|
||||
}
|
||||
if (urgentListPending) {
|
||||
if (urgentListCancel) {
|
||||
urgentListCancel();
|
||||
urgentListCancel = null;
|
||||
}
|
||||
urgentListPending = false;
|
||||
urgentListActiveRequestId = null;
|
||||
}
|
||||
clearUrgentBusyState();
|
||||
setClearControlsDisabled(clearActionInFlight());
|
||||
});
|
||||
|
||||
poll();
|
||||
|
||||
@@ -9,7 +9,10 @@ if ! command -v node >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
echo "[test-node-manager] Running frontend contract tests..."
|
||||
node --test "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs"
|
||||
node --test \
|
||||
"${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/pubsub/ws.test.mjs"
|
||||
|
||||
echo "[test-node-manager] Building bundles..."
|
||||
"${SCRIPT_DIR}/esbuild.sh"
|
||||
|
||||
@@ -786,6 +786,7 @@ fn persist_shaped_devices(mut devices: Vec<ShapedDevice>) -> Result<(), String>
|
||||
lqos_network_devices::apply_shaped_devices_snapshot(
|
||||
"node_manager:persist_shaped_devices",
|
||||
copied,
|
||||
lqos_network_devices::ShapedDevicesSnapshotProvenance::ExternalSnapshot,
|
||||
)
|
||||
.map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?;
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ fn unauthorized() -> (StatusCode, Json<ApplyResponse>) {
|
||||
}
|
||||
|
||||
fn helper_validation_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
|
||||
warn!("Bridge helper request failed: {message}");
|
||||
warn!("Network-mode request failed: {message}");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApplyResponse {
|
||||
@@ -90,7 +90,7 @@ fn helper_validation_error_response(message: String) -> (StatusCode, Json<ApplyR
|
||||
}
|
||||
|
||||
fn helper_internal_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
|
||||
error!("Bridge helper internal error: {message}");
|
||||
error!("Network-mode internal error: {message}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApplyResponse {
|
||||
@@ -102,11 +102,20 @@ fn helper_internal_error_response(message: String) -> (StatusCode, Json<ApplyRes
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_network_mode(candidate: Config) -> Result<Config, StatusCode> {
|
||||
let live = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum MergeNetworkModeError {
|
||||
LoadLiveConfig,
|
||||
InvalidCandidate(String),
|
||||
}
|
||||
|
||||
fn merge_network_mode(candidate: Config) -> Result<Config, MergeNetworkModeError> {
|
||||
let live = lqos_config::load_config().map_err(|_| MergeNetworkModeError::LoadLiveConfig)?;
|
||||
let mut merged = (*live).clone();
|
||||
merged.bridge = candidate.bridge;
|
||||
merged.single_interface = candidate.single_interface;
|
||||
merged
|
||||
.validate()
|
||||
.map_err(MergeNetworkModeError::InvalidCandidate)?;
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
@@ -156,13 +165,23 @@ pub async fn inspect(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeInspectRequest>,
|
||||
) -> Result<Json<NetworkModeInspection>, StatusCode> {
|
||||
) -> Result<Json<NetworkModeInspection>, (StatusCode, Json<ApplyResponse>)> {
|
||||
if login != LoginResult::Admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
return Err(unauthorized());
|
||||
}
|
||||
|
||||
let merged = merge_network_mode(body.config)?;
|
||||
Ok(Json(run_network_mode_inspection(&state, merged).await?))
|
||||
let merged = merge_network_mode(body.config).map_err(|error| match error {
|
||||
MergeNetworkModeError::InvalidCandidate(message) => {
|
||||
helper_validation_error_response(message)
|
||||
}
|
||||
MergeNetworkModeError::LoadLiveConfig => helper_internal_error_response(
|
||||
"Unable to load the live LibreQoS configuration".to_string(),
|
||||
),
|
||||
})?;
|
||||
run_network_mode_inspection(&state, merged)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|_| helper_internal_error_response("Unable to inspect network mode".to_string()))
|
||||
}
|
||||
|
||||
pub async fn apply(
|
||||
@@ -178,16 +197,17 @@ pub async fn apply(
|
||||
let username = get_username(&jar).await;
|
||||
let merged = match merge_network_mode(body.config) {
|
||||
Ok(config) => config,
|
||||
Err(status) => {
|
||||
return (
|
||||
status,
|
||||
Json(ApplyResponse {
|
||||
ok: false,
|
||||
message: "Unable to load the live LibreQoS configuration".to_string(),
|
||||
operation: None,
|
||||
last_backup_id: None,
|
||||
}),
|
||||
);
|
||||
Err(error) => {
|
||||
return match error {
|
||||
MergeNetworkModeError::InvalidCandidate(message) => {
|
||||
helper_validation_error_response(message)
|
||||
}
|
||||
MergeNetworkModeError::LoadLiveConfig => {
|
||||
helper_internal_error_response(
|
||||
"Unable to load the live LibreQoS configuration".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let result = tokio::task::spawn_blocking({
|
||||
@@ -212,7 +232,9 @@ pub async fn apply(
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
Err(err) => {
|
||||
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +260,9 @@ pub async fn confirm(
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
Err(err) => {
|
||||
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +288,9 @@ pub async fn revert(
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
Err(err) => {
|
||||
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +316,9 @@ pub async fn rollback(
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
Err(err) => {
|
||||
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +342,88 @@ pub async fn retry_shaping(
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_internal_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
Err(err) => {
|
||||
helper_internal_error_response(format!("Network-mode helper task failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MergeNetworkModeError, merge_network_mode};
|
||||
use lqos_config::{BridgeConfig, Config, SingleInterfaceConfig};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn with_config_env<T>(test_fn: impl FnOnce() -> T) -> T {
|
||||
let _guard = env_lock().lock().expect("network-mode env lock");
|
||||
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
|
||||
let config_path = std::env::temp_dir().join(format!(
|
||||
"lqosd-network-mode-test-{}.toml",
|
||||
std::process::id()
|
||||
));
|
||||
let raw = include_str!("../../../../lqos_config/src/etc/v15/example.toml");
|
||||
std::fs::write(&config_path, raw).expect("write config");
|
||||
lqos_config::clear_cached_config();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("LQOS_CONFIG", &config_path);
|
||||
}
|
||||
let result = test_fn();
|
||||
|
||||
match old_lqos_config {
|
||||
Some(value) => unsafe {
|
||||
std::env::set_var("LQOS_CONFIG", value);
|
||||
},
|
||||
None => unsafe {
|
||||
std::env::remove_var("LQOS_CONFIG");
|
||||
},
|
||||
}
|
||||
lqos_config::clear_cached_config();
|
||||
let _ = std::fs::remove_file(config_path);
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_network_mode_rejects_invalid_bridge_mtu() {
|
||||
let mut candidate = Config::default();
|
||||
candidate.bridge = Some(BridgeConfig {
|
||||
mtu: Some(9217),
|
||||
..BridgeConfig::default()
|
||||
});
|
||||
|
||||
let error =
|
||||
with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail"));
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
MergeNetworkModeError::InvalidCandidate(
|
||||
"bridge.mtu must be between 576 and 9216".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_network_mode_rejects_invalid_single_interface_mtu() {
|
||||
let mut candidate = Config::default();
|
||||
candidate.bridge = None;
|
||||
candidate.single_interface = Some(SingleInterfaceConfig {
|
||||
mtu: Some(575),
|
||||
..SingleInterfaceConfig::default()
|
||||
});
|
||||
|
||||
let error =
|
||||
with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail"));
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
MergeNetworkModeError::InvalidCandidate(
|
||||
"single_interface.mtu must be between 576 and 9216".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,12 @@
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-section">
|
||||
<h6 class="lqos-config-section-title">Bridge Mode Configuration</h6>
|
||||
<div class="lqos-config-section-subtitle">Choose the two shaping interfaces for the managed Linux bridge flow, or leave XDP enabled if you are intentionally handling Netplan outside this page.</div>
|
||||
<div class="lqos-config-section-subtitle">Choose the two shaping interfaces. Linux bridge mode can manage Netplan; XDP mode leaves Netplan alone.</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="useXdpBridge">
|
||||
<input type="checkbox" class="form-check-input" id="useXdpBridge" aria-describedby="useXdpBridgeHelp">
|
||||
<label class="form-check-label" for="useXdpBridge">Use XDP Bridge</label>
|
||||
<div class="form-text">Use the XDP bridge instead of the Linux Bridge. This remains a manual/power-user netplan workflow.</div>
|
||||
<div id="useXdpBridgeHelp" class="form-text">XDP does not write Netplan or apply Bridge MTU.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -78,6 +78,13 @@
|
||||
</select>
|
||||
<div class="form-text">Pick a different eligible interface for the LAN side.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="bridgeMtu" class="form-label">Bridge MTU</label>
|
||||
<input type="number" class="form-control" id="bridgeMtu" aria-describedby="bridgeMtuHelp bridgeMtuError">
|
||||
<div id="bridgeMtuHelp" class="form-text">Applies to the bridge members and br0.</div>
|
||||
<div id="bridgeMtuError" class="invalid-feedback" role="alert"></div>
|
||||
</div>
|
||||
<div id="bridgeInterfaceHelp" class="small text-secondary"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -87,7 +94,7 @@
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-section">
|
||||
<h6 class="lqos-config-section-title">Single Interface Mode Configuration</h6>
|
||||
<div class="lqos-config-section-subtitle">Pick the trunk interface and define the WAN and LAN VLAN IDs that LibreQoS should prepare in managed Netplan.</div>
|
||||
<div class="lqos-config-section-subtitle">Pick the trunk interface and VLAN IDs for managed Netplan.</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="interface" class="form-label">Interface Name</label>
|
||||
@@ -108,6 +115,13 @@
|
||||
<input type="number" class="form-control" id="networkVlan" min="1" max="4094">
|
||||
<div class="form-text">VLAN ID for LAN traffic.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0 mt-3">
|
||||
<label for="singleInterfaceMtu" class="form-label">Single Interface MTU</label>
|
||||
<input type="number" class="form-control" id="singleInterfaceMtu" aria-describedby="singleInterfaceMtuHelp singleInterfaceMtuError">
|
||||
<div id="singleInterfaceMtuHelp" class="form-text">Applies to the trunk interface.</div>
|
||||
<div id="singleInterfaceMtuError" class="invalid-feedback" role="alert"></div>
|
||||
</div>
|
||||
<div id="singleInterfaceHelp" class="small text-secondary mt-2"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -58,6 +58,7 @@ mod ticker;
|
||||
const WS_VERSION: &str = include_str!("../../../../VERSION_STRING");
|
||||
const HANDSHAKE_TIMEOUT_SECS: u64 = 10;
|
||||
const CONTROL_CHANNEL_SEND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
async fn send_control_command(
|
||||
control_tx: &tokio::sync::mpsc::Sender<ControlChannelCommand>,
|
||||
@@ -199,19 +200,29 @@ async fn handle_socket(
|
||||
// Received a websocket message
|
||||
match inbound {
|
||||
Some(Ok(msg)) => {
|
||||
let should_close = receive_channel_message(
|
||||
msg,
|
||||
channels.clone(),
|
||||
tx.clone(),
|
||||
&mut subscribed_channels,
|
||||
&mut handshake_complete,
|
||||
&mut WsRequestState {
|
||||
private_state: &mut private_state,
|
||||
login: &mut login,
|
||||
shaper_query: shaper_query.clone(),
|
||||
},
|
||||
let should_close = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS),
|
||||
receive_channel_message(
|
||||
msg,
|
||||
channels.clone(),
|
||||
tx.clone(),
|
||||
&mut subscribed_channels,
|
||||
&mut handshake_complete,
|
||||
&mut WsRequestState {
|
||||
private_state: &mut private_state,
|
||||
login: &mut login,
|
||||
shaper_query: shaper_query.clone(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
Ok(should_close) => should_close,
|
||||
Err(_) => {
|
||||
warn!("Websocket request timed out; closing connection");
|
||||
true
|
||||
}
|
||||
};
|
||||
if should_close {
|
||||
break;
|
||||
}
|
||||
@@ -229,6 +240,11 @@ async fn handle_socket(
|
||||
}
|
||||
}
|
||||
}
|
||||
for channel in subscribed_channels.drain() {
|
||||
channels.unsubscribe(channel, tx.clone()).await;
|
||||
}
|
||||
drop(private_state);
|
||||
drop(tx);
|
||||
outbound_handle.abort();
|
||||
let _ = outbound_handle.await;
|
||||
info!("Websocket disconnected");
|
||||
@@ -677,32 +693,37 @@ async fn receive_channel_message(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::UrgentStatus => {
|
||||
WsRequest::UrgentStatus { request_id } => {
|
||||
let response = WsResponse::UrgentStatus {
|
||||
data: urgent::urgent_status_data(),
|
||||
request_id,
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::UrgentList => {
|
||||
WsRequest::UrgentList { request_id } => {
|
||||
let response = WsResponse::UrgentList {
|
||||
data: urgent::urgent_list_data(),
|
||||
request_id,
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::UrgentClear { id } => {
|
||||
WsRequest::UrgentClear { id, request_id } => {
|
||||
let ok = urgent::urgent_clear_id(id);
|
||||
let response = WsResponse::UrgentClearResult { ok };
|
||||
let response = WsResponse::UrgentClearResult { ok, request_id };
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::UrgentClearAll => {
|
||||
WsRequest::UrgentClearAll { request_id } => {
|
||||
urgent::urgent_clear_all_data();
|
||||
let response = WsResponse::UrgentClearAllResult { ok: true };
|
||||
let response = WsResponse::UrgentClearAllResult {
|
||||
ok: true,
|
||||
request_id,
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
@@ -2505,7 +2526,9 @@ fn payload_hint(payload: &[u8]) -> &'static str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decode_ws_request;
|
||||
use crate::node_manager::local_api::urgent::{UrgentList, UrgentStatus};
|
||||
use crate::node_manager::ws::messages::WsRequest;
|
||||
use crate::node_manager::ws::messages::{WsResponse, encode_ws_message};
|
||||
use serde_cbor::Value as CborValue;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -2570,6 +2593,163 @@ mod tests {
|
||||
.expect("valid update payload")
|
||||
}
|
||||
|
||||
fn encode_response_value(response: &WsResponse) -> CborValue {
|
||||
let payload = encode_ws_message(response).expect("response should encode");
|
||||
serde_cbor::from_slice(payload.as_ref()).expect("response should decode as CBOR value")
|
||||
}
|
||||
|
||||
fn response_request_id(response: &WsResponse, event_name: &str) -> Option<u64> {
|
||||
let CborValue::Map(entries) = encode_response_value(response) else {
|
||||
panic!("response should encode as a CBOR map");
|
||||
};
|
||||
let event_key = text("event");
|
||||
let request_id_key = text("request_id");
|
||||
assert_eq!(entries.get(&event_key), Some(&text(event_name)));
|
||||
match entries.get(&request_id_key) {
|
||||
Some(CborValue::Integer(value)) => {
|
||||
Some(u64::try_from(*value).expect("request_id is u64"))
|
||||
}
|
||||
Some(other) => panic!("unexpected request_id value: {other:?}"),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn response_has_request_id(response: &WsResponse) -> bool {
|
||||
let CborValue::Map(entries) = encode_response_value(response) else {
|
||||
panic!("response should encode as a CBOR map");
|
||||
};
|
||||
entries.contains_key(&text("request_id"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_urgent_status_with_request_id() {
|
||||
let payload = serde_cbor::to_vec(&map(vec![(
|
||||
"UrgentStatus",
|
||||
map(vec![("request_id", integer(42))]),
|
||||
)]))
|
||||
.expect("valid urgent status payload");
|
||||
|
||||
let request = decode_ws_request(&payload).expect("urgent status request should decode");
|
||||
match request {
|
||||
WsRequest::UrgentStatus { request_id } => assert_eq!(request_id, Some(42)),
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_urgent_list_without_request_id() {
|
||||
let payload = serde_cbor::to_vec(&map(vec![("UrgentList", map(vec![]))]))
|
||||
.expect("valid urgent list payload");
|
||||
|
||||
let request = decode_ws_request(&payload).expect("urgent list request should decode");
|
||||
match request {
|
||||
WsRequest::UrgentList { request_id } => assert_eq!(request_id, None),
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_urgent_clear_with_request_id() {
|
||||
let payload = serde_cbor::to_vec(&map(vec![(
|
||||
"UrgentClear",
|
||||
map(vec![("id", integer(7)), ("request_id", integer(42))]),
|
||||
)]))
|
||||
.expect("valid urgent clear payload");
|
||||
|
||||
let request = decode_ws_request(&payload).expect("urgent clear request should decode");
|
||||
match request {
|
||||
WsRequest::UrgentClear { id, request_id } => {
|
||||
assert_eq!(id, 7);
|
||||
assert_eq!(request_id, Some(42));
|
||||
}
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_urgent_clear_all_without_request_id() {
|
||||
let payload = serde_cbor::to_vec(&map(vec![("UrgentClearAll", map(vec![]))]))
|
||||
.expect("valid urgent clear all payload");
|
||||
|
||||
let request = decode_ws_request(&payload).expect("urgent clear all request should decode");
|
||||
match request {
|
||||
WsRequest::UrgentClearAll { request_id } => assert_eq!(request_id, None),
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urgent_responses_echo_request_id() {
|
||||
assert_eq!(
|
||||
response_request_id(
|
||||
&WsResponse::UrgentStatus {
|
||||
data: UrgentStatus {
|
||||
has_urgent: true,
|
||||
count: 1,
|
||||
},
|
||||
request_id: Some(42),
|
||||
},
|
||||
"UrgentStatus",
|
||||
),
|
||||
Some(42),
|
||||
);
|
||||
assert_eq!(
|
||||
response_request_id(
|
||||
&WsResponse::UrgentList {
|
||||
data: UrgentList { items: Vec::new() },
|
||||
request_id: Some(43),
|
||||
},
|
||||
"UrgentList",
|
||||
),
|
||||
Some(43),
|
||||
);
|
||||
assert_eq!(
|
||||
response_request_id(
|
||||
&WsResponse::UrgentClearResult {
|
||||
ok: true,
|
||||
request_id: Some(44),
|
||||
},
|
||||
"UrgentClearResult",
|
||||
),
|
||||
Some(44),
|
||||
);
|
||||
assert_eq!(
|
||||
response_request_id(
|
||||
&WsResponse::UrgentClearAllResult {
|
||||
ok: true,
|
||||
request_id: Some(45),
|
||||
},
|
||||
"UrgentClearAllResult",
|
||||
),
|
||||
Some(45),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urgent_responses_omit_missing_request_id() {
|
||||
assert!(!response_has_request_id(&WsResponse::UrgentStatus {
|
||||
data: UrgentStatus {
|
||||
has_urgent: false,
|
||||
count: 0,
|
||||
},
|
||||
request_id: None,
|
||||
}));
|
||||
assert!(!response_has_request_id(&WsResponse::UrgentList {
|
||||
data: UrgentList { items: Vec::new() },
|
||||
request_id: None,
|
||||
}));
|
||||
assert!(!response_has_request_id(&WsResponse::UrgentClearResult {
|
||||
ok: true,
|
||||
request_id: None,
|
||||
}));
|
||||
assert!(!response_has_request_id(
|
||||
&WsResponse::UrgentClearAllResult {
|
||||
ok: true,
|
||||
request_id: None,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_create_shaped_device_with_browser_style_ipv4_bytes() {
|
||||
let payload = create_shaped_device_payload(device_value(
|
||||
|
||||
@@ -324,12 +324,19 @@ pub enum WsRequest {
|
||||
ProtocolFlowTimeline {
|
||||
protocol: String,
|
||||
},
|
||||
UrgentStatus,
|
||||
UrgentList,
|
||||
UrgentStatus {
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentList {
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentClear {
|
||||
id: u64,
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentClearAll {
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentClearAll,
|
||||
UnknownIps,
|
||||
UnknownIpsClear,
|
||||
UnknownIpsCsv,
|
||||
@@ -626,15 +633,23 @@ pub enum WsResponse {
|
||||
},
|
||||
UrgentStatus {
|
||||
data: UrgentStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentList {
|
||||
data: UrgentList,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentClearResult {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UrgentClearAllResult {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<u64>,
|
||||
},
|
||||
UnknownIps {
|
||||
data: Vec<UnknownIp>,
|
||||
|
||||
@@ -4,12 +4,13 @@ use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use fxhash::{FxHashMap, FxHashSet};
|
||||
use lqos_bus::{BusResponse, Circuit};
|
||||
use lqos_config::{
|
||||
NetworkJsonNode, NetworkJsonTransport, TopologyShapingInputsFile, load_config,
|
||||
topology_runtime_status_path,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use lqos_config::{TopologyRuntimeStatusFile, topology_shaping_inputs_path};
|
||||
use lqos_config::load_active_runtime_shaping_inputs;
|
||||
use lqos_config::{
|
||||
NetworkJsonNode, NetworkJsonTransport, TopologyRuntimeShapingPayloadIdentity,
|
||||
TopologyRuntimeStatusFile, TopologyShapingInputsFile,
|
||||
load_active_runtime_shaping_inputs_from_status, load_config, topology_runtime_status_path,
|
||||
};
|
||||
use lqos_queue_tracker::EFFECTIVE_NODE_RATES;
|
||||
use lqos_utils::file_watcher::FileWatcher;
|
||||
use lqos_utils::hash_to_i64;
|
||||
@@ -18,8 +19,6 @@ use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample};
|
||||
use lqos_utils::unix_time::time_since_boot;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
#[cfg(test)]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::Duration;
|
||||
@@ -42,6 +41,8 @@ pub static CIRCUIT_LIVE_LAST_REFRESH_SECS: AtomicU64 = AtomicU64::new(0);
|
||||
pub static CIRCUIT_LIVE_REFRESH_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
pub static EFFECTIVE_CIRCUIT_PARENTS: Lazy<ArcSwap<FxHashMap<String, RuntimeCircuitParent>>> =
|
||||
Lazy::new(|| ArcSwap::new(Arc::new(FxHashMap::default())));
|
||||
static LAST_TOPOLOGY_STATUS_IDENTITY: Lazy<Mutex<Option<TopologyRuntimeShapingPayloadIdentity>>> =
|
||||
Lazy::new(|| Mutex::new(None));
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeCircuitParent {
|
||||
@@ -104,38 +105,18 @@ fn publish_shaping_inputs(shaping_inputs: TopologyShapingInputsFile) {
|
||||
invalidate_executive_cache_snapshot();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn active_runtime_shaping_inputs_path(config: &lqos_config::Config) -> Result<Option<PathBuf>> {
|
||||
let status = TopologyRuntimeStatusFile::load(config)?;
|
||||
if !status.ready {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = status.shaping_inputs_path.trim();
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(PathBuf::from(path)))
|
||||
fn topology_status_identity(
|
||||
status: &TopologyRuntimeStatusFile,
|
||||
) -> TopologyRuntimeShapingPayloadIdentity {
|
||||
status.shaping_payload_identity()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn load_active_runtime_shaping_inputs(
|
||||
config: &lqos_config::Config,
|
||||
) -> Result<Option<TopologyShapingInputsFile>> {
|
||||
if let Some(active_path) = active_runtime_shaping_inputs_path(config)?
|
||||
&& active_path.exists()
|
||||
{
|
||||
let raw = std::fs::read_to_string(&active_path)?;
|
||||
let shaping_inputs = serde_json::from_str(&raw)?;
|
||||
return Ok(Some(shaping_inputs));
|
||||
}
|
||||
fn topology_status_identity_changed(identity: &TopologyRuntimeShapingPayloadIdentity) -> bool {
|
||||
LAST_TOPOLOGY_STATUS_IDENTITY.lock().as_ref() != Some(identity)
|
||||
}
|
||||
|
||||
let fallback_path = topology_shaping_inputs_path(config);
|
||||
if !fallback_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = std::fs::read_to_string(&fallback_path)?;
|
||||
let shaping_inputs = serde_json::from_str(&raw)?;
|
||||
Ok(Some(shaping_inputs))
|
||||
fn remember_topology_status_identity(identity: TopologyRuntimeShapingPayloadIdentity) {
|
||||
*LAST_TOPOLOGY_STATUS_IDENTITY.lock() = Some(identity);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -257,21 +238,8 @@ fn integration_ingress_enabled(config: &lqos_config::Config) -> bool {
|
||||
fn load_ready_runtime_shaping_inputs(
|
||||
config: &lqos_config::Config,
|
||||
) -> Result<Option<TopologyShapingInputsFile>> {
|
||||
let Some(active_path) = active_runtime_shaping_inputs_path(config)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !active_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = std::fs::read_to_string(&active_path)
|
||||
.with_context(|| format!("Unable to read shaping inputs at {}", active_path.display()))?;
|
||||
let shaping_inputs = serde_json::from_str(&raw).with_context(|| {
|
||||
format!(
|
||||
"Unable to decode shaping inputs JSON at {}",
|
||||
active_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(shaping_inputs))
|
||||
load_active_runtime_shaping_inputs(config)
|
||||
.context("Unable to load active runtime shaping inputs")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -311,16 +279,30 @@ fn load_shaped_devices_from_preferred_source(
|
||||
ConfigShapedDevices::load_for_config(config).context("Unable to load ShapedDevices.csv")?;
|
||||
Ok((shaped_devices, ShapedDevicesLoadSource::ShapedDevicesCsv))
|
||||
}
|
||||
fn load_shaping_inputs() {
|
||||
fn load_topology_runtime_status_payload() {
|
||||
let Ok(config) = load_config() else {
|
||||
warn!("Unable to load LibreQoS config while loading shaping_inputs.json");
|
||||
publish_shaping_inputs(TopologyShapingInputsFile::default());
|
||||
warn!("Unable to load LibreQoS config while loading topology runtime status");
|
||||
return;
|
||||
};
|
||||
match lqos_network_devices::load_runtime_shaping_inputs_for_config(config.as_ref()) {
|
||||
let status = match TopologyRuntimeStatusFile::load(config.as_ref()) {
|
||||
Ok(status) => status,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Unable to load topology_runtime_status.json: {err}; keeping last-known-good effective parent cache"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let identity = topology_status_identity(&status);
|
||||
if !topology_status_identity_changed(&identity) {
|
||||
debug!("Topology runtime status changed without shaping payload identity change");
|
||||
return;
|
||||
}
|
||||
match load_active_runtime_shaping_inputs_from_status(config.as_ref(), &status) {
|
||||
Ok(Some(shaping_inputs)) => {
|
||||
debug!("Loaded shaping inputs from active runtime status");
|
||||
publish_shaping_inputs(shaping_inputs);
|
||||
remember_topology_status_identity(identity);
|
||||
}
|
||||
Ok(None) => {
|
||||
if lqos_config::integration_ingress_enabled(config.as_ref()) {
|
||||
@@ -329,9 +311,11 @@ fn load_shaping_inputs() {
|
||||
debug!(
|
||||
"No active runtime shaping inputs published; keeping last-known-good effective parent cache"
|
||||
);
|
||||
remember_topology_status_identity(identity);
|
||||
} else {
|
||||
debug!("Integration ingress disabled; clearing effective parent cache");
|
||||
publish_shaping_inputs(TopologyShapingInputsFile::default());
|
||||
remember_topology_status_identity(identity);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -342,31 +326,31 @@ fn load_shaping_inputs() {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shaping_inputs_watcher() -> Result<()> {
|
||||
pub fn topology_runtime_status_watcher() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("Shaping Inputs Watcher".to_string())
|
||||
.name("Topology Runtime Status Watcher".to_string())
|
||||
.spawn(|| {
|
||||
debug!("Watching for shaping_inputs.json changes");
|
||||
if let Err(e) = watch_for_shaping_inputs_changing() {
|
||||
error!("Error watching for shaping_inputs.json changes: {:?}", e);
|
||||
debug!("Watching for topology_runtime_status.json changes");
|
||||
if let Err(e) = watch_for_topology_runtime_status_changing() {
|
||||
error!("Error watching topology_runtime_status.json: {:?}", e);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn watch_for_shaping_inputs_changing() -> Result<()> {
|
||||
fn watch_for_topology_runtime_status_changing() -> Result<()> {
|
||||
let Ok(config) = load_config() else {
|
||||
error!("Unable to load LibreQoS config to watch shaping_inputs.json");
|
||||
error!("Unable to load LibreQoS config to watch topology_runtime_status.json");
|
||||
return Err(anyhow::Error::msg(
|
||||
"Unable to load LibreQoS config for shaping_inputs.json",
|
||||
"Unable to load LibreQoS config for topology_runtime_status.json",
|
||||
));
|
||||
};
|
||||
let watch_path = topology_runtime_status_path(config.as_ref());
|
||||
|
||||
let mut watcher = FileWatcher::new("topology_runtime_status.json", watch_path);
|
||||
watcher.set_file_exists_callback(load_shaping_inputs);
|
||||
watcher.set_file_created_callback(load_shaping_inputs);
|
||||
watcher.set_file_changed_callback(load_shaping_inputs);
|
||||
watcher.set_file_exists_callback(load_topology_runtime_status_payload);
|
||||
watcher.set_file_created_callback(load_topology_runtime_status_payload);
|
||||
watcher.set_file_changed_callback(load_topology_runtime_status_payload);
|
||||
loop {
|
||||
let result = watcher.watch();
|
||||
info!("topology_runtime_status.json watcher returned: {result:?}");
|
||||
|
||||
@@ -17,6 +17,10 @@ fn now_unix() -> u64 {
|
||||
unix_now().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn urgent_identity_key(code: &str, dedupe_key: Option<&str>) -> String {
|
||||
dedupe_key.unwrap_or(code).to_string()
|
||||
}
|
||||
|
||||
fn prune_expired(q: &mut VecDeque<UrgentIssue>) {
|
||||
let now = now_unix();
|
||||
while let Some(front) = q.front() {
|
||||
@@ -44,8 +48,8 @@ pub fn submit(
|
||||
let mut guard = URGENT.lock();
|
||||
prune_expired(&mut guard);
|
||||
|
||||
// Dedupe: same (code, dedupe_key) within window → update timestamp/message
|
||||
let key = dedupe_key.clone().unwrap_or_else(|| code.clone());
|
||||
// Dedupe: same (code, dedupe_key) within window updates timestamp/message.
|
||||
let key = urgent_identity_key(&code, dedupe_key.as_deref());
|
||||
if let Some(existing) = guard.iter_mut().rev().find(|i| {
|
||||
(i.code == code)
|
||||
&& (i.dedupe_key.as_deref().unwrap_or("") == key)
|
||||
@@ -90,7 +94,79 @@ pub fn clear(id: u64) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears urgent issues matching the same code and dedupe key used for submission.
|
||||
pub fn clear_by_identity(code: &str, dedupe_key: &str) -> usize {
|
||||
let key = urgent_identity_key(code, Some(dedupe_key));
|
||||
let mut guard = URGENT.lock();
|
||||
let before = guard.len();
|
||||
guard.retain(|issue| {
|
||||
issue.code != code || issue.dedupe_key.as_deref().unwrap_or("") != key.as_str()
|
||||
});
|
||||
before.saturating_sub(guard.len())
|
||||
}
|
||||
|
||||
pub fn clear_all() {
|
||||
let mut guard = URGENT.lock();
|
||||
guard.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_by_identity_removes_only_matching_issues() {
|
||||
let code = "TEST_XDP_IP_MAPPING_APPLY_FAILED";
|
||||
let other_code = "TEST_OTHER";
|
||||
let other_dedupe = "TEST_XDP_IP_MAPPING_APPLY_FAILED_OTHER_DEDUPE";
|
||||
clear_by_identity(code, code);
|
||||
clear_by_identity(code, other_dedupe);
|
||||
clear_by_identity(other_code, code);
|
||||
submit(
|
||||
UrgentSource::LibreQoS,
|
||||
UrgentSeverity::Error,
|
||||
code.to_string(),
|
||||
"mapping failed".to_string(),
|
||||
None,
|
||||
Some(code.to_string()),
|
||||
);
|
||||
submit(
|
||||
UrgentSource::LibreQoS,
|
||||
UrgentSeverity::Error,
|
||||
code.to_string(),
|
||||
"same code different dedupe".to_string(),
|
||||
None,
|
||||
Some(other_dedupe.to_string()),
|
||||
);
|
||||
submit(
|
||||
UrgentSource::LibreQoS,
|
||||
UrgentSeverity::Error,
|
||||
other_code.to_string(),
|
||||
"other failed".to_string(),
|
||||
None,
|
||||
Some(code.to_string()),
|
||||
);
|
||||
|
||||
let cleared = clear_by_identity(code, code);
|
||||
let matching_issue_survived = list()
|
||||
.into_iter()
|
||||
.any(|issue| issue.code == code && issue.dedupe_key.as_deref() == Some(other_dedupe));
|
||||
|
||||
assert_eq!(cleared, 1);
|
||||
assert!(matching_issue_survived);
|
||||
assert_eq!(clear_by_identity(code, other_dedupe), 1);
|
||||
assert_eq!(clear_by_identity(other_code, code), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urgent_identity_key_falls_back_to_code() {
|
||||
assert_eq!(
|
||||
urgent_identity_key("CODE_ONLY", None),
|
||||
"CODE_ONLY".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
urgent_identity_key("CODE", Some("DEDUP")),
|
||||
"DEDUP".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ HOTFIX_APT_SOURCE_PATH="${HOTFIX_APT_SOURCE_PATH:-/etc/apt/sources.list.d/libreq
|
||||
HOTFIX_APT_PREFERENCES_PATH="${HOTFIX_APT_PREFERENCES_PATH:-/etc/apt/preferences.d/libreqos-systemd-hotfix}"
|
||||
HOTFIX_APT_PIN_ORIGIN="${HOTFIX_APT_PIN_ORIGIN:-LibreQoS}"
|
||||
HOTFIX_APT_PIN_LABEL="${HOTFIX_APT_PIN_LABEL:-LibreQoS}"
|
||||
HOTFIX_PACKAGE_VERSION="${HOTFIX_PACKAGE_VERSION:-255.4-1ubuntu8.14+libreqos2}"
|
||||
HOTFIX_PACKAGE_VERSION="${HOTFIX_PACKAGE_VERSION:-255.4-1ubuntu8.15+libreqos1}"
|
||||
SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS="${SUPPORTED_UBUNTU_SYSTEMD_VERSION_GLOBS:-255.4-1ubuntu8 255.4-1ubuntu8.*}"
|
||||
HOTFIX_MARKER="${HOTFIX_MARKER:-/opt/libreqos/src/.systemd_hotfix_installed}"
|
||||
HOTFIX_SKIP_REBOOT_PROMPT="${HOTFIX_SKIP_REBOOT_PROMPT:-0}"
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
sys.modules.setdefault("routeros_api", types.SimpleNamespace())
|
||||
sys.modules.setdefault(
|
||||
"liblqos_python",
|
||||
types.SimpleNamespace(
|
||||
load_mikrotik_ipv6_routers_json=lambda: "[]",
|
||||
mikrotik_ipv6_config_path=lambda: "/etc/libreqos/mikrotik_ipv6.toml",
|
||||
),
|
||||
)
|
||||
|
||||
import mikrotikFindIPv6
|
||||
from mikrotikFindIPv6 import _build_ipv4_to_ipv6_map, _mac_from_dhcpv6_duid
|
||||
|
||||
|
||||
class MikrotikFindIPv6Tests(unittest.TestCase):
|
||||
def test_pull_mikrotik_ipv6_fetches_resources_and_returns_json(self):
|
||||
resources = {
|
||||
"/ip/arp": [
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.30"},
|
||||
],
|
||||
"/ip/dhcp-server/lease": [],
|
||||
"/ipv6/dhcp-server/binding": [
|
||||
{"duid": "00030001AABBCCDDEEFF", "address": "2001:db8:30::/56"},
|
||||
],
|
||||
"/ipv6/neighbor": [
|
||||
{"mac-address": "00:11:22:33:44:55", "address": "2001:db8::99"},
|
||||
],
|
||||
}
|
||||
|
||||
class Resource:
|
||||
def __init__(self, entries):
|
||||
self.entries = entries
|
||||
|
||||
def get(self):
|
||||
return self.entries
|
||||
|
||||
class Api:
|
||||
def get_resource(self, name):
|
||||
return Resource(resources[name])
|
||||
|
||||
class Pool:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_api(self):
|
||||
return Api()
|
||||
|
||||
original_pool = getattr(mikrotikFindIPv6.routeros_api, "RouterOsApiPool", None)
|
||||
original_load_router_list = mikrotikFindIPv6._load_router_list
|
||||
mikrotikFindIPv6.routeros_api.RouterOsApiPool = Pool
|
||||
mikrotikFindIPv6._load_router_list = lambda configPath=None: [
|
||||
{
|
||||
"name": "edge",
|
||||
"host": "198.51.100.1",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
}
|
||||
]
|
||||
stderr = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
result = json.loads(mikrotikFindIPv6.pullMikrotikIPv6())
|
||||
finally:
|
||||
if original_pool is None:
|
||||
delattr(mikrotikFindIPv6.routeros_api, "RouterOsApiPool")
|
||||
else:
|
||||
mikrotikFindIPv6.routeros_api.RouterOsApiPool = original_pool
|
||||
mikrotikFindIPv6._load_router_list = original_load_router_list
|
||||
|
||||
self.assertEqual(result, {"192.0.2.30": "2001:db8:30::/56"})
|
||||
self.assertIn("Failed to find associated IPv4 for 2001:db8::99", stderr.getvalue())
|
||||
|
||||
def test_dhcpv6_duid_ll_extracts_mac(self):
|
||||
self.assertEqual(_mac_from_dhcpv6_duid("00030001AABBCCDDEEFF"), "AABBCCDDEEFF")
|
||||
self.assertEqual(_mac_from_dhcpv6_duid("0x00030001aabbccddeeff"), "AABBCCDDEEFF")
|
||||
self.assertEqual(_mac_from_dhcpv6_duid("00:03:00:01:aa:bb:cc:dd:ee:ff"), "AABBCCDDEEFF")
|
||||
|
||||
def test_dhcpv6_duid_llt_extracts_mac(self):
|
||||
self.assertEqual(_mac_from_dhcpv6_duid("0001000100000000AABBCCDDEEFF"), "AABBCCDDEEFF")
|
||||
|
||||
def test_neighbor_ipv6_matches_ipv4_by_mac(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "aa:bb:cc:dd:ee:ff", "address": "192.0.2.10"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::10"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.10": "2001:db8::10"})
|
||||
|
||||
def test_link_local_neighbor_ipv6_is_not_emitted(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "aa:bb:cc:dd:ee:ff", "address": "192.0.2.11"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::11"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_unspecified_ipv6_prefix_is_not_emitted(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "aa:bb:cc:dd:ee:ff", "address": "192.0.2.12"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"duid": "00030001AABBCCDDEEFF", "address": "::/64"},
|
||||
],
|
||||
neighbor6_entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_neighbor_client_address_preserves_dhcpv6_prefix(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.10"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"client-address": "fe80:0:0:0:0:0:0:1", "address": "2001:db8:100::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::1"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.10": "2001:db8:100::/56"})
|
||||
|
||||
def test_arp_ipv6_matches_dhcp4_by_mac(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::20"},
|
||||
],
|
||||
dhcp4_entries=[
|
||||
{"mac-address": "aa:bb:cc:dd:ee:ff", "address": "192.0.2.20"},
|
||||
],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.20": "2001:db8::20"})
|
||||
|
||||
def test_duplicate_ipv6_entries_prefer_non_link_local_prefix(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.40"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::1"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8:40::/56"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::40"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.40": "2001:db8:40::/56"})
|
||||
|
||||
def test_duplicate_same_priority_ipv6_entries_are_ambiguous(self):
|
||||
neighbors = [
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::50"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::9"},
|
||||
]
|
||||
for neighbor6_entries in (neighbors, list(reversed(neighbors))):
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.50"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=neighbor6_entries,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
self.assertIn("Skipped ambiguous IPv6 entries for MAC AABBCCDDEEFF", stderr.getvalue())
|
||||
|
||||
def test_neighbor_ipv6_recovers_from_ambiguous_arp_ipv6(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.51"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::51"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::52"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::53"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.51": "2001:db8::53"})
|
||||
|
||||
def test_neighbor_ipv6_preferred_over_arp_ipv6_for_same_mac(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.52"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::99"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::52"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.52": "2001:db8::52"})
|
||||
|
||||
def test_neighbor_client_address_matches_cidr_text(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.53"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"client-address": "fe80::53/128", "address": "2001:db8:53::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80:0:0:0:0:0:0:53"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.53": "2001:db8:53::/56"})
|
||||
|
||||
def test_bad_duid_falls_back_to_client_address(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.54"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"duid": "not-hex", "client-address": "fe80::54", "address": "2001:db8:54::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::54"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.54": "2001:db8:54::/56"})
|
||||
|
||||
def test_duplicate_client_address_bindings_are_ambiguous(self):
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.57"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"client-address": "fe80::57", "address": "2001:db8:57::/56"},
|
||||
{"client-address": "fe80::57", "address": "2001:db8:58::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::57/128"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
self.assertIn("Skipped ambiguous DHCPv6 client-address entries for fe80::57", stderr.getvalue())
|
||||
|
||||
def test_equivalent_client_address_bindings_are_not_ambiguous(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.58"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"client-address": "fe80::58", "address": "2001:db8:58::/56"},
|
||||
{"client-address": "fe80:0:0:0:0:0:0:58", "address": "2001:db8:58:0::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "fe80::58"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.58": "2001:db8:58::/56"})
|
||||
|
||||
def test_dhcpv6_binding_preferred_over_arp_and_neighbor_ipv6(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.55"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::55"},
|
||||
],
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[
|
||||
{"duid": "00030001AABBCCDDEEFF", "address": "2001:db8:55::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::56"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.55": "2001:db8:55::/56"})
|
||||
|
||||
def test_dhcp4_ipv4_preferred_over_arp_for_same_mac(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.60"},
|
||||
],
|
||||
dhcp4_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.61"},
|
||||
],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::60"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.61": "2001:db8::60"})
|
||||
|
||||
def test_dhcp4_ipv4_recovers_from_ambiguous_arp_ipv4(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.62"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.63"},
|
||||
],
|
||||
dhcp4_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.64"},
|
||||
],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::64"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.64": "2001:db8::64"})
|
||||
|
||||
def test_duplicate_same_priority_ipv4_entries_are_ambiguous(self):
|
||||
arp_entries = [
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.9"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.10"},
|
||||
]
|
||||
for entries in (arp_entries, list(reversed(arp_entries))):
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=entries,
|
||||
dhcp4_entries=[],
|
||||
dhcp6_bindings=[],
|
||||
neighbor6_entries=[
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::60"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
self.assertIn("Skipped ambiguous IPv4 entries for MAC AABBCCDDEEFF", stderr.getvalue())
|
||||
|
||||
def test_malformed_routeros_rows_are_ignored_while_valid_rows_match(self):
|
||||
result = _build_ipv4_to_ipv6_map(
|
||||
arp_entries=[
|
||||
None,
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "not an address"},
|
||||
{"address": "192.0.2.70"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "192.0.2.70"},
|
||||
],
|
||||
dhcp4_entries=[
|
||||
["not", "a", "dict"],
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "still not an address"},
|
||||
],
|
||||
dhcp6_bindings=[
|
||||
{"duid": "not-hex", "address": "2001:db8:70::/56"},
|
||||
{"duid": "00030001AABBCCDDEEFF", "address": "not an address"},
|
||||
{"client-address": "not an address", "address": "2001:db8:71::/56"},
|
||||
],
|
||||
neighbor6_entries=[
|
||||
None,
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "also not an address"},
|
||||
{"address": "2001:db8::70"},
|
||||
{"mac-address": "AA:BB:CC:DD:EE:FF", "address": "2001:db8::70"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"192.0.2.70": "2001:db8::70"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
import unittest
|
||||
|
||||
import LibreQoS
|
||||
|
||||
|
||||
class FakeIpMapBatch:
|
||||
def __init__(self, submit_error=None):
|
||||
self.finished = False
|
||||
self.submitted = False
|
||||
self.submit_error = submit_error
|
||||
|
||||
def finish_ip_mappings(self):
|
||||
self.finished = True
|
||||
|
||||
def submit(self):
|
||||
self.submitted = True
|
||||
if self.submit_error is not None:
|
||||
raise self.submit_error
|
||||
|
||||
|
||||
class XdpMappingRecoveryTests(unittest.TestCase):
|
||||
def test_ready_on_first_check_does_not_sleep(self):
|
||||
sleeps = []
|
||||
|
||||
LibreQoS.wait_for_xdp_ip_mapping_ready(
|
||||
ready=lambda: True,
|
||||
sleep=lambda seconds: sleeps.append(seconds),
|
||||
now=lambda: 0.0,
|
||||
)
|
||||
|
||||
self.assertEqual(sleeps, [])
|
||||
|
||||
def test_readiness_after_one_false_check_sleeps_once(self):
|
||||
checks = iter([False, True])
|
||||
sleeps = []
|
||||
times = iter([0.0, 0.0, 0.0])
|
||||
|
||||
LibreQoS.wait_for_xdp_ip_mapping_ready(
|
||||
ready=lambda: next(checks),
|
||||
sleep=lambda seconds: sleeps.append(seconds),
|
||||
now=lambda: next(times),
|
||||
timeout_seconds=5.0,
|
||||
interval_seconds=0.1,
|
||||
)
|
||||
|
||||
self.assertEqual(sleeps, [0.1])
|
||||
|
||||
def test_persistent_false_readiness_times_out(self):
|
||||
times = iter([0.0, 0.0, 0.2])
|
||||
|
||||
with self.assertRaises(TimeoutError):
|
||||
LibreQoS.wait_for_xdp_ip_mapping_ready(
|
||||
ready=lambda: False,
|
||||
sleep=lambda _seconds: None,
|
||||
now=lambda: next(times),
|
||||
timeout_seconds=0.1,
|
||||
interval_seconds=0.1,
|
||||
)
|
||||
|
||||
def test_readiness_os_error_reports_before_submit(self):
|
||||
batch = FakeIpMapBatch()
|
||||
reports = []
|
||||
|
||||
def raise_os_error():
|
||||
raise OSError("Unable to inspect XDP IP mapping maps")
|
||||
|
||||
LibreQoS.apply_xdp_ip_mappings(
|
||||
batch,
|
||||
{"queued_requests": 1},
|
||||
ready=raise_os_error,
|
||||
sleep=lambda _seconds: None,
|
||||
report_failure=lambda *args: reports.append(args),
|
||||
clear_recovered_issue=lambda *_args: True,
|
||||
)
|
||||
|
||||
self.assertFalse(batch.finished)
|
||||
self.assertFalse(batch.submitted)
|
||||
self.assertEqual(reports[0][0], LibreQoS.XDP_IP_MAPPING_APPLY_FAILED)
|
||||
|
||||
def test_submit_failure_reports_mapping_apply_failure(self):
|
||||
batch = FakeIpMapBatch(submit_error=OSError("Unable to open BPF map"))
|
||||
reports = []
|
||||
|
||||
LibreQoS.apply_xdp_ip_mappings(
|
||||
batch,
|
||||
{"queued_requests": 1},
|
||||
ready=lambda: True,
|
||||
sleep=lambda _seconds: None,
|
||||
report_failure=lambda *args: reports.append(args),
|
||||
clear_recovered_issue=lambda *_args: True,
|
||||
)
|
||||
|
||||
self.assertTrue(batch.finished)
|
||||
self.assertTrue(batch.submitted)
|
||||
self.assertEqual(reports[0][0], LibreQoS.XDP_IP_MAPPING_APPLY_FAILED)
|
||||
|
||||
def test_success_clears_recovered_mapping_issue(self):
|
||||
batch = FakeIpMapBatch()
|
||||
cleared = []
|
||||
|
||||
LibreQoS.apply_xdp_ip_mappings(
|
||||
batch,
|
||||
{"queued_requests": 1},
|
||||
ready=lambda: True,
|
||||
sleep=lambda _seconds: None,
|
||||
report_failure=lambda *_args: self.fail("unexpected failure report"),
|
||||
clear_recovered_issue=lambda code, dedupe_key: cleared.append((code, dedupe_key)) or True,
|
||||
)
|
||||
|
||||
self.assertTrue(batch.finished)
|
||||
self.assertTrue(batch.submitted)
|
||||
self.assertEqual(
|
||||
cleared,
|
||||
[
|
||||
(
|
||||
LibreQoS.XDP_IP_MAPPING_APPLY_FAILED,
|
||||
LibreQoS.XDP_IP_MAPPING_APPLY_FAILED,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user