Skip to content

Commit

Permalink
Merge branch 'main' into release
Browse files Browse the repository at this point in the history
Merge branch 'main' into release
  • Loading branch information
louisevelayo authored Dec 12, 2023
2 parents 9f143a6 + f390f1a commit 673047d
Show file tree
Hide file tree
Showing 10 changed files with 122 additions and 87 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
<!-- ## [1.0.0-alpha.2] - Unreleased -->

## [1.0.0-alpha.2] - 2023-12-12

- Added logging.
- Fixed bug where `UNASSIGNED` nodes were being alerted on as being compromised.
Expand All @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where messages longer than 4096 characters could not be sent through Telegram.
- Added a feature to automatically record all node provider info into the database.
- Added a Watchdog script to notify developers as soon as Node Monitor is down.
- Fixed an issue causing Node Monitor to go offline when a subscriber's email is not found.
- Added email subject line visibility in Slack and Telegram notifications.
- Fixed an issue where Telegram messages were not formatted properly.


## [1.0.0-alpha.1] - 2023-10-20
Expand Down
12 changes: 12 additions & 0 deletions data/t0.json
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,18 @@
"region": "Europe,BE,Brussels Capital",
"status": "UP",
"subnet_id": "w4asl-4nmyj-qnr7c-6cqq4-tkwmt-o26di-iupkq-vx4kt-asbrx-jzuxh-4ae"
},
{
"dc_id": "pl1",
"dc_name": "Portland",
"node_id": "2zulq-xy5xs-lcuqd-hap3b-scpd2-oqa3p-oxdmj-jrwlk-vsrhj-uvcvl-kqe",
"node_operator_id": "lz4fy-gca6y-aodk3-ncdrw-ouoqb-kgvjj-h4nul-eybyu-puwev-hkogp-fqe",
"node_provider_id": "eipr5-izbom-neyqh-s3ec2-52eww-cyfpg-qfomg-3dpwj-4pffh-34xcu-7qe",
"node_provider_name": "87m Neuron, LLC",
"owner": "Flexential",
"region": "North America,US,Oregon",
"status": "UNASSIGNED",
"subnet_id": null
}
]
}
1 change: 1 addition & 0 deletions node_monitor/bot_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class SlackBot:

def __init__(self, slack_token: str) -> None:
self.client = slack_sdk.WebClient(token=slack_token)


