mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Add VISP integration
This commit is contained in:
1 parent
95c9ddf8d8
commit
05663b0b40
15 files changed
+837
-2
No files matched your search
+18
@@ -141,6 +141,24 @@ src/bin/lqos_api
|
||||
/src/linux_tc_rust.txt
|
||||
/src/linux_tc_rust_live.txt
|
||||
/src/linux_tc_rust_prune.txt
|
||||
|
||||
# VISP integration local artifacts (never commit)
|
||||
src/.visp_token_cache_*.json
|
||||
|
||||
# Local key material (never commit)
|
||||
src/.keys/
|
||||
|
||||
# Node manager first-login timestamp (local)
|
||||
src/.fl
|
||||
|
||||
# LibreOffice / editor lock files
|
||||
src/.~lock.*#
|
||||
|
||||
# Local build artifact (built on target host)
|
||||
src/bin/lqos_overrides
|
||||
|
||||
# Local large test topology artifact
|
||||
src/network.large.json
|
||||
<<<<<<< HEAD
|
||||
/src/lqos_overrides.json
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ LQOS_FILES=(
|
||||
integrationSonar.py
|
||||
integrationSplynx.py
|
||||
integrationUISP.py
|
||||
integrationVISP.py
|
||||
LibreQoS.py
|
||||
lqos.example
|
||||
lqTools.py
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
from pythonCheck import checkPythonVersion
|
||||
checkPythonVersion()
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from collections import Counter
|
||||
|
||||
from liblqos_python import (
|
||||
get_libreqos_directory,
|
||||
overwrite_network_json_always,
|
||||
bandwidth_overhead_factor,
|
||||
client_bandwidth_multiplier,
|
||||
visp_client_id,
|
||||
visp_client_secret,
|
||||
visp_username,
|
||||
visp_password,
|
||||
visp_isp_id,
|
||||
visp_online_users_domain,
|
||||
visp_timeout_secs,
|
||||
)
|
||||
|
||||
from integrationCommon import NetworkGraph, NetworkNode, NodeType, isIpv4Permitted
|
||||
|
||||
|
||||
TOKEN_URL = "https://data.visp.net/token"
|
||||
GRAPHQL_URL = "https://integrations.visp.net/graphql"
|
||||
|
||||
|
||||
def _apply_rate(plan_rate_mbps: float) -> float:
|
||||
plan_rate_mbps = float(plan_rate_mbps or 0.0)
|
||||
if plan_rate_mbps <= 0:
|
||||
return 0.0
|
||||
overhead = bandwidth_overhead_factor()
|
||||
minimum = client_bandwidth_multiplier()
|
||||
adjusted = plan_rate_mbps * overhead
|
||||
floor_value = plan_rate_mbps * minimum
|
||||
return max(adjusted, floor_value)
|
||||
|
||||
|
||||
def _unit_to_mbps(value: Any, unit: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
except Exception:
|
||||
return None
|
||||
if v <= 0:
|
||||
return None
|
||||
u = (str(unit or "").strip().lower())
|
||||
if u in ("mbps", "mb", "m"):
|
||||
return v
|
||||
if u in ("kbps", "kb", "k"):
|
||||
return v / 1000.0
|
||||
if u in ("gbps", "gb", "g"):
|
||||
return v * 1000.0
|
||||
# Unknown unit: assume Mbps (VISP mostly uses mbps)
|
||||
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:
|
||||
try:
|
||||
return int(isp_ids[0])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class VispClient:
|
||||
def __init__(self) -> None:
|
||||
# Allow env overrides for quick testing without editing system config.
|
||||
self.client_id = (os.environ.get("VISP_CLIENT_ID", "") or visp_client_id()).strip()
|
||||
self.client_secret = (os.environ.get("VISP_CLIENT_SECRET", "") or visp_client_secret()).strip()
|
||||
self.username = (os.environ.get("VISP_USERNAME", "") or visp_username()).strip()
|
||||
self.password = (os.environ.get("VISP_PASSWORD", "") or visp_password()).strip()
|
||||
|
||||
missing: List[str] = []
|
||||
if not self.client_id:
|
||||
missing.append("visp_client_id / VISP_CLIENT_ID")
|
||||
if not self.client_secret:
|
||||
missing.append("visp_client_secret / VISP_CLIENT_SECRET")
|
||||
if not self.username:
|
||||
missing.append("visp_username / VISP_USERNAME")
|
||||
if not self.password:
|
||||
missing.append("visp_password / VISP_PASSWORD")
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"VISP integration is missing required credentials: "
|
||||
+ ", ".join(missing)
|
||||
+ ". Configure these in `lqos.conf` (or set `LQOS_CONFIG=/path/to/lqos.conf`) "
|
||||
+ "or provide them via env vars for a one-off test."
|
||||
)
|
||||
self.config_isp_id = int(visp_isp_id() or 0)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"x-visp-client-id": self.client_id,
|
||||
"x-visp-client-secret": self.client_secret,
|
||||
}
|
||||
)
|
||||
self._token: Optional[str] = None
|
||||
self._token_exp: int = 0
|
||||
self._isp_id: Optional[int] = None
|
||||
# Default to a fast-fail timeout. Operators can raise this in config if needed.
|
||||
self.timeout_secs: int = max(10, int(visp_timeout_secs() or 20))
|
||||
|
||||
def _token_cache_path(self) -> str:
|
||||
base = get_libreqos_directory()
|
||||
h = hashlib.sha256((self.client_id + "|" + self.username).encode("utf-8")).hexdigest()[:16]
|
||||
return os.path.join(base, f".visp_token_cache_{h}.json")
|
||||
|
||||
def _load_cached_token(self) -> None:
|
||||
path = self._token_cache_path()
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
self._token = data.get("token") or None
|
||||
self._token_exp = int(data.get("exp") or 0)
|
||||
self._isp_id = int(data.get("isp_id") or 0) or None
|
||||
except Exception:
|
||||
# Cache is best-effort.
|
||||
self._token = None
|
||||
self._token_exp = 0
|
||||
self._isp_id = None
|
||||
|
||||
def _store_cached_token(self) -> None:
|
||||
path = self._token_cache_path()
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(
|
||||
{"token": self._token, "exp": self._token_exp, "isp_id": self._isp_id or 0},
|
||||
f,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _token_valid(self) -> bool:
|
||||
if not self._token or not self._token_exp:
|
||||
return False
|
||||
# Refresh token if less than 24h remaining.
|
||||
return int(time.time()) < int(self._token_exp) - 24 * 3600
|
||||
|
||||
def ensure_token(self) -> None:
|
||||
if self._token_valid() and self._isp_id:
|
||||
return
|
||||
if self._token is None or self._token_exp == 0:
|
||||
self._load_cached_token()
|
||||
if self._token_valid() and self._isp_id:
|
||||
return
|
||||
|
||||
headers = {
|
||||
"x-visp-client-id": self.client_id,
|
||||
"x-visp-client-secret": self.client_secret,
|
||||
"x-visp-username": self.username,
|
||||
"x-visp-password": self.password,
|
||||
}
|
||||
last_err = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
r = self.session.get(TOKEN_URL, headers=headers, timeout=self.timeout_secs)
|
||||
if r.status_code in (502, 503, 504):
|
||||
time.sleep(min(10, 1.5 ** attempt))
|
||||
continue
|
||||
if r.status_code != 200:
|
||||
# Include a small prefix of the body for debugging (should not contain secrets).
|
||||
body_prefix = (r.text or "").replace("\n", "\\n")[:300]
|
||||
raise RuntimeError(f"VISP token request failed: HTTP {r.status_code}: {body_prefix}")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
token = data.get("token")
|
||||
payload = data.get("payload") or {}
|
||||
exp = int((payload.get("exp") or 0))
|
||||
isp_id = self.config_isp_id if self.config_isp_id > 0 else _safe_isp_id_from_token_payload(payload)
|
||||
if not token or not exp or not isp_id:
|
||||
raise RuntimeError("VISP token response missing required fields")
|
||||
self._token = token
|
||||
self._token_exp = exp
|
||||
self._isp_id = int(isp_id)
|
||||
self._store_cached_token()
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(min(5, 1.5 ** attempt))
|
||||
raise RuntimeError(f"Unable to obtain VISP token: {last_err}")
|
||||
|
||||
@property
|
||||
def isp_id(self) -> int:
|
||||
self.ensure_token()
|
||||
return int(self._isp_id or 0)
|
||||
|
||||
def gql(self, query: str, variables: Dict[str, Any], op_name: str) -> Dict[str, Any]:
|
||||
self.ensure_token()
|
||||
headers = {
|
||||
"authorization": self._token or "",
|
||||
}
|
||||
body = {"operationName": op_name, "query": query, "variables": variables}
|
||||
last_err = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
r = self.session.post(GRAPHQL_URL, headers=headers, json=body, timeout=self.timeout_secs)
|
||||
if r.status_code in (502, 503, 504):
|
||||
time.sleep(min(10, 1.5 ** attempt))
|
||||
continue
|
||||
# Unauthorized: refresh token and retry once.
|
||||
if r.status_code in (401, 403):
|
||||
self._token = None
|
||||
self._token_exp = 0
|
||||
self.ensure_token()
|
||||
headers["authorization"] = self._token or ""
|
||||
time.sleep(1)
|
||||
continue
|
||||
if r.status_code != 200:
|
||||
body_prefix = (r.text or "").replace("\n", "\\n")[:300]
|
||||
raise RuntimeError(f"VISP GraphQL HTTP {r.status_code}: {body_prefix}")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if isinstance(data, dict) and data.get("errors"):
|
||||
raise RuntimeError(str(data.get("errors")[:1]))
|
||||
return data.get("data") or {}
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(min(5, 1.5 ** attempt))
|
||||
raise RuntimeError(f"VISP GraphQL request failed: {last_err}")
|
||||
|
||||
|
||||
def _wifi_bulk(visp: VispClient) -> List[Dict[str, Any]]:
|
||||
q = "query Bulk($isp_id:Int){ customerCPEwithWirelessServiceSpeeds(isp_id:$isp_id) }"
|
||||
data = visp.gql(q, {"isp_id": visp.isp_id}, "Bulk")
|
||||
res = data.get("customerCPEwithWirelessServiceSpeeds")
|
||||
return res if isinstance(res, list) else []
|
||||
|
||||
def _extract_ipv4_list(ip_val: Any) -> List[str]:
|
||||
out: List[str] = []
|
||||
if not ip_val:
|
||||
return out
|
||||
ip = str(ip_val).strip()
|
||||
if not ip:
|
||||
return out
|
||||
if isIpv4Permitted(ip):
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def _service_is_active(details: Dict[str, Any]) -> bool:
|
||||
# VISP can surface multiple status-like fields. Be conservative about skipping:
|
||||
# only skip if it is clearly terminated/cancelled/deleted. Many VISP tenants appear
|
||||
# to report "INACTIVE" for services that still have IP+speed and should be shaped.
|
||||
def norm(val: Any) -> str:
|
||||
return str(val or "").strip().upper()
|
||||
|
||||
def status_bool(val: Any) -> Optional[bool]:
|
||||
s = norm(val)
|
||||
if not s:
|
||||
return None
|
||||
if s in ("ACTIVE", "CURRENT", "ENABLED", "ONLINE"):
|
||||
return True
|
||||
# Treat only hard-stop states as inactive. Everything else is "unknown" and
|
||||
# should not prevent shaping if IP+speed are present.
|
||||
if s in ("CANCELLED", "CANCELED", "TERMINATED", "DELETED"):
|
||||
return False
|
||||
return None
|
||||
|
||||
pkg = status_bool(details.get("package_status"))
|
||||
if pkg is False:
|
||||
return False
|
||||
|
||||
svc = status_bool(details.get("status"))
|
||||
if svc is not None:
|
||||
return svc
|
||||
|
||||
if pkg is True:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _add_circuit_and_device(
|
||||
net: NetworkGraph,
|
||||
circuit_id: str,
|
||||
customer_name: str,
|
||||
down_mbps: float,
|
||||
up_mbps: float,
|
||||
ipv4s: List[str],
|
||||
mac: str,
|
||||
device_id_suffix: str = "dev",
|
||||
) -> bool:
|
||||
if not ipv4s:
|
||||
return False
|
||||
if down_mbps <= 0 or up_mbps <= 0:
|
||||
return False
|
||||
# Ensure circuit IDs are unique; adding duplicates breaks the graph indexes.
|
||||
if net.findNodeIndexById(circuit_id) != -1:
|
||||
return False
|
||||
net.addRawNode(
|
||||
NetworkNode(
|
||||
id=circuit_id,
|
||||
displayName=customer_name,
|
||||
type=NodeType.client,
|
||||
parentId="",
|
||||
address=customer_name,
|
||||
customerName=customer_name,
|
||||
download=_apply_rate(down_mbps),
|
||||
upload=_apply_rate(up_mbps),
|
||||
)
|
||||
)
|
||||
net.addRawNode(
|
||||
NetworkNode(
|
||||
id=f"{circuit_id}_{device_id_suffix}",
|
||||
displayName=f"{customer_name}_device",
|
||||
type=NodeType.device,
|
||||
parentId=circuit_id,
|
||||
mac=mac,
|
||||
ipv4=ipv4s,
|
||||
ipv6=[],
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def createShaper() -> None:
|
||||
t0 = time.time()
|
||||
visp = VispClient()
|
||||
net = NetworkGraph()
|
||||
|
||||
counters = {
|
||||
"wifi_bulk_services": 0,
|
||||
"shaped": 0,
|
||||
"skipped_inactive": 0,
|
||||
"skipped_no_ip": 0,
|
||||
"skipped_no_speed": 0,
|
||||
}
|
||||
|
||||
disable_wifi_bulk = os.environ.get("VISP_DISABLE_WIFI_BULK", "").strip() in ("1", "true", "yes", "on")
|
||||
# IMPORTANT: VISP imports must be fast. By default, we only use the known bulk resolver.
|
||||
# Set VISP_ENABLE_CUSTOMER_PAGING=1 to opt into the slow, full-tenant scan mode.
|
||||
enable_customer_paging = os.environ.get("VISP_ENABLE_CUSTOMER_PAGING", "").strip() in ("1", "true", "yes", "on")
|
||||
|
||||
# 1) WiFi bulk shaping (fast path with IP + speed + MAC)
|
||||
debug_status = os.environ.get("VISP_DEBUG_STATUS", "").strip() in ("1", "true", "yes", "on")
|
||||
pkg_status_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
|
||||
if disable_wifi_bulk:
|
||||
print("[VISP] VISP_DISABLE_WIFI_BULK enabled; skipping wireless bulk import")
|
||||
else:
|
||||
bulk = _wifi_bulk(visp)
|
||||
for rec in bulk:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
customer_name = str(rec.get("username") or "").strip()
|
||||
ws_list = rec.get("wireless_services") or []
|
||||
if not customer_name or not isinstance(ws_list, list):
|
||||
continue
|
||||
for ws in ws_list:
|
||||
if not isinstance(ws, dict):
|
||||
continue
|
||||
counters["wifi_bulk_services"] += 1
|
||||
if debug_status:
|
||||
pkg_status_counts[str(ws.get("package_status") or "").strip().upper()] += 1
|
||||
status_counts[str(ws.get("status") or "").strip().upper()] += 1
|
||||
if not _service_is_active(ws):
|
||||
counters["skipped_inactive"] += 1
|
||||
continue
|
||||
service_number = ws.get("service_number")
|
||||
if not service_number:
|
||||
continue
|
||||
circuit_id = str(service_number)
|
||||
down = _unit_to_mbps(ws.get("down_speed"), ws.get("down_speed_unit"))
|
||||
up = _unit_to_mbps(ws.get("up_speed"), ws.get("up_speed_unit"))
|
||||
if not down or not up:
|
||||
counters["skipped_no_speed"] += 1
|
||||
continue
|
||||
ipv4s = _extract_ipv4_list(ws.get("ip_address"))
|
||||
if not ipv4s:
|
||||
counters["skipped_no_ip"] += 1
|
||||
continue
|
||||
mac = _normalize_mac(ws.get("mac_address"))
|
||||
if _add_circuit_and_device(net, circuit_id, customer_name, down, up, ipv4s, mac, "wifi"):
|
||||
counters["shaped"] += 1
|
||||
|
||||
# 2) Optional slow mode: full tenant scan for additional service types
|
||||
# We intentionally keep this off by default to avoid long waits during scheduled imports.
|
||||
if enable_customer_paging:
|
||||
print("[VISP] VISP_ENABLE_CUSTOMER_PAGING enabled, but full-tenant scan is currently disabled in this build.")
|
||||
print("[VISP] If you need non-wireless bulk support, we should first confirm VISP provides bulk resolvers for them.")
|
||||
|
||||
# 3) Optional onlineUsers enrichment (only supplements; doesn't create new circuits)
|
||||
domain = str(visp_online_users_domain() or "").strip()
|
||||
if domain:
|
||||
try:
|
||||
q = "query Online($domain:String!){ onlineUsers(domain:$domain){ username ip } }"
|
||||
data = visp.gql(q, {"domain": domain}, "Online")
|
||||
rows = data.get("onlineUsers") or []
|
||||
if isinstance(rows, list):
|
||||
ip_by_user = {}
|
||||
for r in rows:
|
||||
if isinstance(r, dict):
|
||||
u = str(r.get("username") or "").strip()
|
||||
ip = str(r.get("ip") or "").strip()
|
||||
if u and ip and isIpv4Permitted(ip):
|
||||
ip_by_user[u] = ip
|
||||
# Best-effort: if device has no IPs, populate from username match
|
||||
for node in net.nodes:
|
||||
if node.type == NodeType.device and (not node.ipv4 or len(node.ipv4) == 0):
|
||||
ip = ip_by_user.get(node.displayName.replace("_device", ""))
|
||||
if ip:
|
||||
node.ipv4 = [ip]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
net.prepareTree()
|
||||
|
||||
if net.doesNetworkJsonExist() and not overwrite_network_json_always():
|
||||
print("[VISP] network.json exists and overwrite disabled; preserving current file")
|
||||
else:
|
||||
net.createNetworkJson()
|
||||
net.createShapedDevices()
|
||||
|
||||
print(
|
||||
"[VISP] shaped={shaped} wifi_bulk_services={wifi_bulk_services} "
|
||||
"skipped_inactive={skipped_inactive} "
|
||||
"skipped_no_ip={skipped_no_ip} skipped_no_speed={skipped_no_speed} elapsed_s={elapsed_s}".format(
|
||||
**counters,
|
||||
elapsed_s=int(time.time() - t0),
|
||||
)
|
||||
)
|
||||
if debug_status:
|
||||
print("[VISP] Debug: top package_status values:")
|
||||
for k, v in pkg_status_counts.most_common(12):
|
||||
print(f"[VISP] {k or '<empty>'}: {v}")
|
||||
print("[VISP] Debug: top status values:")
|
||||
for k, v in status_counts.most_common(12):
|
||||
print(f"[VISP] {k or '<empty>'}: {v}")
|
||||
|
||||
|
||||
def importFromVISP() -> None:
|
||||
createShaper()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
importFromVISP()
|
||||
@@ -78,6 +78,18 @@ api_key = ""
|
||||
api_secret = ""
|
||||
url = ""
|
||||
|
||||
[visp_integration]
|
||||
enable_visp = false
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
username = ""
|
||||
password = ""
|
||||
# Leave blank to auto-select the first ISP ID returned by the token endpoint.
|
||||
#isp_id = 0
|
||||
timeout_secs = 20
|
||||
# Optional RADIUS domain for online session enrichment.
|
||||
#online_users_domain = ""
|
||||
|
||||
[uisp_integration]
|
||||
enable_uisp = false
|
||||
token = ""
|
||||
|
||||
@@ -70,6 +70,18 @@ api_key = ""
|
||||
api_url = ""
|
||||
timeout_secs = 60
|
||||
|
||||
[visp_integration]
|
||||
enable_visp = false
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
username = ""
|
||||
password = ""
|
||||
# Leave blank to auto-select the first ISP ID returned by the token endpoint.
|
||||
#isp_id = 0
|
||||
timeout_secs = 20
|
||||
# Optional RADIUS domain for online session enrichment.
|
||||
#online_users_domain = ""
|
||||
|
||||
[uisp_integration]
|
||||
enable_uisp = false
|
||||
token = ""
|
||||
|
||||
@@ -16,6 +16,7 @@ mod splynx_integration;
|
||||
mod stormguard;
|
||||
mod tuning;
|
||||
mod uisp_integration;
|
||||
mod visp_integration;
|
||||
mod wispgate;
|
||||
|
||||
pub use bridge::*;
|
||||
|
||||
@@ -71,6 +71,10 @@ pub struct Config {
|
||||
/// section still deserialize cleanly.
|
||||
pub netzur_integration: Option<super::netzur_integration::NetzurIntegration>,
|
||||
|
||||
/// VISP Integration configuration. Optional so older configs without this
|
||||
/// section still deserialize cleanly.
|
||||
pub visp_integration: Option<super::visp_integration::VispIntegration>,
|
||||
|
||||
/// UISP Integration
|
||||
pub uisp_integration: super::uisp_integration::UispIntegration,
|
||||
|
||||
@@ -218,6 +222,7 @@ impl Default for Config {
|
||||
integration_common: super::integration_common::IntegrationConfig::default(),
|
||||
splynx_integration: super::splynx_integration::SplynxIntegration::default(),
|
||||
netzur_integration: Some(super::netzur_integration::NetzurIntegration::default()),
|
||||
visp_integration: Some(super::visp_integration::VispIntegration::default()),
|
||||
uisp_integration: super::uisp_integration::UispIntegration::default(),
|
||||
powercode_integration: super::powercode_integration::PowercodeIntegration::default(),
|
||||
sonar_integration: super::sonar_integration::SonarIntegration::default(),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use allocative::Allocative;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_token_url() -> String {
|
||||
"https://data.visp.net/token".to_string()
|
||||
}
|
||||
|
||||
fn default_graphql_url() -> String {
|
||||
"https://integrations.visp.net/graphql".to_string()
|
||||
}
|
||||
|
||||
fn default_timeout_secs() -> u64 {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_strategy() -> String {
|
||||
"flat".to_string()
|
||||
}
|
||||
|
||||
fn default_service_type_allowlist() -> Vec<String> {
|
||||
vec![
|
||||
// Internet access service types we can resolve to IP + speed.
|
||||
"ServiceTypeWifi".to_string(),
|
||||
"ServiceTypeOtherConnection".to_string(),
|
||||
"ServiceTypeFiberCircuit".to_string(),
|
||||
"ServiceTypeCable".to_string(),
|
||||
"ServiceTypeDsl".to_string(),
|
||||
"ServiceTypeAttDsl".to_string(),
|
||||
"ServiceTypeBpl".to_string(),
|
||||
// LTE is supported only when an IP can be resolved. Keep in allowlist
|
||||
// so operators can use it, but the integration will skip LTE services
|
||||
// without an IP.
|
||||
"ServiceTypeLte".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
|
||||
pub struct VispIntegration {
|
||||
pub enable_visp: bool,
|
||||
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
|
||||
#[serde(default = "default_token_url")]
|
||||
pub token_url: String,
|
||||
#[serde(default = "default_graphql_url")]
|
||||
pub graphql_url: String,
|
||||
|
||||
/// VISP tenant/ISP identifier. If not set, the integration will use the
|
||||
/// first `ispId` returned in the token payload.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub isp_id: Option<i64>,
|
||||
|
||||
/// Optional header override for multi-tenant installs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tenant_id: Option<i64>,
|
||||
|
||||
/// Optional RADIUS domain for `onlineUsers(domain: ...)` enrichment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub online_users_domain: Option<String>,
|
||||
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: String,
|
||||
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub verify_tls: bool,
|
||||
|
||||
#[serde(default = "default_service_type_allowlist")]
|
||||
pub service_type_allowlist: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for VispIntegration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_visp: false,
|
||||
client_id: "".to_string(),
|
||||
client_secret: "".to_string(),
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
token_url: default_token_url(),
|
||||
graphql_url: default_graphql_url(),
|
||||
isp_id: None,
|
||||
tenant_id: None,
|
||||
online_users_domain: None,
|
||||
strategy: default_strategy(),
|
||||
timeout_secs: default_timeout_secs(),
|
||||
verify_tls: true,
|
||||
service_type_allowlist: default_service_type_allowlist(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,9 +628,17 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(netzur_api_key, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(netzur_api_url, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(netzur_api_timeout, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_client_id, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_client_secret, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_username, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_password, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_isp_id, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_online_users_domain, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(visp_timeout_secs, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(automatic_import_uisp, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(automatic_import_splynx, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(automatic_import_netzur, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(automatic_import_visp, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(queue_refresh_interval_mins, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(automatic_import_powercode, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(powercode_api_key, m)?)?;
|
||||
@@ -1323,6 +1331,76 @@ fn netzur_api_timeout() -> PyResult<u64> {
|
||||
.unwrap_or(60))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_client_id() -> PyResult<String> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.client_id.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_client_secret() -> PyResult<String> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.client_secret.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_username() -> PyResult<String> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.username.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_password() -> PyResult<String> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.password.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_isp_id() -> PyResult<i64> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.isp_id)
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_online_users_domain() -> PyResult<String> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.online_users_domain.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn visp_timeout_secs() -> PyResult<u64> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.timeout_secs)
|
||||
.unwrap_or(20))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn automatic_import_uisp() -> PyResult<bool> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
@@ -1345,6 +1423,16 @@ fn automatic_import_netzur() -> PyResult<bool> {
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn automatic_import_visp() -> PyResult<bool> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
Ok(config
|
||||
.visp_integration
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.enable_visp)
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn queue_refresh_interval_mins() -> PyResult<u32> {
|
||||
let config = lqos_config::load_config().unwrap();
|
||||
|
||||
@@ -9,7 +9,7 @@ set -e
|
||||
chmod a+x /tmp/esbuild/esbuild
|
||||
ESBUILD_BIN="/tmp/esbuild/esbuild"
|
||||
#fi
|
||||
scripts=( index.js template.js login.js first-run.js shaped-devices.js tree.js help.js unknown-ips.js configuration.js circuit.js flow_map.js all_tree_sankey.js asn_explorer.js lts_trial.js config_general.js config_tuning.js config_queues.js config_stormguard.js config_lts.js config_iprange.js config_flows.js config_integration.js config_splynx.js config_netzur.js config_uisp.js config_powercode.js config_sonar.js config_interface.js config_network.js config_devices.js config_users.js config_wispgate.js chatbot.js cpu_weights.js cpu_tree.js executive_worst_sites.js executive_oversubscribed_sites.js executive_sites_due_upgrade.js executive_circuits_due_upgrade.js executive_top_asns.js executive_heatmap_rtt.js executive_heatmap_retransmit.js executive_heatmap_download.js executive_heatmap_upload.js)
|
||||
scripts=( index.js template.js login.js first-run.js shaped-devices.js tree.js help.js unknown-ips.js configuration.js circuit.js flow_map.js all_tree_sankey.js asn_explorer.js lts_trial.js config_general.js config_tuning.js config_queues.js config_stormguard.js config_lts.js config_iprange.js config_flows.js config_integration.js config_splynx.js config_netzur.js config_visp.js config_uisp.js config_powercode.js config_sonar.js config_interface.js config_network.js config_devices.js config_users.js config_wispgate.js chatbot.js cpu_weights.js cpu_tree.js executive_worst_sites.js executive_oversubscribed_sites.js executive_sites_due_upgrade.js executive_circuits_due_upgrade.js executive_top_asns.js executive_heatmap_rtt.js executive_heatmap_retransmit.js executive_heatmap_download.js executive_heatmap_upload.js)
|
||||
for script in "${scripts[@]}"
|
||||
do
|
||||
echo "Building {$script}"
|
||||
|
||||
@@ -263,6 +263,7 @@ export function renderConfigMenu(currentPage) {
|
||||
{ href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" },
|
||||
{ href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" },
|
||||
{ href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" },
|
||||
{ href: "config_visp.html", icon: "fa-link", text: "VISP", id: "visp" },
|
||||
{ href: "config_uisp.html", icon: "fa-link", text: "UISP", id: "uisp" },
|
||||
{ href: "config_powercode.html", icon: "fa-link", text: "Powercode", id: "powercode" },
|
||||
{ href: "config_sonar.html", icon: "fa-link", text: "Sonar", id: "sonar" },
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper";
|
||||
|
||||
function validateConfig() {
|
||||
const enabled = document.getElementById("enableVisp").checked;
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const clientId = document.getElementById("vispClientId").value.trim();
|
||||
if (!clientId) {
|
||||
alert("Client ID is required when VISP integration is enabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientSecret = document.getElementById("vispClientSecret").value.trim();
|
||||
if (!clientSecret) {
|
||||
alert("Client Secret is required when VISP integration is enabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
const username = document.getElementById("vispUsername").value.trim();
|
||||
if (!username) {
|
||||
alert("Appuser Username is required when VISP integration is enabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
const password = document.getElementById("vispPassword").value.trim();
|
||||
if (!password) {
|
||||
alert("Appuser Password is required when VISP integration is enabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeout = parseInt(document.getElementById("vispTimeout").value, 10);
|
||||
if (Number.isNaN(timeout) || timeout <= 0) {
|
||||
alert("Timeout must be a positive number of seconds");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateConfig() {
|
||||
const ispIdRaw = document.getElementById("vispIspId").value;
|
||||
const ispIdParsed = ispIdRaw !== "" ? parseInt(ispIdRaw, 10) : null;
|
||||
|
||||
window.config.visp_integration = {
|
||||
enable_visp: document.getElementById("enableVisp").checked,
|
||||
client_id: document.getElementById("vispClientId").value.trim(),
|
||||
client_secret: document.getElementById("vispClientSecret").value.trim(),
|
||||
username: document.getElementById("vispUsername").value.trim(),
|
||||
password: document.getElementById("vispPassword").value.trim(),
|
||||
isp_id: (ispIdParsed !== null && !Number.isNaN(ispIdParsed) && ispIdParsed > 0) ? ispIdParsed : null,
|
||||
online_users_domain: document.getElementById("vispOnlineUsersDomain").value.trim() || null,
|
||||
timeout_secs: parseInt(document.getElementById("vispTimeout").value, 10) || 20
|
||||
};
|
||||
}
|
||||
|
||||
renderConfigMenu('visp');
|
||||
|
||||
loadConfig(() => {
|
||||
if (!window.config) {
|
||||
console.error("Configuration not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = window.config.visp_integration ?? {};
|
||||
document.getElementById("enableVisp").checked = cfg.enable_visp ?? false;
|
||||
document.getElementById("vispClientId").value = cfg.client_id ?? "";
|
||||
document.getElementById("vispClientSecret").value = cfg.client_secret ?? "";
|
||||
document.getElementById("vispUsername").value = cfg.username ?? "";
|
||||
document.getElementById("vispPassword").value = cfg.password ?? "";
|
||||
document.getElementById("vispIspId").value = cfg.isp_id ?? "";
|
||||
document.getElementById("vispTimeout").value = cfg.timeout_secs ?? 20;
|
||||
document.getElementById("vispOnlineUsersDomain").value = cfg.online_users_domain ?? "";
|
||||
|
||||
document.getElementById("saveVisp").addEventListener('click', () => {
|
||||
if (validateConfig()) {
|
||||
updateConfig();
|
||||
saveConfig(() => alert("VISP configuration saved"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
<div id="configMenuContainer"></div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="enableVisp">
|
||||
<label class="form-check-label" for="enableVisp">Enable VISP Integration</label>
|
||||
<div class="form-text">Toggle automated imports from VISP (GraphQL UBO API)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispClientId" class="form-label">Client ID</label>
|
||||
<input type="text" class="form-control" id="vispClientId" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispClientSecret" class="form-label">Client Secret</label>
|
||||
<input type="password" class="form-control" id="vispClientSecret" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispUsername" class="form-label">Appuser Username</label>
|
||||
<input type="text" class="form-control" id="vispUsername" autocomplete="username">
|
||||
<div class="form-text">Same credentials used to login to VISP UBO</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispPassword" class="form-label">Appuser Password</label>
|
||||
<input type="password" class="form-control" id="vispPassword" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispIspId" class="form-label">ISP ID (Tenant)</label>
|
||||
<input type="number" class="form-control" id="vispIspId" min="0" step="1">
|
||||
<div class="form-text">Leave blank to auto-select the first ISP ID returned by the token endpoint</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispTimeout" class="form-label">Request Timeout (seconds)</label>
|
||||
<input type="number" class="form-control" id="vispTimeout" min="15" step="5">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="vispOnlineUsersDomain" class="form-label">Online Users Domain (Optional)</label>
|
||||
<input type="text" class="form-control" id="vispOnlineUsersDomain" autocomplete="off">
|
||||
<div class="form-text">Used to enrich IPs via online RADIUS sessions if VISP is missing static IPs</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="saveVisp" class="btn btn-outline-primary">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script src="config_visp.js%CACHEBUSTERS%"></script>
|
||||
@@ -62,6 +62,7 @@ pub(super) fn static_routes() -> Result<Router> {
|
||||
"config_integration.html",
|
||||
"config_splynx.html",
|
||||
"config_netzur.html",
|
||||
"config_visp.html",
|
||||
"config_uisp.html",
|
||||
"config_powercode.html",
|
||||
"config_sonar.html",
|
||||
|
||||
+3
-1
@@ -11,7 +11,7 @@ from io import StringIO
|
||||
from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue_refresh_interval_mins, \
|
||||
automatic_import_powercode, automatic_import_sonar, influx_db_enabled, get_libreqos_directory, \
|
||||
blackboard_finish, blackboard_submit, automatic_import_wispgate, enable_insight_topology, insight_topology_role, \
|
||||
automatic_import_netzur, calculate_hash, scheduler_alive, scheduler_error, overrides_persistent_devices, overrides_circuit_adjustments, overrides_network_adjustments
|
||||
automatic_import_netzur, automatic_import_visp, calculate_hash, scheduler_alive, scheduler_error, overrides_persistent_devices, overrides_circuit_adjustments, overrides_network_adjustments
|
||||
|
||||
from apscheduler.schedulers.background import BlockingScheduler
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
@@ -96,6 +96,8 @@ def importFromCRM():
|
||||
scheduler_error(error_msg)
|
||||
elif automatic_import_splynx():
|
||||
run_python_integration("integrationSplynx", "importFromSplynx", label="Splynx")
|
||||
elif automatic_import_visp():
|
||||
run_python_integration("integrationVISP", "importFromVISP", label="VISP")
|
||||
elif automatic_import_netzur():
|
||||
run_python_integration("integrationNetzur", "importFromNetzur", label="Netzur")
|
||||
elif automatic_import_powercode():
|
||||
|
||||
Reference in new issue
Block a user