diff --git a/net-mgmt/topology-map/Makefile b/net-mgmt/topology-map/Makefile new file mode 100644 index 000000000..f50c639a9 --- /dev/null +++ b/net-mgmt/topology-map/Makefile @@ -0,0 +1,7 @@ +PLUGIN_NAME= topology-map +PLUGIN_VERSION= 1.0_1 +PLUGIN_COMMENT= Network topology mapper with LLDP/ARP/NDP discovery +PLUGIN_MAINTAINER= kuiper@local +PLUGIN_WWW= https://opnsense.org + +.include "../../Mk/plugins.mk" diff --git a/net-mgmt/topology-map/pkg-descr b/net-mgmt/topology-map/pkg-descr new file mode 100644 index 000000000..fde2f9bd9 --- /dev/null +++ b/net-mgmt/topology-map/pkg-descr @@ -0,0 +1,6 @@ +Network topology mapper for OPNsense. + +This plugin discovers local network nodes and links using LLDP, ARP and NDP, +then exposes a topology view and API endpoints suitable for dashboard summaries. + +WWW: diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/ServiceController.php b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/ServiceController.php new file mode 100644 index 000000000..e4f3d8cad --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/ServiceController.php @@ -0,0 +1,308 @@ +general; + } + + private function parseArp($output) + { + $items = []; + foreach (preg_split('/\r?\n/', (string)$output) as $line) { + if (preg_match('/\(([^\)]+)\)\s+at\s+([0-9a-f:]+)\s+on\s+(\S+)/i', $line, $m)) { + $items[] = [ + 'ip' => $m[1], + 'mac' => strtolower($m[2]), + 'if' => $m[3], + 'source' => 'arp' + ]; + } + } + return $items; + } + + private function parseNdp($output) + { + $items = []; + foreach (preg_split('/\r?\n/', (string)$output) as $line) { + if (preg_match('/^([0-9a-f:]+)\s+([0-9a-f:]+)\s+\S+\s+\S+\s+(\S+)$/i', trim($line), $m)) { + $items[] = [ + 'ip' => strtolower($m[1]), + 'mac' => strtolower($m[2]), + 'if' => $m[3], + 'source' => 'ndp' + ]; + } + } + return $items; + } + + private function parseLldp($output) + { + $items = []; + $current = null; + + foreach (preg_split('/\r?\n/', (string)$output) as $line) { + if (preg_match('/^Interface:\s*([^,]+),/i', $line, $m)) { + if ($current !== null) { + $items[] = $current; + } + $current = [ + 'if' => trim($m[1]), + 'sysname' => 'unknown', + 'port' => 'unknown', + 'source' => 'lldp' + ]; + continue; + } + + if ($current === null) { + continue; + } + + if (preg_match('/^\s*SysName:\s*(.+)$/i', $line, $m)) { + $current['sysname'] = trim($m[1]); + } elseif (preg_match('/^\s*PortID:\s*(.+)$/i', $line, $m)) { + $current['port'] = trim($m[1]); + } + } + + if ($current !== null) { + $items[] = $current; + } + + return $items; + } + + private function buildTopology($interfaces, $arpItems, $ndpItems, $lldpItems, $maxNodes) + { + $nodes = []; + $links = []; + + foreach ($interfaces as $if) { + $if = trim($if); + if ($if === '') { + continue; + } + $nodes['if:' . $if] = [ + 'id' => 'if:' . $if, + 'label' => $if, + 'type' => 'interface' + ]; + } + + $neighborItems = array_merge($arpItems, $ndpItems); + foreach ($neighborItems as $item) { + $ifId = 'if:' . $item['if']; + $hostId = 'host:' . $item['ip']; + if (!isset($nodes[$hostId])) { + $nodes[$hostId] = [ + 'id' => $hostId, + 'label' => $item['ip'], + 'ip' => $item['ip'], + 'mac' => $item['mac'], + 'type' => 'host', + 'source' => $item['source'] + ]; + } + $links[] = [ + 'from' => $ifId, + 'to' => $hostId, + 'type' => $item['source'] + ]; + } + + foreach ($lldpItems as $item) { + $ifId = 'if:' . $item['if']; + $devId = 'lldp:' . $item['if'] . ':' . md5($item['sysname'] . ':' . $item['port']); + if (!isset($nodes[$devId])) { + $nodes[$devId] = [ + 'id' => $devId, + 'label' => $item['sysname'], + 'port' => $item['port'], + 'type' => 'lldp-neighbor', + 'source' => 'lldp' + ]; + } + $links[] = [ + 'from' => $ifId, + 'to' => $devId, + 'type' => 'lldp' + ]; + } + + $nodes = array_values($nodes); + if (count($nodes) > $maxNodes) { + $nodes = array_slice($nodes, 0, $maxNodes); + } + + // Keep only links that still point to nodes after node capping. + $allowedNodeIds = []; + foreach ($nodes as $node) { + $allowedNodeIds[$node['id']] = true; + } + + $links = array_values(array_filter($links, function ($link) use ($allowedNodeIds) { + return isset($allowedNodeIds[$link['from']]) && isset($allowedNodeIds[$link['to']]); + })); + + return ['nodes' => $nodes, 'links' => $links]; + } + + private function collectDiscoveryData() + { + $settings = $this->getSettings(); + if ((string)$settings->enabled === '0') { + return ['status' => 'failed', 'message' => 'Topology mapper is disabled']; + } + + $backend = new Backend(); + $interfacesRaw = trim($backend->configdRun('topologymap interfaces')); + $interfaces = preg_split('/\s+/', $interfacesRaw); + + $arpItems = []; + $ndpItems = []; + $lldpItems = []; + + if ((string)$settings->useArp === '1') { + $arpItems = $this->parseArp($backend->configdRun('topologymap arp')); + } + + if ((string)$settings->useNdp === '1') { + $ndpItems = $this->parseNdp($backend->configdRun('topologymap ndp')); + } + + if ((string)$settings->useLldp === '1') { + $lldpItems = $this->parseLldp($backend->configdRun('topologymap lldp')); + } + + $maxNodes = (int)$settings->maxNodes; + if ($maxNodes < 1) { + $maxNodes = 500; + } + + $topology = $this->buildTopology($interfaces, $arpItems, $ndpItems, $lldpItems, $maxNodes); + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'summary' => [ + 'interfaces' => count(array_filter($interfaces)), + 'hosts' => count($arpItems) + count($ndpItems), + 'neighbors' => count($lldpItems), + 'nodes' => count($topology['nodes']), + 'links' => count($topology['links']) + ], + 'topology' => $topology + ]; + } + + private function buildGeoDataset($topology) + { + $points = []; + + foreach (($topology['nodes'] ?? []) as $node) { + if (($node['type'] ?? '') !== 'host') { + continue; + } + + $ip = (string)($node['ip'] ?? ''); + if (!$this->isPublicIp($ip)) { + continue; + } + + // Coordinates are intentionally null when no local geolocation provider is configured. + $points[] = [ + 'id' => $node['id'], + 'label' => $node['label'] ?? $ip, + 'ip' => $ip, + 'lat' => null, + 'lon' => null, + 'provider' => 'none' + ]; + } + + return $points; + } + + public function discoverAction() + { + if (!$this->request->isPost()) { + return ['status' => 'failed', 'message' => 'POST required']; + } + + $resp = $this->collectDiscoveryData(); + if (($resp['status'] ?? 'failed') !== 'ok') { + return $resp; + } + + $settings = $resp['settings']; + $topology = $resp['topology']; + $geoPoints = ((string)$settings->showGeoMap === '1') ? $this->buildGeoDataset($topology) : []; + + return [ + 'status' => 'ok', + 'summary' => $resp['summary'], + 'topology' => $topology, + 'geomap' => $geoPoints, + 'meta' => [ + 'geo_enabled' => ((string)$settings->showGeoMap === '1') ? 'yes' : 'no', + 'geo_points' => count($geoPoints) + ] + ]; + } + + public function summaryAction() + { + $resp = $this->collectDiscoveryData(); + if (!is_array($resp) || ($resp['status'] ?? 'failed') !== 'ok') { + return ['status' => 'failed']; + } + + return ['status' => 'ok', 'summary' => $resp['summary']]; + } + + public function geomapAction() + { + $resp = $this->collectDiscoveryData(); + if (!is_array($resp) || ($resp['status'] ?? 'failed') !== 'ok') { + return ['status' => 'failed']; + } + + if ((string)$resp['settings']->showGeoMap !== '1') { + return [ + 'status' => 'ok', + 'enabled' => 'no', + 'points' => [] + ]; + } + + return [ + 'status' => 'ok', + 'enabled' => 'yes', + 'points' => $this->buildGeoDataset($resp['topology']) + ]; + } +} diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/SettingsController.php b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/SettingsController.php new file mode 100644 index 000000000..d47f51a02 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/Api/SettingsController.php @@ -0,0 +1,11 @@ +view->settings = $this->getForm('settings'); + $this->view->pick('OPNsense/TopologyMap/index'); + } +} diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/forms/settings.xml b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/forms/settings.xml new file mode 100644 index 000000000..d6b650db8 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/controllers/OPNsense/TopologyMap/forms/settings.xml @@ -0,0 +1,38 @@ +
+ + topologymap.general.enabled + + checkbox + Enable topology discovery and API endpoints. + + + topologymap.general.useLldp + + checkbox + Use LLDP neighbors when lldpd is available. + + + topologymap.general.useArp + + checkbox + Discover IPv4 neighbors from ARP table. + + + topologymap.general.useNdp + + checkbox + Discover IPv6 neighbors from NDP table. + + + topologymap.general.maxNodes + + text + Hard limit for returned node count to protect UI performance. + + + topologymap.general.showGeoMap + + checkbox + Expose geo map dataset for public/external IPs. + +
diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/ACL/ACL.xml b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/ACL/ACL.xml new file mode 100644 index 000000000..723c68e82 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/ACL/ACL.xml @@ -0,0 +1,16 @@ + + + Services: Topology Map + + ui/topologymap/* + api/topologymap/* + + + + Dashboard: Topology Map widget + + api/topologymap/service/summary + api/topologymap/service/geomap + + + diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/Menu/Menu.xml b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/Menu/Menu.xml new file mode 100644 index 000000000..645c58633 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/Menu/Menu.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/TopologyMap.php b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/TopologyMap.php new file mode 100644 index 000000000..c55777459 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/models/OPNsense/TopologyMap/TopologyMap.php @@ -0,0 +1,9 @@ + + //OPNsense/TopologyMap + 1.0.0 + Topology mapper settings + + + + 1 + Y + + + 1 + Y + + + 1 + Y + + + 1 + Y + + + 500 + 50 + 5000 + Y + + + 1 + Y + + + + diff --git a/net-mgmt/topology-map/src/opnsense/mvc/app/views/OPNsense/TopologyMap/index.volt b/net-mgmt/topology-map/src/opnsense/mvc/app/views/OPNsense/TopologyMap/index.volt new file mode 100644 index 000000000..41a0cc162 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/mvc/app/views/OPNsense/TopologyMap/index.volt @@ -0,0 +1,133 @@ + + + + + + +
+
+
+ + + + + +
{{ lang._('Discovery Summary') }}
+ {{ lang._('Interfaces') }}: 0 | + {{ lang._('Hosts') }}: 0 | + {{ lang._('LLDP Neighbors') }}: 0 | + {{ lang._('Nodes') }}: 0 | + {{ lang._('Links') }}: 0 | + {{ lang._('Geo Points') }}: 0 +
+
+
+
+ +
+ {{ partial("layout_partials/base_form",['fields':settings,'id':'frm_topologymap'])}} +
+ +
+ + +
+ +
+
+
+ + + +
{{ lang._('Nodes') }}
+
+
+
+
+ + + +
{{ lang._('Links') }}
+
+
+
diff --git a/net-mgmt/topology-map/src/opnsense/service/conf/actions.d/actions_topologymap.conf b/net-mgmt/topology-map/src/opnsense/service/conf/actions.d/actions_topologymap.conf new file mode 100644 index 000000000..e73fa1d76 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/service/conf/actions.d/actions_topologymap.conf @@ -0,0 +1,27 @@ +[lldp] +command:command -v lldpctl >/dev/null 2>&1 && lldpctl 2>/dev/null || true +type:script_output +description:Get LLDP neighbors +message:Getting LLDP neighbors +errors:no + +[arp] +command:arp -an +type:script_output +description:Get ARP table +message:Getting ARP table +errors:no + +[ndp] +command:ndp -an +type:script_output +description:Get NDP table +message:Getting NDP table +errors:no + +[interfaces] +command:ifconfig -l +type:script_output +description:Get interface list +message:Getting interface list +errors:no diff --git a/net-mgmt/topology-map/src/opnsense/www/js/widgets/Metadata/TopologyMap.xml b/net-mgmt/topology-map/src/opnsense/www/js/widgets/Metadata/TopologyMap.xml new file mode 100644 index 000000000..5e2f2f385 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/www/js/widgets/Metadata/TopologyMap.xml @@ -0,0 +1,18 @@ + + + TopologyMap.js + + /api/topologymap/service/summary + /api/topologymap/service/geomap + + + Topology Map + Interfaces + Hosts + LLDP Neighbors + Nodes + Links + Geo Points + + + diff --git a/net-mgmt/topology-map/src/opnsense/www/js/widgets/TopologyMap.js b/net-mgmt/topology-map/src/opnsense/www/js/widgets/TopologyMap.js new file mode 100644 index 000000000..5615ed9d4 --- /dev/null +++ b/net-mgmt/topology-map/src/opnsense/www/js/widgets/TopologyMap.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 + * All rights reserved. + */ + +export default class TopologyMap extends BaseTableWidget { + constructor() { + super(); + this.tickTimeout = 60; + } + + getMarkup() { + let $container = $('
'); + $container.append(this.createTable('topologymap-table', { + headerPosition: 'left' + })); + return $container; + } + + async onMarkupRendered() { + const rows = [ + [[this.translations.interfaces], $('').prop('outerHTML')], + [[this.translations.hosts], $('').prop('outerHTML')], + [[this.translations.neighbors], $('').prop('outerHTML')], + [[this.translations.nodes], $('').prop('outerHTML')], + [[this.translations.links], $('').prop('outerHTML')], + [[this.translations.geoPoints], $('').prop('outerHTML')] + ]; + + super.updateTable('topologymap-table', rows); + } + + async onWidgetTick() { + const summary = await this.ajaxCall('/api/topologymap/service/summary'); + const geomap = await this.ajaxCall('/api/topologymap/service/geomap'); + + if (!summary || summary.status !== 'ok' || !summary.summary) { + ['interfaces', 'hosts', 'neighbors', 'nodes', 'links'].forEach((name) => { + $('#tm-' + name).text('-'); + }); + $('#tm-geo-points').text('-'); + return; + } + + $('#tm-interfaces').text(summary.summary.interfaces ?? '-'); + $('#tm-hosts').text(summary.summary.hosts ?? '-'); + $('#tm-neighbors').text(summary.summary.neighbors ?? '-'); + $('#tm-nodes').text(summary.summary.nodes ?? '-'); + $('#tm-links').text(summary.summary.links ?? '-'); + + if (geomap && geomap.status === 'ok' && Array.isArray(geomap.points)) { + $('#tm-geo-points').text(geomap.points.length); + } else { + $('#tm-geo-points').text('0'); + } + } +}