This commit is contained in:
Kuiper 2026-05-25 09:41:45 +08:00 committed by GitHub
commit fa5c56732d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2076 additions and 0 deletions

View file

@ -0,0 +1,7 @@
PLUGIN_NAME= telegram-notify
PLUGIN_VERSION= 1.0.11
PLUGIN_COMMENT= Telegram notification manager
PLUGIN_MAINTAINER= kuiper@local
PLUGIN_WWW= https://core.telegram.org/bots/api
.include "../../Mk/plugins.mk"

View file

@ -0,0 +1,7 @@
Telegram notification manager for OPNsense.
This plugin provides a simple UI to configure a Telegram bot token,
destination chat and optional send settings, then test notification
delivery directly from the OPNsense web interface.
WWW: <https://core.telegram.org/bots/api>

View file

@ -0,0 +1,312 @@
<?php
namespace OPNsense\TelegramNotify\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\Cron\Cron;
use OPNsense\Core\Config;
use OPNsense\TelegramNotify\TelegramNotify;
class ServiceController extends ApiControllerBase
{
private static $validEvents = [
'system', 'gateway', 'service', 'vpn', 'security', 'updates',
];
private static $eventFields = [
'system' => 'eventSystem',
'gateway' => 'eventGateway',
'service' => 'eventService',
'vpn' => 'eventVpn',
'security' => 'eventSecurity',
'updates' => 'eventUpdates',
];
public function testAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST required'];
}
$model = new TelegramNotify();
$general = $model->general;
if ((string)$general->enabled === '0') {
return ['status' => 'failed', 'message' => 'Telegram notifications are disabled'];
}
if (trim((string)$general->botToken) === '' || trim((string)$general->chatId) === '') {
return ['status' => 'failed', 'message' => 'Bot token and Chat ID are required'];
}
$eventType = strtolower(trim((string)$this->request->getPost('event_type')));
if ($eventType === '') {
$eventType = 'system';
}
if (!in_array($eventType, self::$validEvents, true)) {
return ['status' => 'failed', 'message' => 'Invalid event type'];
}
$eventField = self::$eventFields[$eventType];
if ((string)$general->$eventField === '0') {
return ['status' => 'failed', 'message' => 'Selected event type is disabled in settings'];
}
$message = trim((string)$this->request->getPost('message'));
if ($message === '') {
$message = 'OPNsense test notification from Telegram Notify plugin.';
}
// Delegate the HTTP call to configd (runs as root). The backend script
// uses drill @DNS to resolve api.telegram.org and curl --resolve to
// bypass the system resolver, which avoids DNS timeouts from PHP-FPM.
$raw = trim((new Backend())->configdpRun(
'telegramnotify send',
[$eventType, base64_encode($message)]
));
if ($raw === '') {
return ['status' => 'failed', 'message' => 'No response from backend (configd may not have loaded the new action yet — wait a few seconds and try again)'];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return ['status' => 'failed', 'message' => 'Backend returned unexpected output: ' . substr($raw, 0, 200)];
}
if (!empty($decoded['ok'])) {
return ['status' => 'ok', 'message' => 'Test message sent successfully', 'event_type' => $eventType];
}
$apiMessage = !empty($decoded['description']) ? $decoded['description'] : 'Unknown Telegram API error';
return ['status' => 'failed', 'message' => $apiMessage];
}
public function enableIdsCronAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST required'];
}
$model = new TelegramNotify();
$backend = new Backend();
$cronModel = new Cron();
$existingUuid = trim((string)$model->general->idsPollCron);
if ($existingUuid !== '' && $cronModel->getNodeByReference('jobs.job.' . $existingUuid) != null) {
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'IDS auto alerts cron rule already exists',
'uuid' => $existingUuid
];
}
foreach ($cronModel->getNodeByReference('jobs.job')->iterateItems() as $cronItem) {
if ((string)$cronItem->origin === 'TelegramNotify' && (string)$cronItem->command === 'telegramnotify ids.poll') {
$foundUuid = $cronItem->getAttributes()['uuid'];
$model->general->idsPollCron = $foundUuid;
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'Linked existing IDS auto alerts cron rule',
'uuid' => $foundUuid
];
}
}
$uuid = $cronModel->newDailyJob(
'TelegramNotify',
'telegramnotify ids.poll',
'Poll IDS alerts and send Telegram notifications',
'5',
'1'
);
$cronJob = $cronModel->getNodeByReference('jobs.job.' . $uuid);
if ($cronJob != null) {
$cronJob->minutes = '*';
$cronJob->hours = '*';
$cronJob->days = '*';
$cronJob->months = '*';
$cronJob->weekdays = '*';
}
if ($cronModel->performValidation()->count() > 0) {
return ['status' => 'failed', 'message' => 'Unable to add IDS auto alerts cron rule'];
}
$cronModel->serializeToConfig();
$model->general->idsPollCron = $uuid;
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
$backend->configdRun('template reload OPNsense/Cron');
$backend->configdRun('cron restart');
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'IDS auto alerts cron rule created (runs every minute, max 5 alerts per run)',
'uuid' => $uuid
];
}
public function enableMonitCronAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST required'];
}
$model = new TelegramNotify();
$backend = new Backend();
$cronModel = new Cron();
$existingUuid = trim((string)$model->general->monitPollCron);
if ($existingUuid !== '' && $cronModel->getNodeByReference('jobs.job.' . $existingUuid) != null) {
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'Monit auto alerts cron rule already exists',
'uuid' => $existingUuid
];
}
foreach ($cronModel->getNodeByReference('jobs.job')->iterateItems() as $cronItem) {
if ((string)$cronItem->origin === 'TelegramNotify' && (string)$cronItem->command === 'telegramnotify monit.poll') {
$foundUuid = $cronItem->getAttributes()['uuid'];
$model->general->monitPollCron = $foundUuid;
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'Linked existing Monit auto alerts cron rule',
'uuid' => $foundUuid
];
}
}
$uuid = $cronModel->newDailyJob(
'TelegramNotify',
'telegramnotify monit.poll',
'Poll Monit alerts and send Telegram notifications',
'3',
'1'
);
$cronJob = $cronModel->getNodeByReference('jobs.job.' . $uuid);
if ($cronJob != null) {
$cronJob->minutes = '*';
$cronJob->hours = '*';
$cronJob->days = '*';
$cronJob->months = '*';
$cronJob->weekdays = '*';
}
if ($cronModel->performValidation()->count() > 0) {
return ['status' => 'failed', 'message' => 'Unable to add Monit auto alerts cron rule'];
}
$cronModel->serializeToConfig();
$model->general->monitPollCron = $uuid;
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
$backend->configdRun('template reload OPNsense/Cron');
$backend->configdRun('cron restart');
return [
'status' => 'ok',
'result' => 'ok',
'message' => 'Monit auto alerts cron rule created (runs every minute, max 3 alerts per run)',
'uuid' => $uuid
];
}
public function disableIdsCronAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST required'];
}
$model = new TelegramNotify();
$backend = new Backend();
$cronModel = new Cron();
$deleted = false;
$storedUuid = trim((string)$model->general->idsPollCron);
if ($storedUuid !== '') {
if ($cronModel->jobs->job->del($storedUuid)) {
$deleted = true;
}
}
foreach ($cronModel->getNodeByReference('jobs.job')->iterateItems() as $cronItem) {
if ((string)$cronItem->origin === 'TelegramNotify' && (string)$cronItem->command === 'telegramnotify ids.poll') {
$uuid = $cronItem->getAttributes()['uuid'];
if ($cronModel->jobs->job->del($uuid)) {
$deleted = true;
}
}
}
$model->general->idsPollCron = '';
$cronModel->serializeToConfig();
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
$backend->configdRun('template reload OPNsense/Cron');
$backend->configdRun('cron restart');
return [
'status' => 'ok',
'result' => 'ok',
'message' => $deleted ? 'IDS auto alerts cron rule disabled' : 'IDS auto alerts cron rule was already disabled'
];
}
public function disableMonitCronAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed', 'message' => 'POST required'];
}
$model = new TelegramNotify();
$backend = new Backend();
$cronModel = new Cron();
$deleted = false;
$storedUuid = trim((string)$model->general->monitPollCron);
if ($storedUuid !== '') {
if ($cronModel->jobs->job->del($storedUuid)) {
$deleted = true;
}
}
foreach ($cronModel->getNodeByReference('jobs.job')->iterateItems() as $cronItem) {
if ((string)$cronItem->origin === 'TelegramNotify' && (string)$cronItem->command === 'telegramnotify monit.poll') {
$uuid = $cronItem->getAttributes()['uuid'];
if ($cronModel->jobs->job->del($uuid)) {
$deleted = true;
}
}
}
$model->general->monitPollCron = '';
$cronModel->serializeToConfig();
$model->serializeToConfig($validateFullModel = false, $disable_validation = true);
Config::getInstance()->save();
$backend->configdRun('template reload OPNsense/Cron');
$backend->configdRun('cron restart');
return [
'status' => 'ok',
'result' => 'ok',
'message' => $deleted ? 'Monit auto alerts cron rule disabled' : 'Monit auto alerts cron rule was already disabled'
];
}
}

View file

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

View file

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

View file

@ -0,0 +1,86 @@
<form>
<field>
<id>telegramnotify.general.enabled</id>
<label>Enable Telegram notifications</label>
<type>checkbox</type>
<help>Enable or disable Telegram notifications.</help>
</field>
<field>
<id>telegramnotify.general.botToken</id>
<label>Bot Token</label>
<type>password</type>
<help>Telegram bot token from BotFather.</help>
</field>
<field>
<id>telegramnotify.general.chatId</id>
<label>Chat ID</label>
<type>text</type>
<help>Destination chat ID or channel ID.</help>
</field>
<field>
<id>telegramnotify.general.threadId</id>
<label>Thread ID (optional)</label>
<type>text</type>
<help>Telegram topic thread ID for forum groups.</help>
</field>
<field>
<id>telegramnotify.general.parseMode</id>
<label>Parse Mode</label>
<type>dropdown</type>
<help>Message parse mode used by Telegram.</help>
</field>
<field>
<id>telegramnotify.general.disableWebPagePreview</id>
<label>Disable Web Page Preview</label>
<type>checkbox</type>
<help>Disable automatic link previews in messages.</help>
</field>
<field>
<id>telegramnotify.general.disableNotification</id>
<label>Send Silently</label>
<type>checkbox</type>
<help>Send messages without notification sound.</help>
</field>
<field>
<id>telegramnotify.general.dnsServer</id>
<label>DNS Server (optional)</label>
<type>text</type>
<help>Custom DNS server for resolving api.telegram.org (e.g. 8.8.8.8). Leave empty to use the system resolver. Use this if the firewall itself has DNS issues.</help>
</field>
<field>
<id>telegramnotify.general.eventSystem</id>
<label>System Events</label>
<type>checkbox</type>
<help>Allow system-related events.</help>
</field>
<field>
<id>telegramnotify.general.eventGateway</id>
<label>Gateway Events</label>
<type>checkbox</type>
<help>Allow gateway status and reachability events.</help>
</field>
<field>
<id>telegramnotify.general.eventService</id>
<label>Service Events</label>
<type>checkbox</type>
<help>Allow service start, stop and failure events.</help>
</field>
<field>
<id>telegramnotify.general.eventVpn</id>
<label>VPN Events</label>
<type>checkbox</type>
<help>Allow VPN tunnel and peer status events.</help>
</field>
<field>
<id>telegramnotify.general.eventSecurity</id>
<label>Security Events</label>
<type>checkbox</type>
<help>Allow security and intrusion-related events.</help>
</field>
<field>
<id>telegramnotify.general.eventUpdates</id>
<label>Update Events</label>
<type>checkbox</type>
<help>Allow firmware and package update events.</help>
</field>
</form>

