mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
Merge 8a546533f0 into cb9a5d6d69
This commit is contained in:
commit
fa5c56732d
30 changed files with 2076 additions and 0 deletions
7
net-mgmt/telegram-notify/Makefile
Normal file
7
net-mgmt/telegram-notify/Makefile
Normal 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"
|
||||
7
net-mgmt/telegram-notify/pkg-descr
Normal file
7
net-mgmt/telegram-notify/pkg-descr
Normal 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>
|
||||
|
|
@ -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'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<menu>
|
||||
<Services>
|
||||
<TelegramNotify VisibleName="Telegram Notify" cssClass="fa fa-paper-plane">
|
||||
<Settings url="/ui/telegramnotify"/>
|
||||
</TelegramNotify>
|
||||
</Services>
|
||||
</menu>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TelegramNotify;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class TelegramNotify extends BaseModel
|
||||
{
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
7
net-mgmt/topology-map/Makefile
Normal file
7
net-mgmt/topology-map/Makefile
Normal 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"
|
||||
6
net-mgmt/topology-map/pkg-descr
Normal file
6
net-mgmt/topology-map/pkg-descr
Normal 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>
|
||||
|
|
@ -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'])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<menu>
|
||||
<Services>
|
||||
<TopologyMap VisibleName="Topology Map" cssClass="fa fa-sitemap">
|
||||
<Settings url="/ui/topologymap"/>
|
||||
</TopologyMap>
|
||||
</Services>
|
||||
</menu>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TopologyMap;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class TopologyMap extends BaseModel
|
||||
{
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue