This commit is contained in:
rchac committed 2026-04-21 22:50:26 +00:00
1 parent 823450f1d2
commit 86f758d79a
4 files changed
+44 -2

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ Use esta integración cuando Sonar sea su fuente de verdad CRM/NMS.
- La integración refresca automáticamente los datos importados y de shaping que LibreQoS usa con Sonar.
- `network.json` queda para despliegues DIY o manuales.
- La integración importa IPs desde equipos de inventario, cuentas Radius e IPs asignadas directamente a la cuenta Sonar. Las IPs de Radius y de cuenta se agregan como dispositivos suplementarios de shaping y se deduplican contra las IPs de inventario.
- Las asignaciones IPv4 e IPv6 se separan en los campos correctos de LibreQoS. Los prefijos IPv6 de Sonar, como redes delegadas `/56`, se importan como asignaciones IPv6.
- Use la WebUI para confirmar que la importación terminó correctamente.
## Referencia completa
+1
View File
@@ -8,6 +8,7 @@ Current behavior notes:
- Paginated Sonar GraphQL requests now use a split connect/read timeout and retry transient read timeouts before failing the import.
- Emitted Sonar identities are namespaced (for example `sonar:account:<id>` and `sonar:device:<id>`) so they remain stable across overrides and downstream tooling.
- Account device discovery now preserves inventory-item IP handling and also imports Radius account and account-level IP assignments when they exist. Inventory-backed MACs are still used for AP mapping; Radius-only and account-level IPs are added as supplemental shaping devices and overlapping subnets are de-duplicated.
- IPv4 and IPv6 assignments are split into the correct LibreQoS fields. Sonar IPv6 prefixes such as delegated `/56` networks are imported as IPv6 assignments, not IPv4 device entries.
- Sonar `child_accounts` are also imported when they expose their own service and usable IP data. If a child account lacks its own address, LibreQoS falls back to the parent account address so the child can still be emitted as its own circuit.
- Sonar settings now support ISP-specific recurring-service fallback rates plus a recurring-service exclusion list. LibreQoS still prefers active `DATA` services first; recurring mappings are only used when an account has no usable `DATA` service.
- If Sonar returns non-JSON content or GraphQL errors, the integration now raises a more specific error message showing the endpoint and a short response preview.
+28 -1
View File
@@ -1,5 +1,6 @@
from pythonCheck import checkPythonVersion
checkPythonVersion()
import ipaddress
import requests
import subprocess
import time
@@ -227,6 +228,23 @@ def dedupeSubnets(subnets):
return deduped
def splitIpVersions(subnets):
ipv4 = []
ipv6 = []
invalid = []
for subnet in subnets or []:
try:
network = ipaddress.ip_network(subnet, strict=False)
except ValueError:
invalid.append(subnet)
continue
if network.version == 4:
ipv4.append(subnet)
else:
ipv6.append(subnet)
return ipv4, ipv6, invalid
def normalizeServiceName(name):
return (name or "").strip().casefold()
@@ -773,6 +791,8 @@ def buildMacToApMap(aps):
def sonarImportSummary(sites, aps, accounts):
total_devices = 0
devices_with_ips = 0
ipv4_assignments = 0
ipv6_assignments = 0
account_level_ip_devices = 0
accounts_with_parent = 0
for account in accounts:
@@ -782,12 +802,16 @@ def sonarImportSummary(sites, aps, accounts):
total_devices += 1
if len(device.get('ips') or []) > 0:
devices_with_ips += 1
ipv4, ipv6, _invalid = splitIpVersions(device.get('ips', []))
ipv4_assignments += len(ipv4)
ipv6_assignments += len(ipv6)
if device.get('source') == 'account_ip_assignments':
account_level_ip_devices += 1
return (
f"Sonar import summary: sites={len(sites)} aps={len(aps)} "
f"accounts={len(accounts)} accounts_with_ap_parent={accounts_with_parent} "
f"devices={total_devices} devices_with_ips={devices_with_ips} "
f"ipv4_assignments={ipv4_assignments} ipv6_assignments={ipv6_assignments} "
f"account_level_ip_devices={account_level_ip_devices}"
)
@@ -836,7 +860,10 @@ def createShaper():
net.addRawNode(customer)
for device in account['devices']:
libre_device = NetworkNode(id=device['id'], displayName=device['name'],parentId=account['id'],type=NodeType.device, ipv4=device['ips'],ipv6=[],mac=device['mac'])
ipv4, ipv6, invalid_ips = splitIpVersions(device.get('ips', []))
if invalid_ips:
print(f"Skipping invalid Sonar IP assignment(s) for {device['id']}: {invalid_ips}")
libre_device = NetworkNode(id=device['id'], displayName=device['name'],parentId=account['id'],type=NodeType.device, ipv4=ipv4,ipv6=ipv6,mac=device['mac'])
net.addRawNode(libre_device)
net.prepareTree()
+14 -1
View File
@@ -180,6 +180,17 @@ class TestIntegrationSonarDevices(unittest.TestCase):
self.assertEqual(devices[1]["mac"], "")
self.assertEqual(devices[1]["source"], "account_ip_assignments")
def test_split_ip_versions_preserves_ipv6_prefixes(self):
ipv4, ipv6, invalid = integrationSonar.splitIpVersions([
"100.64.8.80",
"2605:40c1:300::/56",
"not an ip",
])
self.assertEqual(ipv4, ["100.64.8.80"])
self.assertEqual(ipv6, ["2605:40c1:300::/56"])
self.assertEqual(invalid, ["not an ip"])
def test_account_level_ips_are_deduped_against_inventory_and_radius_ips(self):
devices = integrationSonar.buildAccountDevices(
sonar_account(
@@ -429,7 +440,7 @@ class TestIntegrationSonarDevices(unittest.TestCase):
},
{
"id": "sonar:account-ip-assignments:1",
"ips": ["100.64.11.11"],
"ips": ["100.64.11.11", "2605:40c1:300::/56"],
"source": "account_ip_assignments",
},
],
@@ -447,6 +458,8 @@ class TestIntegrationSonarDevices(unittest.TestCase):
self.assertIn("accounts_with_ap_parent=1", summary)
self.assertIn("devices=2", summary)
self.assertIn("devices_with_ips=1", summary)
self.assertIn("ipv4_assignments=1", summary)
self.assertIn("ipv6_assignments=1", summary)
self.assertIn("account_level_ip_devices=1", summary)