Add topology map plugin: initial implementation with discovery features and UI integration

This commit is contained in:
Kuiper 2026-04-12 01:52:47 -03:00
parent 15a8c47f72
commit 8a546533f0
14 changed files with 684 additions and 0 deletions

View file

@ -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"

View file

@ -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: <https://opnsense.org>

View file

@ -0,0 +1,308 @@
<?php
namespace OPNsense\TopologyMap\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\TopologyMap\TopologyMap;
class ServiceController extends ApiControllerBase
{
private function isPublicIp($ip)
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
private function getSettings()
{
$model = new TopologyMap();
return $model->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'])
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace OPNsense\TopologyMap\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
class SettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelName = 'topologymap';
protected static $internalModelClass = 'OPNsense\\TopologyMap\\TopologyMap';
}

View file

@ -0,0 +1,12 @@
<?php
namespace OPNsense\TopologyMap;
class IndexController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->settings = $this->getForm('settings');
$this->view->pick('OPNsense/TopologyMap/index');
}
}

View file

@ -0,0 +1,38 @@
<form>
<field>
<id>topologymap.general.enabled</id>
<label>Enable topology mapper</label>
<type>checkbox</type>
<help>Enable topology discovery and API endpoints.</help>
</field>
<field>
<id>topologymap.general.useLldp</id>
<label>Use LLDP discovery</label>
<type>checkbox</type>
<help>Use LLDP neighbors when lldpd is available.</help>
</field>
<field>
<id>topologymap.general.useArp</id>
<label>Use ARP discovery</label>
<type>checkbox</type>
<help>Discover IPv4 neighbors from ARP table.</help>
</field>
<field>
<id>topologymap.general.useNdp</id>
<label>Use NDP discovery</label>
<type>checkbox</type>
<help>Discover IPv6 neighbors from NDP table.</help>
</field>
<field>
<id>topologymap.general.maxNodes</id>
<label>Maximum nodes</label>
<type>text</type>
<help>Hard limit for returned node count to protect UI performance.</help>
</field>
<field>
<id>topologymap.general.showGeoMap</id>
<label>Enable geo map API output</label>
<type>checkbox</type>
<help>Expose geo map dataset for public/external IPs.</help>
</field>
</form>

View file