def send_message(
self, slack_channel_name: str,
Expand Down
6 changes: 5 additions & 1 deletion node_monitor/bot_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ def send_message(
) -> None | requests.exceptions.HTTPError:
"""Send a message to a single Telegram chat."""
max_message_length = 4096
message_parts = textwrap.wrap(message, width=max_message_length)

# TODO: use itertools.batched here when python version is updated to >=3.12.
message_parts = [
message[i:i + max_message_length]
for i in range(0, len(message), max_message_length)]

try:
for part in message_parts:
Expand Down
97 changes: 41 additions & 56 deletions node_monitor/node_monitor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
from collections import deque
from typing import Deque, List, Dict, Optional
from typing import Deque, List, Dict, Optional, Callable
from toolz import groupby # type: ignore
import schedule
import logging
Expand Down Expand Up @@ -100,83 +100,68 @@ def _analyze(self) -> None:
self.actionables = {k: v for k, v
in self.compromised_nodes_by_provider.items()
if k in subscriber_ids}


def broadcast_alerts(self) -> None:
"""Broadcast relevant alerts to the appropriate channels. Retrieves
subscribers, node_labels, and email_recipients from the database."""

def _make_broadcaster(self) -> Callable[[str, str, str], None]:
"""A closure that returns a broadcast function with a local cache.
Allows the returned function to be run in a loop without
querying the database.
"""
subscribers = self.node_provider_db.get_subscribers_as_dict()
node_labels = self.node_provider_db.get_node_labels_as_dict()
email_recipients = self.node_provider_db.get_emails_as_dict()
slack_channels = self.node_provider_db.get_slack_channels_as_dict()
telegram_chats = self.node_provider_db.get_telegram_chats_as_dict()
for node_provider_id, nodes in self.actionables.items():

def broadcaster(node_provider_id: str,
subject: str, message: str) -> None:
"""Broadcasts a generic message to a subscriber through their
selected communication channel(s)."""
preferences = subscribers[node_provider_id]
subject, message = messages.nodes_compromised_message(nodes, node_labels)
# - - - - - - - - - - - - - - - - -
dispatch = f"{subject}\n\n{message}"
if preferences['notify_email'] == True:
recipients = email_recipients[node_provider_id]
logging.info(f"Sending alert email message to {recipients}...")
self.email_bot.send_emails(recipients, subject, message)
if preferences['notify_slack'] == True:
if self.slack_bot is not None:
channels = slack_channels[node_provider_id]
logging.info(f"Sending alert slack messages to {channels}...")
err1 = self.slack_bot.send_messages(channels, message)
if err1 is not None:
logging.error(f"SlackBot.send_message() failed with error: {err1}")
recipients = email_recipients.get(node_provider_id, [])
if recipients:
self.email_bot.send_emails(recipients, subject, message)
if preferences['notify_slack'] == True:
if self.slack_bot:
channels = slack_channels.get(node_provider_id, [])
if channels:
err1 = self.slack_bot.send_messages(channels, dispatch)
if preferences['notify_telegram'] == True:
if self.telegram_bot is not None:
chats = telegram_chats[node_provider_id]
logging.info(f"Sending alert telegram messages to {chats}...")
err2 = self.telegram_bot.send_messages(chats, message)
if err2 is not None:
logging.error(f"TelegramBot.send_message() failed with error: {err2}")
# - - - - - - - - - - - - - - - - -
if self.telegram_bot:
chats = telegram_chats.get(node_provider_id, [])
if chats:
err2 = self.telegram_bot.send_messages(chats, dispatch)
return None

return broadcaster


def broadcast_alerts(self) -> None:
"""Broadcast relevant alerts to the appropriate channels."""
broadcaster = self._make_broadcaster()
node_labels = self.node_provider_db.get_node_labels_as_dict()
for node_provider_id, nodes in self.actionables.items():
logging.info(f"Broadcasting alert message to {node_provider_id}...")
subject, message = messages.nodes_compromised_message(nodes, node_labels)
broadcaster(node_provider_id, subject, message)


def broadcast_status_report(self) -> None:
"""Broadcasts a Node Status Report to all Node Providers.
Retrieves subscribers, node_labels, and email_recipients from the
database. Filters out Node Providers that are not subscribed to
status reports.
"""
"""Broadcasts a Node Status Report to all Node Providers."""
broadcaster = self._make_broadcaster()
subscribers = self.node_provider_db.get_subscribers_as_dict()
node_labels = self.node_provider_db.get_node_labels_as_dict()
email_recipients = self.node_provider_db.get_emails_as_dict()
slack_channels = self.node_provider_db.get_slack_channels_as_dict()
telegram_chats = self.node_provider_db.get_telegram_chats_as_dict()
latest_snapshot_nodes = self.snapshots[-1].nodes
all_nodes_by_provider: Dict[Principal, List[ic_api.Node]] = \
groupby(lambda node: node.node_provider_id, latest_snapshot_nodes)
reportable_nodes = {k: v for k, v
in all_nodes_by_provider.items()
if k in subscribers.keys()}
# - - - - - - - - - - - - - - - - -
for node_provider_id, nodes in reportable_nodes.items():
logging.info(f"Broadcasting status report {node_provider_id}...")
preferences = subscribers[node_provider_id]
subject, message = messages.nodes_status_message(nodes, node_labels)
# - - - - - - - - - - - - - - - - -
if preferences['notify_email'] == True:
recipients = email_recipients[node_provider_id]
logging.info(f"Sending status report email to {recipients}...")
self.email_bot.send_emails(recipients, subject, message)
if preferences['notify_slack'] == True:
if self.slack_bot is not None:
channels = slack_channels[node_provider_id]
logging.info(f"Sending status report slack message to {channels}...")
err1 = self.slack_bot.send_messages(channels, message)
if err1 is not None:
logging.error(f"SlackBot.send_message() failed with error: {err1}")
if preferences['notify_telegram'] == True:
if self.telegram_bot is not None:
chats = telegram_chats[node_provider_id]
logging.info(f"Sending status report telegram messages to {chats}...")
err2 = self.telegram_bot.send_messages(chats, message)
if err2 is not None:
logging.error(f"TelegramBot.send_message() failed with error: {err2}")
# - - - - - - - - - - - - - - - - -
broadcaster(node_provider_id, subject, message)


def update_node_provider_lookup_if_new(
Expand Down
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ def pytest_collection_modifyitems(config, items):
item.add_marker(skip_live_telegram)


@pytest.fixture
def fake_data():
fakenode = ic_api.Node(
dc_id='fake_dc_id',
dc_name='fake_dc_name',
node_id='fake_node_id',
node_operator_id='fake_node_operator_id',
node_provider_id='fake_node_provider_id',
node_provider_name='fake_node_provider_name',
owner='fake_owner',
region='fake_region',
status='DOWN',
subnet_id='fake_subnet_id',
)
fakelabel = {'fake_node_id': 'fake_label'}
return fakenode, fakelabel


## Create mock data for testing
## Data was pulled from the ic-api using cURL and stored in json files.
Expand Down
21 changes: 5 additions & 16 deletions tests/test_bot_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from node_monitor.bot_email import EmailBot
import node_monitor.load_config as c
import node_monitor.node_monitor_helpers.messages as messages
import node_monitor.ic_api as ic_api
from tests.conftest import fake_data


# This test sends emails by default
# Usage to disable live email sending:
Expand Down Expand Up @@ -37,24 +38,12 @@ def test_send_emails_mock(mock_smtp):


@pytest.mark.live_email
def test_send_emails_network():
def test_send_emails_network(fake_data):
"""Send real emails over a network to a test inbox and check that
they were received."""

## Create a fake node model
fakenode = ic_api.Node(
dc_id = 'fake_dc_id',
dc_name = 'fake_dc_name',
node_id = 'fake_node_id',
node_operator_id = 'fake_node_operator_id',
node_provider_id = 'fake_node_provider_id',
node_provider_name = 'fake_node_provider_name',
owner = 'fake_owner',
region = 'fake_region',
status = 'DOWN',
subnet_id = 'fake_subnet_id',
)
fakelabel = {'fake_node_id': 'fake_label'}
## Generate fake node and label
fakenode, fakelabel = fake_data

## Init the authenticated email bot instance
email_bot = EmailBot(c.EMAIL_USERNAME, c.EMAIL_PASSWORD)
Expand Down
17 changes: 12 additions & 5 deletions tests/test_bot_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,41 @@
from unittest.mock import patch

from node_monitor.bot_slack import SlackBot
import node_monitor.node_monitor_helpers.messages as messages
import node_monitor.load_config as c
from tests.conftest import fake_data


@patch("slack_sdk.WebClient")
def test_send_message(mock_web_client):
mock_client = mock_web_client.return_value

expected_channel = "#node-monitor"
expected_subject = "Subject message"
expected_message = "Hello, Slack!"
dispatch = f"{expected_subject}\n\n{expected_message}"

slack_bot = SlackBot(c.TOKEN_SLACK)
slack_bot.send_message(expected_channel, expected_message)
slack_bot.send_message(expected_channel, dispatch)

mock_client.chat_postMessage.assert_called_once_with(
channel=expected_channel,
text=expected_message)
text=f"{expected_subject}\n\n{expected_message}")


@pytest.mark.live_slack
def test_send_message_slack():
def test_send_message_slack(fake_data):
"""Send a real test message to a Slack workspace"""
fakenode, fakelabel = fake_data
slack_bot = SlackBot(c.TOKEN_SLACK)
slack_channel_name = "node-monitor"
message = "🔬 This is a test message from Node Monitor"

subject, message = messages.nodes_compromised_message([fakenode], fakelabel)
dispatch = f"{subject}\n\n{message}"

## SlackBot.send_message() normally returns an error without raising
## an exception to prevent NodeMonitor from crashing if the message
## fails to send. We make sure to raise it here to purposely fail the test.
err = slack_bot.send_message(slack_channel_name, message)
err = slack_bot.send_message(slack_channel_name, dispatch)
if err is not None:
raise err
19 changes: 14 additions & 5 deletions tests/test_bot_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
from unittest.mock import patch

import node_monitor.load_config as c
import node_monitor.node_monitor_helpers.messages as messages
from node_monitor.bot_telegram import TelegramBot
from tests.conftest import fake_data


@patch("requests.post")
def test_send_message(mock_post):
telegram_bot = TelegramBot(c.TOKEN_TELEGRAM)
chat_id = "1234567890"
subject = "Test subject"
message = "Test message"
dispatch = f"{subject}\n\n{message}"
payload = {
"chat_id": chat_id,
"text": message
"text": f"{subject}\n\n{message}"
}
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None

telegram_bot.send_message(chat_id, message)
telegram_bot.send_message(chat_id, dispatch)

mock_post.assert_called_once_with(
f"https://api.telegram.org/bot{telegram_bot.telegram_token}/sendMessage",
Expand All @@ -27,11 +32,15 @@ def test_send_message(mock_post):


@pytest.mark.live_telegram
def test_send_live_message():
def test_send_live_message(fake_data):
"""Send a real test message to a Telegram channel"""
fakenode, fakelabel = fake_data
telegram_bot = TelegramBot(c.TOKEN_TELEGRAM)
chat_id = "-1001925583150"
message = "🔬 This is a test message from Node Monitor"

err = telegram_bot.send_message(chat_id, message)
subject, message = messages.nodes_compromised_message([fakenode], fakelabel)
dispatch = f"{subject}\n\n{message}"

err = telegram_bot.send_message(chat_id, dispatch)
if err is not None:
raise err
12 changes: 10 additions & 2 deletions tests/test_node_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,22 @@
'notify_slack': True,
'node_provider_name': 'Allusion',
'notify_telegram': True,
}
}
},
'eipr5-izbom-neyqh-s3ec2-52eww-cyfpg-qfomg-3dpwj-4pffh-34xcu-7qe': {
'node_provider_id': 'eipr5-izbom-neyqh-s3ec2-52eww-cyfpg-qfomg-3dpwj-4pffh-34xcu-7qe',
'notify_on_status_change': True,
'notify_email': True,
'notify_slack': True,
'node_provider_name': '87m Neuron, LLC',
'notify_telegram': True}}
mock_node_provider_db.get_node_labels_as_dict.return_value = \
{'77fe5-a4oq4-o5pk6-glxt7-ejfpv-tdkrr-24mgs-yuvvz-2tqx6-mowdr-eae': 'dummy-node-label-1',
'clb2i-sz6tk-tlcpr-hgnfv-iybzf-ytorn-dmzkz-m2iw2-lpkqb-l455g-pae': 'dummy-node-label-2'}
mock_node_provider_db.get_emails_as_dict.return_value = \
{'rbn2y-6vfsb-gv35j-4cyvy-pzbdu-e5aum-jzjg6-5b4n5-vuguf-ycubq-zae':
['[email protected]']}
# 'eipr5-izbom-neyqh-s3ec2-52eww-cyfpg-qfomg-3dpwj-4pffh-34xcu-7qe':
# ['[email protected]']}
mock_node_provider_db.get_slack_channels_as_dict.return_value = \
{'rbn2y-6vfsb-gv35j-4cyvy-pzbdu-e5aum-jzjg6-5b4n5-vuguf-ycubq-zae':
['#node-monitor']}
Expand Down

0 comments on commit 673047d

Please sign in to comment.