Если тебе надоели постоянные обрывы связи и косяки провайдера, но субъективные оценки типа «подвисает» не внушают доверия, лучший выбор — записать состояние сети в автоматическом режиме. Причем для этого необязательно гонять Nagios, который к тому же не так прост в настройке. Сегодня мы напишем утилиту для мониторинга сети, которая легко настраивается и сохраняет в журнал RTT до заданных хостов, packet loss и скорость соединения (опционально), а логи летят прямо в Telegram.
Виновником появления этой статьи стал уже несколько месяцев сбоящий интернет, который мне предоставляет единственный в округе провайдер. Увы, в мою деревню ничего, кроме ADSL, не завезли, и, судя по качеству связи, и тот не дошел без многочисленных скруток. Packet loss порой доходит до 60–70%, что уже ни в какие ворота не лезет. Поэтому я решил сам измерить качество связи, дабы ткнуть провайдеру под нос логи вместе с заявлением о расторжении договора.
Как написать программу для мониторинга сети на C#
Наша цель — написать простой сетевой монитор, чтобы в фоновом режиме отслеживать главные показатели в сети и сохранять их для анализа. Думаю, сбора следующих параметров хватит с головой, а если тебе понадобится что-то еще, всегда можно добавить (не забудь рассказать об этом мне).
- Пинг для заданных хостов. Просто маст-хэв для любой диагностической утилиты. Измеряя пинг, можно узнать также и процент потерь пакетов (packet loss), и коды ошибок, позволяющие узнать, что именно не так с сетью. Например, Destination Prohibited означает, что сеть вроде и есть, но администратор какого-то из промежуточных устройств не пропускает пакет. В общем, анализировать статус-коды ответов обязательно.
- Реальная возможность подключений по TCP. Возможна ситуация, когда хосты вроде живы и откликаются на пинг, DNS работает, а доступ в интернет закрыт за неоплату. Этот тест потенциально позволит нам выявить недобросовестного провайдера, который подделывает ответы на пинги, но не обеспечивает реальный коннект.
- Уведомления о времени даунтайма в Telegram. Они должны отправляться, как только соединение восстановится. Сообщение по-хорошему должно включать расширенную инфу о пинге и потерях пакетов после сбоя, а также состояние HTTP-клиента.
- Доступ к роутеру. Для домашней сети с нестабильным Wi-Fi это особенно актуально. Роутер может просто упасть от перегрузки (например, очередной школохакер ломится на дырявый WPS, но вместо взлома получается DoS) или попросту не выдержать всех клиентов, которых в ином «умном доме» может быть и 15, и 20. Короче, роутер в любой момент может уйти в перезагрузку, а мы будем грешить на провайдера. Это нехорошо, поэтому при потере связи с роутером мы не будем тестировать дальше, а просто подождем, пока починят.
Цели обрисованы. Теперь детали реализации.
- Программа предназначена для длительной работы в фоновом режиме. Оформим программу как системный сервис Windows.
- Если мы работаем в фоновом режиме, ни консольный интерфейс, ни тем более GUI нам не нужен. Тем лучше — меньше кода.
- Проверки не должны сильно нагружать канал, ведь будет некомфортно работать. Так что постоянно флудить пингами мы не станем. Отправим очередь из десятка пакетов раз в минуту-две, и хватит. Реже отправлять не имеет смысла — большинство неполадок устраняются в течение нескольких минут, а мы хотим знать о каждом сбое.
- Возможность хранить отчет в JSON и выгружать CSV для изучения в Excel — с фильтрацией по дате создания.
- Неплохо бы прикрутить возможность забирать логи по сети и скидывать статистику на центральный сервер, но в рамках демо я этого делать не буду.
Из этого следует, что нам понадобится работа с JSON. Писать я буду на C# и воспользуюсь модулем Json.NET.
Json.NET — популярная и простая библиотека для работы с JSON. Скачать ее можно с NuGet, а примеры использования лежат на сайте проекта.
Написание программы
Для начала скачай Visual Studio с сайта Microsoft, если у тебя ее еще нет. Нужна поддержка языка C# и NuGet (с вкладки «Дополнительные компоненты»).
Первым делом создаем новый проект типа «Консольное приложение». Можно было, конечно, реализовать его в качестве «Службы Windows», тогда не нужно было бы городить костыли для регистрации нашего монитора как системной службы. Бонусом получили бы автозапуск. Жаль, что в случае «шаблонного» сервиса мы теряем ту гибкость и управляемость, что имеем при ручном управлении.
Готово. Теперь — алгоритм. Алгоритм работы программы будет прост. Во-первых, нужно прочитать настройки. Они у нас будут в файле JSON рядом с исполняемым файлом. Во-вторых, надо создать и запустить таймер, чтобы неожиданные задержки канала не мешали нам производить замеры через равные промежутки времени. И в-третьих, надо написать код сохранения результатов замеров. Поехали!
Сперва определим, что именно мы сможем настраивать. Я выбрал следующие параметры:
- хост и порт, до которых будет проходить проверка работоспособности HTTP;
- количество пакетов пинга и их тайм-аут;
- задержка перед отправлением следующего пакета пинга;
- задержка между соседними измерениями (та, которая определяет, раз в сколько минут проверка);
- включить или выключить вывод сообщений в консоль (для отладки);
- хосты, которые будем пинговать;
- IP роутера (чтобы узнавать, не завис ли он). Ты спросишь, зачем отдельно IP роутера, если его можно указать в общем списке адресов для проверки, и будешь прав. Разница в том, что, если программа не обнаружит связи с роутером, остальные хосты проверяться не будут, чтобы не тратить ресурсы;
- тайм-аут для подключения по HTTP;
- максимальный уровень packet loss, при котором подключение считается нормальным. Мне пришлось поставить себе 10%, так как 5–7% совсем не редкость для моей деревни;
- выходной формат строки для CSV, если ты вдруг решишь отключить вывод ненужных столбцов. Признаюсь, я уже забыл, зачем мне это понадобилось;
- выходной файл CSV, в который будут дописываться результаты и возможность отключить запись.
1234567891011121314151617static String HTTP_TEST_HOST;static int HTTP_TEST_PORT;static int HTTP_TIMEOUT;static int PING_COUNT;static int PING_DELAY;static int PING_TIMEOUT;static List<String> PING_HOSTS;static int MEASURE_DELAY;static String ROUTER_IP;static bool CUI_ENABLED;static double MAX_PKT_LOSS;static String OUT_FILE;static bool WRITE_CSV;static String CSV_PATTERN;static String TG_TOKEN;static String TG_CHAT_ID;static bool TG_NOTIFY;
Думаю, нет смысла расписывать, какая переменная за что отвечает, я постарался дать им понятные названия. Если что, можешь прочитать комментарии к коду (ссылка на GitHub — в конце статьи).
С настройками разобрались, теперь добавим их загрузку. Тут все просто: читаем файл, скармливаем его Json.NET, раскладываем настройки по переменным.
Теперь позаботимся о выводе CSV. Поскольку строка в конфиге задает только шаблон вывода, заголовки столбцов нам придется назначить самостоятельно. А так как мы хотим знать и результаты измерений по каждому хосту из списка, нужен цикл. Ниже — часть кода, которая отвечает за формирование заголовка таблицы.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
String CSV_HEADER = CSV_PATTERN .Replace("FTIME", "Snapshot time") .Replace("IUP", "Internet up") .Replace("AVGRTT", "Average ping (ms)") .Replace("ROUTERRTT", "Ping to router (ms)") .Replace("LOSS", "Packet loss, %") .Replace("MID", "Measure ID") .Replace("SEQ", "SeqID") .Replace("HTTP", "HTTP OK") .Replace("STIME", "STime"); foreach (var host in PING_HOSTS) { CSV_HEADER = CSV_HEADER.Replace("RN", $"RTT to {host};RN"); } CSV_HEADER = CSV_HEADER.Replace("RN", ";;\r\n"); |
Теперь небольшое пояснение, что тут происходит. Сначала мы заменяем почти все идентификаторы в строке формата на их человекочитаемые значения. Почти — потому что RN, обозначающий конец строки, остается. Далее в цикле мы вот таким нехитрым образом дописываем новые столбцы, а под конец закрываем строку с помощью ;;\r\n и убираем RN.
С этим кодом и так все понятно: парсим аргументы, если их нет — выводим справку. Программа знает четыре режима работы.
- При запуске без аргументов. Просто выводит справку и ждет, когда пользователь ее прочитает.
- Запуск с -d или --daemon. Программа запускается и работает в фоновом режиме, никуда не устанавливаясь.
- Запуск с -m или --measure-once. Программа также не будет регистрировать сервис, но и прятать окно не будет, в отличие от второго режима. Просто для запуска портативной измерялки с флешки.
- Режим установки. Войти в него можно с помощью параметров -i или --install. В этом случае будет зарегистрирован сервис, а программа перезапустится как сервис в режиме 2.
Обработка этой несложной логики представлена на скриншоте выше. На этом подготовительная часть завершена, делаем логику измерений.
РЕКОМЕНДУЕМ:
Как на ассемблере написать игру
Для сохранения результатов мы договорились использовать JSON. А в .NET можно без проблем сериализовать в него классы и структуры. Каждый снапшот состояния сети содержит немало информации, так что давай объявим класс для хранения результата измерения, а еще лучше — структуру, поскольку класс использует больше ресурсов, а вся его гибкость нам не нужна. В итоге структура, хранящая результат измерения, выглядит вот так:
1 2 3 4 5 6 7 8 9 |
struct net_state { public bool inet_ok; public bool http_ok; public Dictionary<String,int> avg_rtts; public double packet_loss; public DateTime measure_time; public int router_rtt; public long measure_id; } |
Флаг inet_ok определяет, удовлетворительное ли состояние сети при этом измерении, согласно настройкам из конфига. Здесь будет false если провален хотя бы один из тестов: тест HTTP, средний пинг до всех хостов больше 0,75 тайм-аута, packet loss больше максимально допустимого или роутер ушел в офлайн.
Флаг http_ok определяет успех теста HTTP-клиента. Если здесь true, то соединение действительно можно установить. Но хитрые провайдеры при неуплате могут подделывать трафик HTTP для перенаправления на свою страницу, что следует иметь в виду, если пинги не проходят, а HTTP говорит, что все в норме.
Дальше мы видим словарь avg_rtts, в котором указаны средние пинги до каждого хоста из списка. Адрес хоста здесь служит ключом, а средний результат — значением; packet_loss показывает среднюю потерю пакетов на всех хостах.
Тут мы видим небольшой недостаток схемы: программа должна померить пинг до каждого хоста из списка (пусть их будет пять), но, если мы хотим отправить каждому десять пакетов с тайм-аутом 3 секунды, а ни один не доступен, на все замеры уйдет 5 × 10 × 3 = 150 секунд, что больше двух минут между первым и последним измерением.
Да и мы условились замерять каждую минуту, так что такую длительность замера позволить нельзя. Если три подряд пакета превысили тайм-аут или вернулся Destination Prohibited или No such host хотя бы однажды, дальше мерить не имеет смысла. Запомним это до момента реализации.
Поле router_rtt в представлении не нуждается, а вот зачем нужен measure_id, когда есть measure_time, я сейчас объясню. Параметр measure_id изменяется последовательно на единицу с момента первого измерения после запуска. И в папке со снапшотами эти измерения выглядят как очередь файлов с именами, где отличается только последняя цифра. В общем, это нужно для облегчения ручной работы.
Еще нам понадобятся глобальные переменные start_measure_id для хранения ID первого результата и seq_id для порядкового номера измерения в очереди. Теперь объявим функцию проведения измерений. Вот ее шаблон:
1 2 3 4 5 6 7 8 9 10 11 12 |
private static void DoMeasures() { System.Timers.Timer _timer = new System.Timers.Timer(); _timer.AutoReset = true; _timer.Interval = MEASURE_DELAY; _timer.Elapsed += delegate { }; ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; httpc = new HttpClient(); TgNotify("nmon is now running", true); _timer.Start(); while (true) Thread.Sleep(1000000); } |
Тут все просто: объявляем таймер, настраиваем его и запускаем. А еще настраиваем HTTPS — тот же Telegram, например, признает только HTTPS. Далее поток уходит в спячку (строка 14). Основное действие разворачивается в обработчике события срабатывания таймера (делегат в 6–9 строках этой врезки). Туда мы и будем писать в дальнейшем.
Вначале инициализируем объект снапшота, записав в него значение measure_id. Далее измеряем пинг до роутера и при отсутствии коннекта выходим, сделав соответствующую запись в лог.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
net_state snapshot = new net_state(); snapshot.inet_ok = true; snapshot.measure_id = start_measure_id++; snapshot.measure_time = DateTime.Now; Ping ping = new Ping(); // First, check if router is available var prr = ping.Send(ROUTER_IP, PING_TIMEOUT); snapshot.router_rtt = prr.Status == IPStatus.Success ? (int)prr.RoundtripTime : PING_TIMEOUT; if (prr.Status != IPStatus.Success) { // Router is unreachable. Don’t waste resources snapshot.avg_rtts = new Dictionary<string, int>(); snapshot.http_ok = false; snapshot.inet_ok = false; snapshot.packet_loss = 1; foreach (var ci in PING_HOSTS) { snapshot.avg_rtts.Add(ci, PING_TIMEOUT); } WriteLog("Router was unreachable."); SaveSnapshot(snapshot); return; } |
Тут самый большой интерес представляет блок обработки недоступности роутера. Поясню: для корректной дальнейшей обработки в Excel все результаты измерений должны быть заполнены, даже если мы их не проводили. Так что заполним эти значения так, как будто измерение было, но все запросы закончились по тайм-ауту. Затем запишем лог и сохраним ущербное состояние сети.
Допустим, коннект до роутера есть. Следующее действие — проверяем работу HTTP. Причем для этого вовсе не обязательно использовать WebClient. Мы просто подключимся к заданному хосту и порту по TCP. Если подключение не сломается, значит, HTTP доступен и работает. Делается это все следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
try { snapshot.http_ok = true; TcpClient tc = new TcpClient(); tc.BeginConnect(HTTP_TEST_HOST, HTTP_TEST_PORT, null, null); Thread.Sleep(HTTP_TIMEOUT); if (!tc.Connected) { snapshot.http_ok = false; snapshot.inet_ok = false; } } catch { snapshot.http_ok = false; snapshot.inet_ok = false; } |
Теперь осталось только замерить пинги. Количество возможных хостов для измерений ограничено только фантазией пользователя, поэтому, чтобы уложиться по времени в одну минуту, придется задействовать несколько потоков. Все они сделают замеры и сложат результат в общий список, что, конечно, с точки зрения реализации выглядит так себе, но сойдет для сельской местности мы же для себя пишем. В потоке у нас будет выполняться простая функция, код которой представлен в листинге ниже.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
static void PerformPingTest(Object arg) { String host = (String)arg; int pkts_lost_row = 0; int local_success = 0; long local_time = 0; Ping ping = new Ping(); for (int i = 0; i < PING_COUNT; i++) { if (pkts_lost_row == 3) { // 3 lost packets in row // Handle here... } var result = ping.Send(host, PING_TIMEOUT); if (result.Status == IPStatus.Success){ // Handle success here } switch (result.Status) { ... } // Handle different fails } measure_results.Add(host, (int)(local_time / (local_success == 0 ? 1 : local_success))); exited_threads++; return; } |
Код сокращен, чтобы не тратить место. Сначала мы вытаскиваем адрес хоста в отдельную переменную. Почему нельзя было сразу String? Потому что этот метод передается как делегат в ParameterizedThreadStart, а он признает только аргументы типа Object. Далее объявляем локальные переменные для хранения промежуточного результата и подсчета packet loss. Отправляем пинг, смотрим результат. Ничего сложного. Перед любым выходом из функции помечаем, что поток свою работу выполнил и результаты уже доступны. Все!
РЕКОМЕНДУЕМ:
Лучшие игры для программистов и технарей
После отработки всех потоков главная функция должна забрать результаты и занести их в объект. Затем посчитать packet loss и указать, в порядке ли интернет, по мнению программы. Напомню критерии установки флага inet_ok в false:
Если провален хотя бы один из тестов: тест HTTP, средний пинг до всех хостов больше 0,75 тайм-аута, packet loss больше максимально допустимого или роутер ушел в офлайн.
Так и запишем. Полный код этой процедуры:
1 2 3 4 5 6 7 |
snapshot.avg_rtts = measure_results; snapshot.packet_loss = (double)(pkt_sent - success_pkts) / pkt_sent; snapshot.inet_ok = !( snapshot.http_ok == false || ((double)total_time / success_pkts >= 0.75 * PING_TIMEOUT) || snapshot.packet_loss >= MAX_PKT_LOSS || snapshot.router_rtt == PING_TIMEOUT); |
Дело за малым: сохранить снапшот и по необходимости отправить уведомление в «Телегу». Для этого я сделал функцию SaveSnapshot, которая только генерирует имя файла, сериализует снапшот и записывает его. Код не представляет никакого интереса, посмотреть его можно в репозитории к статье. Функция для отправки сообщений в Telegram приведена ниже.
1 2 3 4 5 6 7 8 9 |
static async void TgNotify(String message, bool with_sound) { if (!TG_NOTIFY) return; Dictionary<String, String> req_data = new Dictionary<string, string>(); req_data.Add("chat_id", TG_CHAT_ID); req_data.Add("text", message); req_data.Add("disable_notification", (!with_sound).ToString().ToLower()); String sf = JsonConvert.SerializeObject(req_data); var result = await httpc.PostAsync($"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", new StringContent(sf, System.Text.Encoding.UTF8, "application/json")); } |
Тут мы пользуемся HTTP Bot API «Телеграма», чтобы отправить сообщение. Проверка того, включены ли уведомления, выполняется тут же. Так как Telegram уважает только JSON, пришлось еще раз применить Json.NET. Остальное очевидно, только httpc — это System.Net.Http.HttpClient. Вызов этой функции мы разместим после проверки состояния сети в конце обработчика таймера, а еще в функции логирования, но сообщения оттуда будут приходить без звука.
Тестируем
После сборки и запуска программа выглядит как на скриншоте. Последуем ее указанию и запустимся с параметром -m.
Через пару минут видим, что в папке snapshots рядом с бинарником прибавилось файлов.
Некоторое время прототип программы проработал в фоне, после чего у меня собрался солидный лог. Откроем в Excel файл main.csv, лежащий в папке с программой. Видим следующую картину.
Теперь можно строить графики. Выбираем столбец D, затем Вставка → Диаграмма → График с накоплением. Регулируем шкалу и видим нечто похожее на картинку ниже.
Естественно, можно построить график чего угодно, вот, например, график потерь.
И еще немного скриншотов работы.
Построение таблицы по снапшотам
Все графики выше — это, конечно, хорошо, но что, если мы отключили запись таблицы из соображений экономии ресурсов? У нас еще есть папка со снапшотами в виде отдельных файлов JSON. Не руками же их разбирать в таблицу. Выход есть! Напишем парсер, а заодно сможем получить только отчет за последние N дней. Расчехляй Visual Studio, создавай новый проект, обзывай его как-нибудь, добавляй в зависимости Json.NET — и приступим.
Из основного проекта скопируем в этот структуру net_state.
Дальше программа должна реализовывать следующий алгоритм.
- Выбрать все записи за определенный период и прочитать их.
- Пройти все прочитанные записи и выбрать, какие хосты там пингуются. Составить полный список таких хостов.
- Составить новый заголовок CSV.
- Последовательно пройти все записи от старых к новым и записать данные из этих записей в соответствующие столбцы.
- Записать остатки в файл и выйти из программы.
Думаю, запросить у пользователя параметры ты можешь и сам, да и определить, какие файлы читать нужно, тоже. Мы же остановимся на генерации заголовка. Алгоритм прост как валенок:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Dictionary<String, int> host_ids = new Dictionary<string, int>(); int hid = 0; foreach (var ci in snaps) { foreach (var ch in ci.avg_rtts.Keys) { if (host_ids.ContainsKey(ch)) continue; host_ids.Add(ch, hid++); } } // Build CSV header with this information string csv_hdrs = ""; for (int i = 0; i < hid; i++) { csv_hdrs += $"RTT to {host_ids.Keys.ToArray()[i]}{delim}"; } tgtcsv = tgtcsv.Replace("AHR", csv_hdrs); |
Тут delim — это символ, разделяющий записи в выходном CSV (по умолчанию точка с запятой), snap — выбранные на прошлом этапе записи, а tgt_csv — содержимое выходного файла одной строкой.
После формирования заголовка нужно наполнить таблицу. Этим займется следующий алгоритм:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
foreach (var ninfo in snaps) { tgtcsv += $"{ninfo.measure_time.ToShortDateString()} " + $"{ninfo.measure_time.ToShortTimeString()}{delim}" + $"{ninfo.inet_ok}{delim}{ninfo.http_ok}{delim}" + $"{ninfo.avg_rtts.Values.Average().ToString("N2")}{delim}" + ninfo.router_rtt.ToString() + delim + (ninfo.packet_loss * 100).ToString("N2") + delim + $"{ninfo.measure_id}{delim}{seq_id++}{delim}" + $"{ninfo.measure_time.ToShortTimeString()}{delim}{delim}"; List<String> items = host_ids.Keys.ToList(); foreach (var ci in items) { if (ninfo.avg_rtts.ContainsKey(ci)) { tgtcsv += ninfo.avg_rtts[ci]; } tgtcsv += delim; } tgtcsv += $"{delim}\r\n"; } |
Тут мы в строках 3–11 формируем первую часть строки таблицы и приклеиваем ее к самой таблице. Затем проходим по найденным хостам, и, если в этом снапшоте есть измерение до этого хоста, записываем, иначе оставляем пустую клетку. После завершения текущей строки переходим к следующей. Если достигнута последняя строка, записываем все в выходной файл и выходим.
Я запустил программу на наборе из нескольких результатов измерений, получилось все правильно.
Заключение
Как видишь, написать простой анализатор сети, даже с уведомлениями в любую точку мира, совсем несложно. Более того, его очень легко допилить под свои нужды.
В самом начале был упомянут замер скорости соединения, но я не стал его реализовывать, так как точная динамика скорости в фоне не получится, кто-то обязательно будет использовать сеть в это время. А недостоверные или сложно проверяемые замеры мы делать не будем. При желании ты можешь сам добавить эту функцию, благо есть готовая библиотека для работы с сервисом Speedtest.net.
РЕКОМЕНДУЕМ:
Система распознавания лиц на Python
Любые дополнения, предложения, как улучшить, и замечания можешь написать в комментариях, а использование моего проекта в любых целях ничем не ограничено. Удачных замеров!
Здравствуйте, очень понравилось как Вы решили эту задачу.
Я нашёл вашу статью потому как у меня возникла похожая задача, есть некоторое приложение написанное на базе браузера, и есть задача — некоторые объекты загружаемые в игру сделаную на базе Adobe Flash Player, заменить другими пиктограммами. Не могли бы Вы помочь переделать (модернизировать) этот код для моей задачи.