@ -0,0 +1,16 @@
<acl>
<page-user-topologymap>
<name>Services: Topology Map</name>
<patterns>
<pattern>ui/topologymap/*</pattern>
<pattern>api/topologymap/*</pattern>
</patterns>
</page-user-topologymap>
<page-dashboard-widget-topologymap>
<name>Dashboard: Topology Map widget</name>
<patterns>
<pattern>api/topologymap/service/summary</pattern>
<pattern>api/topologymap/service/geomap</pattern>
</patterns>
</page-dashboard-widget-topologymap>
</acl>

View file

@ -0,0 +1,7 @@
<menu>
<Services>
<TopologyMap VisibleName="Topology Map" cssClass="fa fa-sitemap">
<Settings url="/ui/topologymap"/>
</TopologyMap>
</Services>
</menu>

View file

@ -0,0 +1,9 @@
<?php
namespace OPNsense\TopologyMap;
use OPNsense\Base\BaseModel;
class TopologyMap extends BaseModel
{
}

View file

@ -0,0 +1,35 @@
<model>
<mount>//OPNsense/TopologyMap</mount>
<version>1.0.0</version>
<description>Topology mapper settings</description>
<items>
<general>
<enabled type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enabled>
<useLldp type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</useLldp>
<useArp type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</useArp>
<useNdp type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</useNdp>
<maxNodes type="IntegerField">
<Default>500</Default>
<MinimumValue>50</MinimumValue>
<MaximumValue>5000</MaximumValue>
<Required>Y</Required>
</maxNodes>
<showGeoMap type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</showGeoMap>
</general>
</items>
</model>

View file

@ -0,0 +1,133 @@
<script>
$(document).ready(function () {
function renderSummary(summary) {
$('#summaryInterfaces').text(summary.interfaces || 0);
$('#summaryHosts').text(summary.hosts || 0);
$('#summaryNeighbors').text(summary.neighbors || 0);
$('#summaryNodes').text(summary.nodes || 0);
$('#summaryLinks').text(summary.links || 0);
$('#summaryGeoPoints').text(summary.geoPoints || 0);
}
function renderTable(selector, rows, cols) {
var html = '<table class="table table-striped __nomb"><thead><tr>';
for (var i = 0; i < cols.length; i++) {
html += '<th>' + cols[i].label + '</th>';
}
html += '</tr></thead><tbody>';
for (var r = 0; r < rows.length; r++) {
html += '<tr>';
for (var c = 0; c < cols.length; c++) {
var key = cols[c].key;
var value = rows[r][key] || '';
html += '<td>' + $('<div/>').text(value).html() + '</td>';
}
html += '</tr>';
}
html += '</tbody></table>';
$(selector).html(html);
}
function loadData() {
ajaxCall('/api/topologymap/service/discover', {}, function (data, status) {
if (status !== 'success' || data['status'] !== 'ok') {
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(data['message'] || '{{ lang._('Unable to load topology data.') }}');
return;
}
$('#responseMsg').addClass('hidden').removeClass('alert-danger').html('');
var summary = data['summary'] || {};
summary.geoPoints = (data['meta'] && data['meta']['geo_points']) ? data['meta']['geo_points'] : 0;
renderSummary(summary);
var nodes = (data['topology'] && data['topology']['nodes']) ? data['topology']['nodes'] : [];
var links = (data['topology'] && data['topology']['links']) ? data['topology']['links'] : [];
renderTable('#nodesTable', nodes, [
{key: 'label', label: '{{ lang._('Node') }}'},
{key: 'type', label: '{{ lang._('Type') }}'},
{key: 'ip', label: '{{ lang._('IP') }}'},
{key: 'mac', label: '{{ lang._('MAC') }}'},
{key: 'source', label: '{{ lang._('Source') }}'}
]);
renderTable('#linksTable', links, [
{key: 'from', label: '{{ lang._('From') }}'},
{key: 'to', label: '{{ lang._('To') }}'},
{key: 'type', label: '{{ lang._('Type') }}'}
]);
});
}
mapDataToFormUI({'frm_topologymap': '/api/topologymap/settings/get'});
$('#saveAct').click(function () {
saveFormToEndpoint('/api/topologymap/settings/set', 'frm_topologymap', function () {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html('{{ lang._('Settings saved.') }}');
loadData();
});
});
$('#refreshAct').click(function () {
loadData();
});
loadData();
});
</script>
<div class="alert alert-info" role="alert">
{{ lang._('Automatic topology mapping using LLDP, ARP and NDP discovery. Geo map output is available for future dashboard/map widgets.') }}
</div>
<div class="alert hidden" role="alert" id="responseMsg"></div>
<div class="row">
<div class="col-md-12">
<div class="content-box tab-content table-responsive">
<table class="table table-striped __nomb" style="margin-bottom:0">
<tr><th class="listtopic">{{ lang._('Discovery Summary') }}</th></tr>
<tr>
<td>
<strong>{{ lang._('Interfaces') }}:</strong> <span id="summaryInterfaces">0</span> |
<strong>{{ lang._('Hosts') }}:</strong> <span id="summaryHosts">0</span> |
<strong>{{ lang._('LLDP Neighbors') }}:</strong> <span id="summaryNeighbors">0</span> |
<strong>{{ lang._('Nodes') }}:</strong> <span id="summaryNodes">0</span> |
<strong>{{ lang._('Links') }}:</strong> <span id="summaryLinks">0</span> |
<strong>{{ lang._('Geo Points') }}:</strong> <span id="summaryGeoPoints">0</span>
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-12" style="margin-top: 10px;">
{{ partial("layout_partials/base_form",['fields':settings,'id':'frm_topologymap'])}}
</div>
<div class="col-md-12" style="margin-top: 10px;">
<button class="btn btn-primary" id="saveAct" type="button"><b>{{ lang._('Save') }}</b></button>
<button class="btn btn-default" id="refreshAct" type="button"><b>{{ lang._('Refresh Discovery') }}</b></button>
</div>
<div class="row" style="margin-top: 10px;">
<div class="col-md-6">
<div class="content-box tab-content table-responsive">
<table class="table table-striped __nomb" style="margin-bottom:0">
<tr><th class="listtopic">{{ lang._('Nodes') }}</th></tr>
<tr><td id="nodesTable"></td></tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="content-box tab-content table-responsive">
<table class="table table-striped __nomb" style="margin-bottom:0">
<tr><th class="listtopic">{{ lang._('Links') }}</th></tr>
<tr><td id="linksTable"></td></tr>
</table>
</div>
</div>
</div>

View file

@ -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

View file

@ -0,0 +1,18 @@
<metadata>
<topologymap>
<filename>TopologyMap.js</filename>
<endpoints>
<endpoint>/api/topologymap/service/summary</endpoint>
<endpoint>/api/topologymap/service/geomap</endpoint>
</endpoints>
<translations>
<title>Topology Map</title>
<interfaces>Interfaces</interfaces>
<hosts>Hosts</hosts>
<neighbors>LLDP Neighbors</neighbors>
<nodes>Nodes</nodes>
<links>Links</links>
<geoPoints>Geo Points</geoPoints>
</translations>
</topologymap>
</metadata>

View file

@ -0,0 +1,57 @@
/*
* Copyright (C) 2026
* All rights reserved.
*/
export default class TopologyMap extends BaseTableWidget {
constructor() {
super();
this.tickTimeout = 60;
}
getMarkup() {
let $container = $('<div></div>');
$container.append(this.createTable('topologymap-table', {
headerPosition: 'left'
}));
return $container;
}
async onMarkupRendered() {
const rows = [
[[this.translations.interfaces], $('<span id="tm-interfaces">').prop('outerHTML')],
[[this.translations.hosts], $('<span id="tm-hosts">').prop('outerHTML')],
[[this.translations.neighbors], $('<span id="tm-neighbors">').prop('outerHTML')],
[[this.translations.nodes], $('<span id="tm-nodes">').prop('outerHTML')],
[[this.translations.links], $('<span id="tm-links">').prop('outerHTML')],
[[this.translations.geoPoints], $('<span id="tm-geo-points">').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');
}
}
}