View file

@ -0,0 +1,9 @@
<acl>
<page-user-telegramnotify>
<name>Services: Telegram Notify</name>
<patterns>
<pattern>ui/telegramnotify/*</pattern>
<pattern>api/telegramnotify/*</pattern>
</patterns>
</page-user-telegramnotify>
</acl>

View file

@ -0,0 +1,7 @@
<menu>
<Services>
<TelegramNotify VisibleName="Telegram Notify" cssClass="fa fa-paper-plane">
<Settings url="/ui/telegramnotify"/>
</TelegramNotify>
</Services>
</menu>

View file

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

View file

@ -0,0 +1,72 @@
<model>
<mount>//OPNsense/TelegramNotify</mount>
<version>1.0.0</version>
<description>Telegram notification settings</description>
<items>
<general>
<enabled type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enabled>
<botToken type="TextField">
<Required>N</Required>
</botToken>
<chatId type="TextField">
<Required>N</Required>
</chatId>
<threadId type="IntegerField">
<MinimumValue>1</MinimumValue>
<Required>N</Required>
</threadId>
<parseMode type="OptionField">
<Default>None</Default>
<OptionValues>
<None>None</None>
<MarkdownV2>MarkdownV2</MarkdownV2>
<HTML>HTML</HTML>
</OptionValues>
</parseMode>
<disableWebPagePreview type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</disableWebPagePreview>
<disableNotification type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</disableNotification>
<dnsServer type="TextField">
<Required>N</Required>
</dnsServer>
<idsPollCron type="TextField">
<Required>N</Required>
</idsPollCron>
<monitPollCron type="TextField">
<Required>N</Required>
</monitPollCron>
<eventSystem type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventSystem>
<eventGateway type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventGateway>
<eventService type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventService>
<eventVpn type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventVpn>
<eventSecurity type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventSecurity>
<eventUpdates type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</eventUpdates>
</general>
</items>
</model>

View file

@ -0,0 +1,142 @@
<script>
$(document).ready(function () {
mapDataToFormUI({'frm_telegramnotify': '/api/telegramnotify/settings/get'});
$('#saveAct').click(function () {
saveFormToEndpoint('/api/telegramnotify/settings/set', 'frm_telegramnotify', function () {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html('{{ lang._('Settings saved.') }}');
});
});
$('#testAct').click(function () {
var msg = $('#testMessage').val();
var eventType = $('#testEventType').val();
ajaxCall('/api/telegramnotify/service/test', {'message': msg, 'event_type': eventType}, function (data, status) {
if (status === 'success' && data['status'] === 'ok') {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html(data['message']);
} else {
var err = (data && data['message']) ? data['message'] : '{{ lang._('Test failed.') }}';
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(err);
}
});
});
$('#enableIdsAutoAct').click(function () {
ajaxCall('/api/telegramnotify/service/enableIdsCron', {}, function (data, status) {
if (status === 'success' && (data['status'] === 'ok' || data['result'] === 'ok')) {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html(data['message'] || '{{ lang._('IDS auto alerts cron rule created.') }}');
} else {
var err = (data && data['message']) ? data['message'] : '{{ lang._('Unable to create IDS auto alerts cron rule.') }}';
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(err);
}
});
});
$('#enableMonitAutoAct').click(function () {
ajaxCall('/api/telegramnotify/service/enableMonitCron', {}, function (data, status) {
if (status === 'success' && (data['status'] === 'ok' || data['result'] === 'ok')) {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html(data['message'] || '{{ lang._('Monit auto alerts cron rule created.') }}');
} else {
var err = (data && data['message']) ? data['message'] : '{{ lang._('Unable to create Monit auto alerts cron rule.') }}';
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(err);
}
});
});
$('#disableMonitAutoAct').click(function () {
ajaxCall('/api/telegramnotify/service/disableMonitCron', {}, function (data, status) {
if (status === 'success' && (data['status'] === 'ok' || data['result'] === 'ok')) {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html(data['message'] || '{{ lang._('Monit auto alerts cron rule disabled.') }}');
} else {
var err = (data && data['message']) ? data['message'] : '{{ lang._('Unable to disable Monit auto alerts cron rule.') }}';
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(err);
}
});
});
$('#toggleMonitInfo').click(function () {
$('#monitInfoBox').toggleClass('hidden');
});
$('#toggleIdsInfo').click(function () {
$('#idsInfoBox').toggleClass('hidden');
});
$('#disableIdsAutoAct').click(function () {
ajaxCall('/api/telegramnotify/service/disableIdsCron', {}, function (data, status) {
if (status === 'success' && (data['status'] === 'ok' || data['result'] === 'ok')) {
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html(data['message'] || '{{ lang._('IDS auto alerts cron rule disabled.') }}');
} else {
var err = (data && data['message']) ? data['message'] : '{{ lang._('Unable to disable IDS auto alerts cron rule.') }}';
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(err);
}
});
});
});
</script>
<div class="alert alert-info" role="alert">
{{ lang._('Configure your Telegram bot and test delivery with a live message.') }}
</div>
<div class="alert alert-info" role="alert" style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<strong>{{ lang._('Monit integration') }}</strong>
<div>
<button class="btn btn-default" id="toggleMonitInfo" type="button" title="{{ lang._('Show details') }}"><b>i</b></button>
<button class="btn btn-default" id="enableMonitAutoAct" type="button"><b>{{ lang._('Enable Monit Auto Alerts (Cron)') }}</b></button>
<button class="btn btn-default" id="disableMonitAutoAct" type="button"><b>{{ lang._('Disable') }}</b></button>
</div>
</div>
<div id="monitInfoBox" class="hidden" style="margin-top:8px;">
{{ lang._('In Monit, use an alert exec command like:') }}<br/>
<code>/usr/local/sbin/configctl telegramnotify monit "$SERVICE" "$EVENT" "$ACTION" "$DESCRIPTION" "$HOST" "$DATE"</code><br/>
{{ lang._('Or enable automatic Monit log polling:') }}<br/>
<code>/usr/local/sbin/configctl telegramnotify monit.poll 3</code>
</div>
</div>
<div class="alert alert-info" role="alert" style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<strong>{{ lang._('IDS integration (Suricata)') }}</strong>
<div>
<button class="btn btn-default" id="toggleIdsInfo" type="button" title="{{ lang._('Show details') }}"><b>i</b></button>
<button class="btn btn-default" id="enableIdsAutoAct" type="button"><b>{{ lang._('Enable IDS Auto Alerts (Cron)') }}</b></button>
<button class="btn btn-default" id="disableIdsAutoAct" type="button"><b>{{ lang._('Disable') }}</b></button>
</div>
</div>
<div id="idsInfoBox" class="hidden" style="margin-top:8px;">
{{ lang._('Run periodically via Automation/Cron to send blocked IDS alerts from eve.json:') }}<br/>
<code>/usr/local/sbin/configctl telegramnotify ids.poll 5</code><br/>
{{ lang._('The number is max blocked alerts per run (1-50).') }}
</div>
</div>
<div class="alert hidden" role="alert" id="responseMsg"></div>
<div class="col-md-12">
{{ partial("layout_partials/base_form",['fields':settings,'id':'frm_telegramnotify'])}}
</div>
<div class="col-md-12" style="margin-top: 10px;">
<div class="form-group">
<label for="testEventType">{{ lang._('Test Event Type') }}</label>
<select id="testEventType" class="form-control">
<option value="system">{{ lang._('System') }}</option>
<option value="gateway">{{ lang._('Gateway') }}</option>
<option value="service">{{ lang._('Service') }}</option>
<option value="vpn">{{ lang._('VPN') }}</option>
<option value="security">{{ lang._('Security') }}</option>
<option value="updates">{{ lang._('Updates') }}</option>
</select>
</div>
<div class="form-group">
<label for="testMessage">{{ lang._('Test Message') }}</label>
<input id="testMessage" class="form-control" type="text" value="OPNsense test notification" />
</div>
</div>
<div class="col-md-12">
<button class="btn btn-primary" id="saveAct" type="button"><b>{{ lang._('Save') }}</b></button>
<button class="btn btn-default" id="testAct" type="button"><b>{{ lang._('Send Test') }}</b></button>
</div>

View file

@ -0,0 +1,178 @@
#!/usr/local/bin/python3
"""
Poll Suricata EVE alerts and forward them to Telegram Notify.
Usage:
ids_poll.py [max_alerts]
Designed for periodic execution from configd/cron. Keeps a file offset state to
only process new alerts each run.
"""
import base64
import json
import os
import subprocess
import sys
EVE_FILE = '/var/log/suricata/eve.json'
STATE_FILE = '/var/db/telegramnotify_ids_state.json'
PYTHON_BIN = '/usr/local/bin/python3'
SEND_SCRIPT = '/usr/local/opnsense/scripts/OPNsense/TelegramNotify/send_message.py'
def out(payload):
print(json.dumps(payload))
def load_state():
try:
with open(STATE_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {'offset': 0}
def save_state(offset):
try:
directory = os.path.dirname(STATE_FILE)
os.makedirs(directory, exist_ok=True)
with open(STATE_FILE, 'w', encoding='utf-8') as f:
json.dump({'offset': int(offset)}, f)
except Exception:
# Non-fatal: next run may resend some alerts.
pass
def format_alert(evt):
alert = evt.get('alert') or {}
signature = str(alert.get('signature') or 'unknown signature')
category = str(alert.get('category') or 'unknown category')
severity = str(alert.get('severity') or 'n/a')
action = str(alert.get('action') or evt.get('action') or 'n/a')
src_ip = str(evt.get('src_ip') or 'n/a')
src_port = str(evt.get('src_port') or 'n/a')
dst_ip = str(evt.get('dest_ip') or 'n/a')
dst_port = str(evt.get('dest_port') or 'n/a')
proto = str(evt.get('proto') or 'n/a')
iface = str(evt.get('in_iface') or evt.get('interface') or 'n/a')
return (
'IDS alert\n'
'Signature: {}\n'
'Category: {}\n'
'Severity: {}\n'
'Action: {}\n'
'Proto: {}\n'
'From: {}:{}\n'
'To: {}:{}\n'
'Interface: {}'
).format(signature, category, severity, action, proto, src_ip, src_port, dst_ip, dst_port, iface)
def is_blocked_event(evt):
event_type = str(evt.get('event_type') or '').lower()
if event_type == 'drop':
return True
alert = evt.get('alert') or {}
action = str(alert.get('action') or evt.get('action') or '').lower()
return action in ('blocked', 'drop', 'dropped', 'reject', 'rejected')
def send_message(text):
msg_b64 = base64.b64encode(text.encode('utf-8')).decode('ascii')
result = subprocess.run(
[PYTHON_BIN, SEND_SCRIPT, 'security', msg_b64],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
err = (result.stderr or result.stdout or 'unknown error').strip()
return False, err[:250]
output = (result.stdout or '').strip()
try:
data = json.loads(output)
except Exception:
return False, 'send_message returned invalid JSON'
if data.get('ok'):
return True, None
return False, str(data.get('description') or 'unknown Telegram API error')
def main():
max_alerts = 5
if len(sys.argv) > 1:
try:
max_alerts = max(1, min(50, int(sys.argv[1])))
except Exception:
max_alerts = 5
if not os.path.exists(EVE_FILE):
out({'status': 'ok', 'processed': 0, 'sent': 0, 'message': 'EVE log not found'})
return
state = load_state()
start_offset = int(state.get('offset') or 0)
processed = 0
sent = 0
errors = []
try:
with open(EVE_FILE, 'r', encoding='utf-8', errors='ignore') as f:
file_size = os.path.getsize(EVE_FILE)
if start_offset < 0 or start_offset > file_size:
start_offset = 0
f.seek(start_offset)
while sent < max_alerts:
line = f.readline()
if not line:
break
processed += 1
line = line.strip()
if not line:
continue
try:
evt = json.loads(line)
except Exception:
continue
if str(evt.get('event_type')) not in ('alert', 'drop'):
continue
if not is_blocked_event(evt):
continue
msg = format_alert(evt)
ok, err = send_message(msg)
if ok:
sent += 1
else:
errors.append(err)
end_offset = f.tell()
save_state(end_offset)
except Exception as e:
out({'status': 'failed', 'processed': processed, 'sent': sent, 'message': str(e)})
sys.exit(1)
response = {'status': 'ok', 'processed': processed, 'sent': sent}
if errors:
response['errors'] = errors[:3]
out(response)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,75 @@
#!/usr/local/bin/python3
"""
Bridge Monit alert events to Telegram Notify.
Intended usage from Monit exec hooks through configctl:
configctl telegramnotify monit "$SERVICE" "$EVENT" "$ACTION" "$DESCRIPTION" "$HOST" "$DATE"
"""
import base64
import json
import subprocess
import sys
SEND_SCRIPT = '/usr/local/opnsense/scripts/OPNsense/TelegramNotify/send_message.py'
PYTHON_BIN = '/usr/local/bin/python3'
def json_out(payload):
print(json.dumps(payload))
def safe_value(index):
if len(sys.argv) > index:
value = (sys.argv[index] or '').strip()
return value if value else 'n/a'
return 'n/a'
def main():
service = safe_value(1)
event = safe_value(2)
action = safe_value(3)
description = safe_value(4)
host = safe_value(5)
date = safe_value(6)
msg = (
'Monit alert\n'
'Service: {}\n'
'Event: {}\n'
'Action: {}\n'
'Host: {}\n'
'Date: {}\n'
'Description: {}'
).format(service, event, action, host, date, description)
msg_b64 = base64.b64encode(msg.encode('utf-8')).decode('ascii')
try:
result = subprocess.run(
[PYTHON_BIN, SEND_SCRIPT, 'service', msg_b64],
capture_output=True,
text=True,
timeout=30
)
except Exception as e:
json_out({'ok': False, 'description': 'monit_hook execution failed: ' + str(e)})
sys.exit(1)
output = (result.stdout or '').strip()
if result.returncode != 0:
err = (result.stderr or output or 'unknown error').strip()
json_out({'ok': False, 'description': 'send_message failed: ' + err[:250]})
sys.exit(1)
if output:
print(output)
return
json_out({'ok': False, 'description': 'send_message returned no output'})
sys.exit(1)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,147 @@
#!/usr/local/bin/python3
"""
Poll Monit log entries and forward alert-like lines to Telegram Notify.
Usage:
monit_poll.py [max_messages]
Designed for periodic execution from configd/cron. Keeps a file offset state to
only process new log lines each run.
"""
import base64
import json
import os
import subprocess
import sys
MONIT_LOG = '/var/log/monit.log'
STATE_FILE = '/var/db/telegramnotify_monit_state.json'
PYTHON_BIN = '/usr/local/bin/python3'
SEND_SCRIPT = '/usr/local/opnsense/scripts/OPNsense/TelegramNotify/send_message.py'
KEYWORDS = (
'failed',
'failure',
'timeout',
'error',
'does not exist',
'not running',
'connection failed',
'execution failed',
)
def out(payload):
print(json.dumps(payload))
def load_state():
try:
with open(STATE_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {'offset': 0}
def save_state(offset):
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, 'w', encoding='utf-8') as f:
json.dump({'offset': int(offset)}, f)
except Exception:
pass
def is_alert_line(line):
lline = line.lower()
return any(keyword in lline for keyword in KEYWORDS)
def send_message(text):
msg_b64 = base64.b64encode(text.encode('utf-8')).decode('ascii')
result = subprocess.run(
[PYTHON_BIN, SEND_SCRIPT, 'service', msg_b64],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
err = (result.stderr or result.stdout or 'unknown error').strip()
return False, err[:250]
output = (result.stdout or '').strip()
try:
data = json.loads(output)
except Exception:
return False, 'send_message returned invalid JSON'
if data.get('ok'):
return True, None
return False, str(data.get('description') or 'unknown Telegram API error')
def main():
max_messages = 3
if len(sys.argv) > 1:
try:
max_messages = max(1, min(20, int(sys.argv[1])))
except Exception:
max_messages = 3
if not os.path.exists(MONIT_LOG):
out({'status': 'ok', 'processed': 0, 'sent': 0, 'message': 'Monit log not found'})
return
state = load_state()
start_offset = int(state.get('offset') or 0)
processed = 0
sent = 0
errors = []
try:
with open(MONIT_LOG, 'r', encoding='utf-8', errors='ignore') as f:
file_size = os.path.getsize(MONIT_LOG)
if start_offset < 0 or start_offset > file_size:
start_offset = 0
f.seek(start_offset)
while sent < max_messages:
line = f.readline()
if not line:
break
processed += 1
line = line.strip()
if not line:
continue
if not is_alert_line(line):
continue
msg = 'Monit alert\n{}'.format(line)
ok, err = send_message(msg)
if ok:
sent += 1
else:
errors.append(err)
end_offset = f.tell()
save_state(end_offset)
except Exception as e:
out({'status': 'failed', 'processed': processed, 'sent': sent, 'message': str(e)})
sys.exit(1)
result = {'status': 'ok', 'processed': processed, 'sent': sent}
if errors:
result['errors'] = errors[:3]
out(result)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,291 @@
#!/usr/local/bin/python3
"""
Send a Telegram notification.
Resolves api.telegram.org via an explicit DNS server (drill) and uses
curl --resolve to bypass the system DNS resolver entirely.
Usage: send_message.py <event_type> <base64_encoded_message>
"""
import sys
import json
import base64
import subprocess
import re
import xml.etree.ElementTree as ET
import urllib.parse
import shutil
CONFIG_PATH = '/conf/config.xml'
TELEGRAM_HOST = 'api.telegram.org'
TELEGRAM_PORT = 443
EVENT_FIELDS = {
'system': 'eventSystem',
'gateway': 'eventGateway',
'service': 'eventService',
'vpn': 'eventVpn',
'security': 'eventSecurity',
'updates': 'eventUpdates',
}
EVENT_LABELS = {
'system': 'System',
'gateway': 'Gateway',
'service': 'Service',
'vpn': 'VPN',
'security': 'Security',
'updates': 'Updates',
}
def read_config():
root = ET.parse(CONFIG_PATH).getroot()
node = root.find('.//OPNsense/TelegramNotify/general')
if node is None:
raise RuntimeError('TelegramNotify configuration node not found in config.xml')
keys = [
'botToken', 'chatId', 'threadId', 'parseMode',
'disableWebPagePreview', 'disableNotification', 'dnsServer',
'eventSystem', 'eventGateway', 'eventService',
'eventVpn', 'eventSecurity', 'eventUpdates',
]
return {k: (node.findtext(k) or '').strip() for k in keys}
def resolve_via_drill(hostname, dns_server):
"""Return ([ipv4, ...], error). Uses explicit DNS server only."""
drill_bin = None
for candidate in ('/usr/bin/drill', '/usr/local/bin/drill', 'drill'):
found = shutil.which(candidate)
if found:
drill_bin = found
break
if not drill_bin:
return [], 'drill binary not found on system'
try:
result = subprocess.run(
[drill_bin, '@' + dns_server, hostname, 'A'],
capture_output=True, text=True, timeout=8
)
except Exception as e:
return [], 'drill execution failed: ' + str(e)
addresses = []
for line in result.stdout.splitlines():
parts = line.strip().split()
# drill answer section: name ttl class type address
if len(parts) == 5 and parts[3] == 'A':
ip = parts[4]
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
addresses.append(ip)
if addresses:
dedup = []
for ip in addresses:
if ip not in dedup:
dedup.append(ip)
return dedup, None
detail = (result.stderr or result.stdout or '').strip()
if detail:
detail = detail[:200]
else:
detail = 'no A record returned'
return [], detail
def resolve_via_doh(hostname):
"""Return ([ipv4, ...], error) using DNS-over-HTTPS over port 443 only."""
candidates = [
{
'host': 'cloudflare-dns.com',
'ip': '1.1.1.1',
'url': 'https://cloudflare-dns.com/dns-query?name={}&type=A'.format(hostname),
},
{
'host': 'dns.google',
'ip': '8.8.8.8',
'url': 'https://dns.google/resolve?name={}&type=A'.format(hostname),
},
]
errors = []
addresses = []
for item in candidates:
cmd = [
'/usr/local/bin/curl',
'-s',
'-S',
'--max-time', '12',
'--connect-timeout', '6',
'--resolve', '{}:443:{}'.format(item['host'], item['ip']),
'-H', 'accept: application/dns-json',
item['url'],
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except Exception as e:
errors.append('{} via {} failed: {}'.format(item['host'], item['ip'], str(e)))
continue
if result.returncode != 0:
msg = (result.stderr or result.stdout or '').strip()[:200]
errors.append('{} via {} curl error: {}'.format(item['host'], item['ip'], msg))
continue
try:
data = json.loads(result.stdout)
except Exception:
errors.append('{} via {} returned invalid JSON'.format(item['host'], item['ip']))
continue
answers = data.get('Answer') or []
for answer in answers:
ip = str(answer.get('data', '')).strip()
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
addresses.append(ip)
if not addresses:
errors.append('{} via {} returned no A record'.format(item['host'], item['ip']))
if addresses:
dedup = []
for ip in addresses:
if ip not in dedup:
dedup.append(ip)
return dedup, None
return [], '; '.join(errors) if errors else 'No DoH resolver candidates available'
def send_via_curl(token, data, resolved_ip=None):
"""Call the Telegram sendMessage endpoint via the system curl binary."""
url = 'https://{}/bot{}/sendMessage'.format(TELEGRAM_HOST, token)
body = urllib.parse.urlencode(data)
cmd = [
'/usr/local/bin/curl',
'-s',
'-S',
'--max-time', '20',
'--connect-timeout', '10',
'-4', # force IPv4
'-X', 'POST',
'-d', body,
]
if resolved_ip:
# Inject resolved IP so curl never needs DNS
cmd += ['--resolve', '{}:{}:{}'.format(TELEGRAM_HOST, TELEGRAM_PORT, resolved_ip)]
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
return result.stdout, result.stderr, result.returncode
def main():
if len(sys.argv) < 3:
out({'ok': False, 'description': 'Usage: send_message.py <event_type> <b64_message>'})
sys.exit(1)
event_type = sys.argv[1].lower().strip()
try:
message = base64.b64decode(sys.argv[2]).decode('utf-8')
except Exception:
out({'ok': False, 'description': 'Failed to decode message argument'})
sys.exit(1)
if event_type not in EVENT_FIELDS:
out({'ok': False, 'description': 'Invalid event type: ' + event_type})
sys.exit(1)
try:
cfg = read_config()
except Exception as e:
out({'ok': False, 'description': 'Config read error: ' + str(e)})
sys.exit(1)
token = cfg.get('botToken', '')
chat_id = cfg.get('chatId', '')
if not token or not chat_id:
out({'ok': False, 'description': 'Bot token and Chat ID are required'})
sys.exit(1)
if cfg.get(EVENT_FIELDS[event_type], '1') == '0':
out({'ok': False, 'description': 'Event type {} is disabled in settings'.format(event_type)})
sys.exit(1)
full_message = '[{}] {}'.format(EVENT_LABELS[event_type], message)
# Resolve hostname bypassing system DNS
dns_server = cfg.get('dnsServer', '') or '8.8.8.8'
resolved_ips, resolve_error = resolve_via_drill(TELEGRAM_HOST, dns_server)
if not resolved_ips:
doh_ips, doh_error = resolve_via_doh(TELEGRAM_HOST)
if doh_ips:
resolved_ips = doh_ips
else:
out({
'ok': False,
'description': 'DNS resolve failed. drill@{}: {} ; DoH fallback: {}'.format(
dns_server,
resolve_error,
doh_error
)
})
sys.exit(1)
post_data = {
'chat_id': chat_id,
'text': full_message,
}
if cfg.get('disableWebPagePreview') == '1':
post_data['disable_web_page_preview'] = 'true'
if cfg.get('disableNotification') == '1':
post_data['disable_notification'] = 'true'
if cfg.get('threadId'):
post_data['message_thread_id'] = cfg['threadId']
parse_mode = cfg.get('parseMode', '')
if parse_mode and parse_mode != 'None':
post_data['parse_mode'] = parse_mode
try:
last_error = 'Unknown error'
response = None
for ip in resolved_ips:
stdout, stderr, returncode = send_via_curl(token, post_data, ip)
if returncode != 0:
err = (stderr or stdout or '').strip()
last_error = 'ip {}: {}'.format(ip, err[:200])
continue
try:
response = json.loads(stdout)
break
except json.JSONDecodeError:
last_error = 'ip {} returned non-JSON: {}'.format(ip, stdout[:200])
if response is None:
out({'ok': False, 'description': 'curl failed on all resolved IPs: {}'.format(last_error)})
sys.exit(1)
except subprocess.TimeoutExpired:
out({'ok': False, 'description': 'curl request timed out'})
sys.exit(1)
except Exception as e:
out({'ok': False, 'description': 'Unexpected error: ' + str(e)})
sys.exit(1)
out(response)
def out(obj):
print(json.dumps(obj))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,27 @@
[send]
command:/usr/local/bin/python3 /usr/local/opnsense/scripts/OPNsense/TelegramNotify/send_message.py
parameters:%s %s
type:script_output
description:Send a Telegram notification
message:Sending Telegram notification
[monit]
command:/usr/local/bin/python3 /usr/local/opnsense/scripts/OPNsense/TelegramNotify/monit_hook.py
parameters:%s %s %s %s %s %s
type:script_output
description:Send a Telegram notification from Monit event
message:Sending Telegram notification from Monit
[ids.poll]
command:/usr/local/bin/python3 /usr/local/opnsense/scripts/OPNsense/TelegramNotify/ids_poll.py
parameters:%s
type:script_output
description:Poll IDS alerts and send Telegram notifications
message:Polling IDS alerts for Telegram notifications
[monit.poll]
command:/usr/local/bin/python3 /usr/local/opnsense/scripts/OPNsense/TelegramNotify/monit_poll.py
parameters:%s
type:script_output
description:Poll Monit log alerts and send Telegram notifications
message:Polling Monit alerts for Telegram notifications

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');
}
}
}