network v0.4.2 [extra]
Модуль проверки локальной сети
README
# network
Карта домашней сети: кто онлайн, кто новый, кто пропал.
Тип: Модуль. Категория: `extra`. Зависимости: системный `nmap` (сканирование выполняется внешними вызовами `nmap`; без него модуль не видит устройства), pip-пакет `python-telegram-bot` (кнопки и диалоги в Telegram; пин версии — как у транспорта).
## Описание
Глаза бота в локальной сети (например, `192.168.0.0/24`).
Модуль периодически обходит сеть сканером и ведёт карту устройств: сверяет свежий обход с кешем и докладывает о четырёх событиях — новое устройство, переподключение, отключение и смена IP. Устройство считается пропавшим только после нескольких пропусков подряд, так что случайный пропуск одного обхода тревогу не поднимает. Мелкие повторы уведомлений склеиваются в одно сообщение за окно агрегации.
Про каждое устройство копятся статистика появлений и список открытых портов. Человеческие имена подписываются вручную — и из Telegram, и из веба. Знакомые MAC-адреса (свои устройства, которые неинтересны) складываются в игнор-лист и больше не шумят.
Модуль поддерживает подключение дополнительных источников данных через `register_monitor`: любой источник, умеющий периодически отдавать наблюдения, встраивается в ту же карту и те же уведомления. Длинные списки в Telegram режутся на чанки по 3000 символов.
## Возможности
- `scanner` `{}` → движок сканирования (кеш устройств)
- `cache_file` `{}` → путь кеша устройств
- `ignored_file` `{}` → путь игнор-листа
- `devices_snapshot` `{}` → `{total, online, devices[]}` — обогащённый снимок для мониторинга
- `register_monitor` `{name, factory, interval_s, type, silent}` → плагин доп. источника
## Права
- `nmap_manage` — Сканер сети (GET/POST /api/admin/nmap*)
- `receive_notifications_nmap` — Уведомления сканера сети (новые/потерянные устройства)
- `receive_notifications_nmap_edit` — Право менять себе уведомления сканера сети
## Telegram
- кнопка «📡 Сканирование» → меню, постраничные списки устройств и статистики (просмотр — право `view_net_status`, изменения — `nmap_manage`)
- переименование устройств диалогом (`net_setname` / `nmap_setname`)
## Web
- `GET /api/network/devices` — устройства для мониторинга
- `GET /api/admin/nmap` — устройства + игнор-лист
- `POST /api/admin/nmap/name` — подписать имя `{mac, name}`
- `POST /api/admin/nmap/ignore` — игнор `{action: add|remove, mac}`
- `POST /api/network/nmap/delete` — удалить устройство (только веб, в TG удаления нет)
- карточка «Сканер сети»
## События шины
- излучает `network.device_new`, `network.device_reconnected`, `network.device_disconnected`, `network.device_ip_changed` (по одному на устройство)
## Конфигурация (`config.schema.json`)
- обязательных нет; опциональные (дефолты):
- `SCAN_NETWORK_RANGE` = `10.10.10.0/24` — диапазон сканирования (задайте свою подсеть)
- `SCAN_ENABLED` = `1` — фоновый обход сети (`0` — только кеш/веб/мониторы, без скана)
- `CHECK_DURATION_NSCAN_LONG` = `30` — длинный цикл (пауза между обходами), сек
- `CHECK_DURATION_NSCAN_FAST` = `5` — быстрый цикл (мониторы доп. источников), сек
- `DEBOUNCE_COUNT` = `2` — пропусков подряд до «пропал» (nmap + пинг)
- `AGGREGATION_INTERVAL` = `300` — окно склейки мелких уведомлений, сек
- `AGGREGATION_MAX_CHARS` = `3500` — лимит склеенного текста
- `NOTIF_MAX_RETRIES` = `3` — ретраи уведомлений
- `NOTIF_RETRY_BACKOFF` = `1.5` — backoff ретраев
- `NOTIF_CHUNK_LIMIT` = `3000` — чанки TG (потолок Telegram 4096)
- `SCAN_NMAP_BIN` = `nmap` — бинарник сканера
- `SCAN_TIMING` = `-T4` — темп обхода (`-T0`…`-T5`; мусор → `-T4`)
- `SCAN_PRIVILEGED` = `1` — `--privileged` (ARP-скан; без root падает на `-T` connect)
- `SCAN_EXTRA_ARGS` = `` — доп. аргументы обхода (напр. `--min-rate 50`)
- `SCAN_TIMEOUT_S` = `300` — потолок одного обхода, сек (10…3600)
- `PORTSCAN_ENABLED` = `1` — портскан новых/вернувшихся (`0` — не сканировать порты)
- `PORTSCAN_PORTS` = `-p-` — какие порты (напр. `--top-ports 1000` вместо всех 65535)
- `PORTSCAN_TIMING` = `-T5` — темп портскана
- `PORTSCAN_TIMEOUT_S` = `600` — потолок портскана одного IP, сек (10…7200)
- `PING_BIN` = `ping`, `PING_COUNT` = `10` (1…100), `PING_TIMEOUT_S` = `20` (1…300) — перепроверка пропавших
- `NET_TG_PAGE` = `8` — строк на страницу TG-списков (1…50)
- `SCAN_STOP_TIMEOUT_S` = `10` — ожидание потоков при останове, сек
- `MONITOR_MIN_INTERVAL_S` = `5`, `MONITOR_MAX_INTERVAL_S` = `86400` — клампы `register_monitor`
## Как сканирует (безопасность)
- Обход: `nmap -sn` — только discovery (ARP/ICMP/TCP-пинг хостов своей подсети, порты не трогает). Безопасно и тихо: ~десятки пакетов на хост раз в `CHECK_DURATION_NSCAN_LONG`. Требует `cap_net_raw` у nmap (ставит `install.sh` через setcap) — иначе нет MAC-адресов.
- Хосты без MAC (скан без привилегий) сохраняются под ключом `ip:<адрес>` + warning в лог (раньше молча выбрасывались). Игнор-лист и кнопки переименования для них недоступны (не к чему привязать).
- Один MAC может отвечать с нескольких IP (алиасы на одном NIC). Устройство в кеше одно, все его адреса лежат в `ips`, основной — в `ip`; основной не меняется, пока виден в обходе (иначе у мульти-IP хоста каждые 60 с прыгал бы адрес и летели ложные `ip_changed`).
- Пропавшие перепроверяются `ping -c N` перед признанием «отключён» + `DEBOUNCE_COUNT` пропусков — ложных срабатываний нет.
- Портскан (`-p-`, все 65535) — только для новых и переподключившихся устройств, один раз, в фоне. Самый шумный кусок: при желании ужмите до `PORTSCAN_PORTS=--top-ports 1000` или выключите `PORTSCAN_ENABLED=0` (карта устройств продолжит работать).
Манифест
{
"name": "network",
"version": "0.4.2",
"category": "extra",
"description": "Сеть: сканер устройств, WireGuard/Guacamole/fail2ban-мониторы, MAC-блокировки, сниффер.",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "1dd562644e15cbec55e0adf11138168dfb690b6c",
"updated": "2026-10-20 10:00:45 +0300",
"history": [
{
"version": "0.1.0",
"date": "2026-09-08 09:46:42 +0300",
"commit": "5c758d465ca4dd3e1541909f2b685dc5c5424246"
},
{
"version": "0.2.0",
"date": "2026-09-11 10:24:38 +0300",
"commit": "1fa8180d936fbda6caa5efa0841f338fe2e569b1"
},
{
"version": "0.3.0",
"date": "2026-09-11 10:41:03 +0300",
"commit": "f645c6d24b4da92aba8e00156457b0c440f85d2b"
},
{
"version": "0.3.1",
"date": "2026-09-12 23:22:16 +0300",
"commit": "db9c47f797b3a96a29b646f86025a87d1eb4d645"
},
{
"version": "0.3.2",
"date": "2026-09-14 00:47:42 +0300",
"commit": "e8d36b3d51166a6c1dd13c3eb72f48cdbb7956d2"
},
{
"version": "0.3.3",
"date": "2026-09-14 00:56:05 +0300",
"commit": "c7ba9a668eae7e30625afcad5cecb5b5931575fe"
},
{
"version": "0.3.4",
"date": "2026-09-14 08:54:17 +0300",
"commit": "c515a4348823477dacf1fa00732253ffeabc4184"
},
{
"version": "0.3.5",
"date": "2026-09-14 09:04:12 +0300",
"commit": "c5b660a598e65f542b8b4a3355354f660e0cb8c1"
},
{
"version": "0.3.6",
"date": "2026-09-14 09:08:22 +0300",
"commit": "42a3a7207dcdee1f174e2a239a39861f6b5626e7"
},
{
"version": "0.3.7",
"date": "2026-09-14 09:14:29 +0300",
"commit": "dd208f5dd4618bea5f2134a82ceb3a4577385c43"
},
{
"version": "0.3.8",
"date": "2026-09-14 20:00:53 +0300",
"commit": "ff34f9c62413b043289a8c7b8151c3218e342695"
},
{
"version": "0.3.9",
"date": "2026-09-17 09:30:25 +0300",
"commit": "47801cc7f98e5d02856f09e224f586aba5c5d256"
},
{
"version": "0.4.0",
"date": "2026-10-10 17:04:21 +0300",
"commit": "078a7e70690791b0ea66f0766ef14e9d2d293b25"
},
{
"version": "0.4.1",
"date": "2026-10-12 17:08:48 +0300",
"commit": "0e7068e27ad93ead45ee753e9090ed49feda5f64"
},
{
"version": "0.4.2",
"date": "2026-10-20 09:52:13 +0300",
"commit": "64de3fc47719b23104a604b82be07693fc583a95"
}
],
"integrity": "sha256:747755c01ac0a0690d33ef46de41340c0612d7d944c687cc08a28ee1d4eca440",
"files": "[13 файлов — см. вкладки ниже]",
"note": "Модуль проверки локальной сети",
"wdesc": "",
"wbody": "",
"_extra": {
"api": "",
"events_emitted": "",
"events_subscribed": "[]",
"audit_allow": "",
"uses_rights": "",
"shared_routes": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 8.2 КБ
- 4.4 КБ
- 0.8 КБ
- 2.3 КБ
- 9.9 КБ
- 0.2 КБ
- 33.5 КБ
- 5.6 КБ
- 15.5 КБ
- 1.2 КБ
- 4.4 КБ
- web/
- 1.1 КБ
- 6.1 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
config.py 4.4 КБ
"""Значения конфига пакета network (читаются как config.X).
install() — до импорта движков: значения биндятся при импорте.
"""
import os
_vals: dict = {}
def _int(values: dict, key: str, default: int) -> int:
try:
return int(str(values.get(key, default) or default))
except (ValueError, TypeError):
return default
def _clamp_int(values: dict, key: str, default: int, lo: int, hi: int) -> int:
return max(lo, min(hi, _int(values, key, default)))
def _bool(values: dict, key: str, default: bool) -> bool:
raw = str(values.get(key, "1" if default else "0") or "").strip().lower()
return raw not in ("0", "false", "no", "off", "")
def install(values: dict, files: dict) -> None:
_vals.clear()
_vals.update(values)
_vals.update(files)
for k, d in (("CHECK_DURATION_NSCAN_LONG", 30), ("CHECK_DURATION_NSCAN_FAST", 5),
("DEBOUNCE_COUNT", 2), ("AGGREGATION_INTERVAL", 300),
("AGGREGATION_MAX_CHARS", 3500), ("NOTIF_MAX_RETRIES", 3),
("ROUTER_PORT", 22), ("TRAFFIC_MAX_SCANS", 50),
("TRAFFIC_MAX_PCAP_MB", 20), ("TRAFFIC_DEFAULT_DURATION", 30),
("TRAFFIC_MAX_DURATION", 120), ("TRAFFIC_DEFAULT_PACKETS", 2000),
("TRAFFIC_MAX_PACKETS", 10000)):
_vals[k] = _int(values, k, d)
try:
_vals["NOTIF_RETRY_BACKOFF"] = float(
str(values.get("NOTIF_RETRY_BACKOFF", "1.5") or "1.5"))
except (ValueError, TypeError):
_vals["NOTIF_RETRY_BACKOFF"] = 1.5
for k in ("ROUTER_HOST", "ROUTER_USER", "ROUTER_PASSWORD",
"SCAN_NETWORK_RANGE"):
_vals.setdefault(k, (values.get(k) or "").strip())
if "ROUTER_HOST" not in _vals or not _vals["ROUTER_HOST"]:
_vals["ROUTER_HOST"] = (values.get("ROUTER_HOST") or "10.10.10.1").strip()
if not _vals.get("ROUTER_USER"):
_vals["ROUTER_USER"] = (values.get("ROUTER_USER") or "root").strip()
if not _vals.get("SCAN_NETWORK_RANGE"):
_vals["SCAN_NETWORK_RANGE"] = (
values.get("SCAN_NETWORK_RANGE") or "10.10.10.0/24").strip()
prot = (values.get("MACBLOCK_PROTECTED") or "bc:24:11:39:ea:6c").strip()
_vals["MACBLOCK_PROTECTED"] = [m.strip().upper() for m in prot.split(",") if m.strip()]
_vals["ADMIN_PASS"] = values.get("ADMIN_PASS") or ""
for k, d in (("TRAFFIC_DEFAULT_DURATION", 30), ("TRAFFIC_DEFAULT_PACKETS", 2000)):
_vals[k] = _int(values, k, d)
# --- сканирование ---
_vals["SCAN_ENABLED"] = _bool(values, "SCAN_ENABLED", True)
_vals["SCAN_NMAP_BIN"] = (
values.get("SCAN_NMAP_BIN") or "nmap").strip() or "nmap"
timing = (values.get("SCAN_TIMING") or "-T4").strip()
_vals["SCAN_TIMING"] = timing if timing in (
"-T0", "-T1", "-T2", "-T3", "-T4", "-T5") else "-T4"
_vals["SCAN_PRIVILEGED"] = _bool(values, "SCAN_PRIVILEGED", True)
_vals["SCAN_EXTRA_ARGS"] = (values.get("SCAN_EXTRA_ARGS") or "").strip()
_vals["SCAN_TIMEOUT_S"] = _clamp_int(values, "SCAN_TIMEOUT_S", 300, 10, 3600)
_vals["PORTSCAN_ENABLED"] = _bool(values, "PORTSCAN_ENABLED", True)
_vals["PORTSCAN_PORTS"] = (
values.get("PORTSCAN_PORTS") or "-p-").strip() or "-p-"
ptiming = (values.get("PORTSCAN_TIMING") or "-T5").strip()
_vals["PORTSCAN_TIMING"] = ptiming if ptiming in (
"-T0", "-T1", "-T2", "-T3", "-T4", "-T5") else "-T5"
_vals["PORTSCAN_TIMEOUT_S"] = _clamp_int(
values, "PORTSCAN_TIMEOUT_S", 600, 10, 7200)
_vals["PING_BIN"] = (
values.get("PING_BIN") or "ping").strip() or "ping"
_vals["PING_COUNT"] = _clamp_int(values, "PING_COUNT", 10, 1, 100)
_vals["PING_TIMEOUT_S"] = _clamp_int(values, "PING_TIMEOUT_S", 20, 1, 300)
# Потолок Telegram — 4096 символов.
_vals["NOTIF_CHUNK_LIMIT"] = _clamp_int(
values, "NOTIF_CHUNK_LIMIT", 3000, 500, 4000)
_vals["NET_TG_PAGE"] = _clamp_int(values, "NET_TG_PAGE", 8, 1, 50)
_vals["SCAN_STOP_TIMEOUT_S"] = _clamp_int(
values, "SCAN_STOP_TIMEOUT_S", 10, 1, 120)
_vals["MONITOR_MIN_INTERVAL_S"] = _clamp_int(
values, "MONITOR_MIN_INTERVAL_S", 5, 1, 3600)
_vals["MONITOR_MAX_INTERVAL_S"] = _clamp_int(
values, "MONITOR_MAX_INTERVAL_S", 86400, 60, 604800)
def __getattr__(name: str):
try:
return _vals[name]
except KeyError:
raise AttributeError("network.config: нет ключа %s" % name) from None
module.py 9.9 КБ
"""Сканер сети + планировщик мониторов network_*."""
import logging
import os
import threading
from core.base_module import BaseModule
from core.legacy import RightsCheckerAdapter
from . import config as pkg_config
logger = logging.getLogger(__name__)
class Module(BaseModule):
def setup(self, ctx) -> None:
mod_dir = os.path.dirname(os.path.abspath(__file__))
self.config = ctx.module_config("network", mod_dir)
self.ctx = ctx
net_dir = os.path.join(ctx.data_dir, "cache", "network")
def _f(key, default):
return (self.config.get(key, "") or "").strip() or default
def _fp(key, default):
from core.paths import abs_data
return abs_data(ctx.data_dir,
(self.config.get(key, "") or "").strip(),
default)
files = {
"MESSAGE_QUEUE_FILE": _fp(
"MESSAGE_QUEUE_FILE", os.path.join(net_dir, "message_queue.json")),
"CACHE_FILE_NETWORK": _fp(
"CACHE_FILE_NETWORK", os.path.join(net_dir, "networkscan_cache.json")),
"SCAN_NETWORK_IGNORED_MACS": _fp(
"SCAN_NETWORK_IGNORED_MACS", os.path.join(net_dir, "networkscan_ignored.json")),
}
# Сканер пишет без makedirs — каталог создаём здесь.
for _d in (net_dir,):
try:
os.makedirs(_d, exist_ok=True)
except Exception: # noqa: BLE001
pass
# install — до импорта движков (значения биндятся при импорте).
pkg_config.install(dict(self.config.values), files)
try:
from . import scan as scan_mod
except ImportError as e:
from core.errors import ConfigError
raise ConfigError("network: движки требуют venv (telegram): %s" % e)
self.scan_mod = scan_mod
self.rights_adapter = RightsCheckerAdapter(ctx.rights)
try:
notify_ids = ctx.api.call("access.admin_ids")
except Exception: # noqa: BLE001
notify_ids = []
self.scanner = scan_mod.NetworkScanner(
files["MESSAGE_QUEUE_FILE"], files["CACHE_FILE_NETWORK"],
notify_ids, self.rights_adapter, None,
pkg_config.SCAN_NETWORK_RANGE, pkg_config.CHECK_DURATION_NSCAN_LONG,
pkg_config.CHECK_DURATION_NSCAN_FAST, files["SCAN_NETWORK_IGNORED_MACS"],
on_diff=self._on_diff, notify_api=ctx.api)
self._thread = None
self._mon_thread = None
self._mon_stop = threading.Event()
self._monitor_registry = {}
ctx.api.register("network", "scanner", lambda: self.scanner)
ctx.api.register("network", "cache_file", lambda: files["CACHE_FILE_NETWORK"])
ctx.api.register(
"network", "ignored_file", lambda: files["SCAN_NETWORK_IGNORED_MACS"])
ctx.api.register("network", "register_monitor", self._register_monitor)
from . import store as _store
ctx.api.register("network", "devices_snapshot", _store.devices_snapshot)
def _on_diff(self, diff):
mapping = (
("new", "network.device_new"),
("reconnected", "network.device_reconnected"),
("disconnected", "network.device_disconnected"),
("ip_changed", "network.device_ip_changed"),
)
for section, event in mapping:
for mac, dev in ((diff or {}).get(section) or {}).items():
try:
self.ctx.events.emit(event, {
"mac": mac,
"ip": dev.get("ip"),
"old_ip": dev.get("old_ip"),
"name": dev.get("name"),
"name_custom": dev.get("name_custom") or "",
"type": dev.get("type"),
"ports": list(dev.get("ports") or []),
"first_appearance": dev.get("first_appearance"),
"last_appearance": dev.get("last_appearance"),
"last_disconnection": dev.get("last_disconnection"),
"connections_total": dev.get("connections_total"),
})
except Exception: # noqa: BLE001
logger.exception("network emit %s failed", event)
def _register_monitor(self, name, spec):
"""Плагин доп. источника: {factory, interval_s, type, silent}."""
if not name or not isinstance(spec, dict):
raise ValueError("register_monitor: нужно имя и spec")
factory = spec.get("factory")
if not callable(factory):
raise ValueError("register_monitor %s: нет factory" % name)
try:
interval = int(spec.get("interval_s", 60) or 60)
except (TypeError, ValueError):
raise ValueError("register_monitor %s: interval_s — число" % name)
lo = getattr(pkg_config, "MONITOR_MIN_INTERVAL_S", 5)
hi = getattr(pkg_config, "MONITOR_MAX_INTERVAL_S", 86400)
interval = max(lo, min(hi, interval))
notif_type = str(spec.get("type") or name)
self._monitor_registry[str(name)] = {
"factory": factory, "interval": interval,
"type": notif_type, "silent": bool(spec.get("silent", False)),
}
logger.info("network: монитор %s каждые %s с", name, interval)
return True
def start(self) -> None:
try:
self.scanner.bot = self.ctx.api.call("transport_telegram.sender")
except Exception as e: # noqa: BLE001
logger.warning("network: нет sender: %s", e)
# Без BOT_TOKEN sender непривязан: TG-ветка fanout гаснет,
# веб-инбокс идёт штатно.
# notify.register — в start: все setup'ы уже выполнены.
try:
self.ctx.api.call("notify.register", "Nmap",
"receive_notifications_nmap",
"receive_notifications_nmap_edit", "network")
except Exception as e: # noqa: BLE001
logger.warning("network: notify.register: %s", e)
# Мониторы регистрируют сами владельцы через register_monitor.
monitors = {}
for name, spec in self._monitor_registry.items():
try:
monitors[name] = {
"monitor": spec["factory"](),
"interval": spec["interval"],
"type": spec["type"], "silent": spec["silent"]}
except Exception as e: # noqa: BLE001
logger.warning("network: монитор %s не поднялся: %s", name, e)
self._monitors = {n: m for n, m in monitors.items()
if m.get("monitor") is not None}
self._mon_stop.clear()
# SCAN_ENABLED=0 — без фонового обхода (кеш/веб/мониторы работают).
self._scan_enabled = bool(getattr(pkg_config, "SCAN_ENABLED", True))
if self._scan_enabled:
self._thread = threading.Thread(target=self._run_scan, daemon=True,
name="net-scanner")
self._thread.start()
else:
self._thread = None
logger.warning("network: SCAN_ENABLED=0 — фоновый скан выключен")
self._mon_thread = threading.Thread(target=self._run_monitors, daemon=True,
name="net-monitors")
self._mon_thread.start()
def _run_monitors(self):
import time as _time
due = {}
tick = 5
try:
tick = max(1, min([m["interval"] for m in self._monitors.values()] or [5]))
except Exception: # noqa: BLE001
pass
while not self._mon_stop.wait(tick):
now = _time.time()
for name, spec in list(self._monitors.items()):
if now < due.get(name, 0):
continue
due[name] = now + spec["interval"]
try:
msg = spec["monitor"].run_monitoring()
except Exception as e: # noqa: BLE001
logger.warning("network monitor %s: %s", name, type(e).__name__)
continue
try:
if msg:
self.scanner.send_notification(
msg, notif_type=spec["type"], disable_notif=spec["silent"])
else:
with self.scanner._queue_lock:
queued = [m for m in self.scanner.load_message_queue()
if m.get("type") == spec["type"]]
if queued:
self.scanner.send_notification(
"", notif_type=spec["type"], disable_notif=spec["silent"])
except Exception as e: # noqa: BLE001
logger.warning("network notify %s: %s", name, e)
def stop(self) -> None:
self._mon_stop.set()
timeout = getattr(pkg_config, "SCAN_STOP_TIMEOUT_S", 10)
for t in (self._thread, self._mon_thread):
if t is not None:
try:
t.join(timeout=timeout)
except Exception: # noqa: BLE001
pass
def _run_scan(self):
try:
self.scanner.start_scan()
except Exception as e: # noqa: BLE001
logger.error("network scan: %s", e)
def health(self) -> dict:
alive = self._thread.is_alive() if self._thread else False
return {"ok": self.scanner is not None, "module": self.name,
"scanner": alive,
"scan_enabled": bool(getattr(self, "_scan_enabled", True))}
scan.py 33.5 КБ
import subprocess
import json
import time
import logging
import os
from datetime import datetime, timedelta
import threading
from threading import Thread
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
import re
import copy
from . import config
logger = logging.getLogger(__name__)
def escape_markdown_v2(text):
"""Экранирование для MarkdownV2 (код-блоки не трогаем)."""
in_code = False
result = []
for line in text.split("\n"):
if line.strip().startswith("```"):
in_code = not in_code
result.append(line)
continue
if in_code:
result.append(line)
else:
result.append(re.sub(r'([_*\[\]()~>#+\-=|{}.!])', r'\\\1', line))
return "\n".join(result)
def format_duration(seconds):
return str(timedelta(seconds=seconds))
def _cfg(name, default):
try:
return getattr(config, name)
except AttributeError:
return default
def _ip_sort_key(ip):
"""Ключ числовой сортировки IP (10.0.0.9 < 10.0.0.10)."""
try:
return tuple(int(p) for p in ip.split("."))
except (AttributeError, ValueError):
return (999, 999, 999, 999)
def is_host_reachable(ip):
"""Пинг-доступность хоста (PING_* из конфига)."""
try:
result = subprocess.run(
[_cfg("PING_BIN", "ping"), "-c", str(_cfg("PING_COUNT", 10)), ip],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=_cfg("PING_TIMEOUT_S", 20),
)
return result.returncode == 0
except Exception as e:
logger.error(f"Ошибка проверки пинга для {ip}: {e}")
return False
def _extract_new_device_macs(text):
"""Извлекает MAC-адреса новых устройств (секции, начинающиеся с 🚨)."""
out = []
for m in re.finditer(r"\U0001F6A8.{0,300}?MAC:\s*((?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})",
text, re.S):
mac = m.group(1).upper().replace("-", ":")
if mac not in out:
out.append(mac)
return out
# MarkdownV2 раздувает текст — режем с запасом (потолок TG 4096).
TG_CHUNK_LIMIT = 3000
def split_tg_message(text, limit=None):
"""Резка уведомления на чанки по границам блоков. limit=None — из конфига."""
text = text or ""
if limit is None:
limit = _cfg("NOTIF_CHUNK_LIMIT", TG_CHUNK_LIMIT)
if len(text) <= limit:
return [text]
parts, cur, cur_len = [], [], 0
for block in text.split("\n\n"):
b = block.strip("\n")
if not b:
continue
if len(b) > limit:
if cur:
parts.append("\n\n".join(cur))
cur, cur_len = [], 0
for i in range(0, len(b), limit):
parts.append(b[i:i + limit])
continue
if cur and cur_len + len(b) + 2 > limit:
parts.append("\n\n".join(cur))
cur, cur_len = [], 0
cur.append(b)
cur_len += len(b) + 2
if cur:
parts.append("\n\n".join(cur))
return parts or [text]
class NetworkScanner:
def __init__(self, message_queue, cache_file, admin_ids,
rights_checker_class, sender,
network_range, long_check_interval, fast_check_interval,
mac_filter_file, on_diff=None, notify_api=None):
self.message_queue_file = message_queue
self._queue_lock = threading.Lock()
self.cache_file = cache_file
self.admin_ids = admin_ids
self.rights_checker = rights_checker_class
self.bot = sender
self._notify_api = notify_api
self.network_range = network_range
self.long_check_interval = long_check_interval
self.fast_check_interval = fast_check_interval
self.mac_filter_file = mac_filter_file
self.excluded_macs = self.load_mac_filter()
self._on_diff = on_diff
def load_cache(self):
if not os.path.exists(self.cache_file):
return {}
with open(self.cache_file, 'r') as f:
try:
return json.load(f)
except Exception as e:
logger.error(f"Ошибка загрузки кэша: {e}")
return {}
def save_cache(self, cache):
with open(self.cache_file, 'w') as f:
json.dump(cache, f, indent=4)
def load_mac_filter(self):
if not os.path.exists(self.mac_filter_file):
return []
try:
with open(self.mac_filter_file, 'r') as f:
mac_list = json.load(f)
if not isinstance(mac_list, list):
logger.error("Формат JSON для фильтра MAC должен быть списком.")
return []
return [mac.upper() for mac in mac_list]
except Exception as e:
logger.error(f"Ошибка загрузки MAC фильтра: {e}")
return []
def perform_network_scan(self):
"""Sweep-скан: nmap -sn (только discovery, без портов). Команда — из конфига."""
cmd = [_cfg("SCAN_NMAP_BIN", "nmap"), "-sn",
_cfg("SCAN_TIMING", "-T4")]
if _cfg("SCAN_PRIVILEGED", True):
cmd.append("--privileged")
extra = (_cfg("SCAN_EXTRA_ARGS", "") or "").split()
cmd.extend(extra)
cmd.append(self.network_range)
try:
result = subprocess.run(cmd, capture_output=True, text=True,
check=True,
timeout=_cfg("SCAN_TIMEOUT_S", 300))
except subprocess.TimeoutExpired:
logger.error("Сканирование сети: таймаут %s с (%s)",
_cfg("SCAN_TIMEOUT_S", 300), " ".join(cmd[:-1]))
return {}
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка сканирования сети: {e}")
return {}
devices = {}
current_device = None
macless = 0
self.excluded_macs = self.load_mac_filter()
def _flush(dev):
nonlocal macless
if not dev or dev.get("mac") or not dev.get("ip"):
return
key = "ip:%s" % dev["ip"]
if key not in devices:
macless += 1
devices[key] = dev
for line in result.stdout.splitlines():
line = line.strip()
if line.startswith("Nmap scan report for"):
_flush(current_device)
if '(' in line and ')' in line:
ip = line.split('(')[-1].split(')')[0]
hostname = line.split('(')[0].split()[-1]
else:
ip = line.split()[-1]
hostname = "Unknown"
current_device = {
"ip": ip,
"ips": [ip],
"name": hostname,
"mac": None,
"ports": [],
"type": "Unknown"
}
elif line.startswith("MAC Address:") and current_device is not None:
parts = line.split()
mac = parts[2].upper()
if mac in self.excluded_macs:
current_device = None
continue
current_device["mac"] = mac
if "(" in line and ")" in line:
vendor = line.split("(")[-1].split(")")[0]
current_device["type"] = vendor.strip()
# Один MAC может отвечать с нескольких IP (алиасы на NIC):
# запись в кеше одна, но все адреса копятся в "ips".
known = devices.get(mac)
if known is None:
devices[mac] = current_device
elif current_device["ip"] not in known["ips"]:
known["ips"].append(current_device["ip"])
current_device = None
_flush(current_device)
for dev in devices.values():
dev["ips"] = sorted(set(dev.get("ips") or [dev["ip"]]),
key=_ip_sort_key)
if macless:
logger.warning(
"nmap без MAC-адресов (%s хостов по IP): нет --privileged "
"(нужны cap_net_raw у nmap) — игнор-лист и имена по MAC "
"для них недоступны", macless)
return devices
def perform_port_scan(self, ip):
"""Портскан IP (только новые/вернувшиеся). PORTSCAN_ENABLED=0 — пропуск."""
if not _cfg("PORTSCAN_ENABLED", True):
return []
ports = (_cfg("PORTSCAN_PORTS", "-p-") or "-p-").split()
cmd = ([_cfg("SCAN_NMAP_BIN", "nmap")] + ports +
["--open", _cfg("PORTSCAN_TIMING", "-T5"), ip])
try:
result = subprocess.run(cmd, capture_output=True, text=True,
check=True,
timeout=_cfg("PORTSCAN_TIMEOUT_S", 600))
except subprocess.TimeoutExpired:
logger.error(f"Портскан {ip}: таймаут "
f"{_cfg('PORTSCAN_TIMEOUT_S', 600)} с")
return []
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка сканирования портов для {ip}: {e}")
return []
ports = []
for line in result.stdout.splitlines():
if "/tcp" in line and "open" in line:
port = line.split("/")[0]
ports.append(port)
return ports if ports else ["Нет открытых портов"]
def add_ignored_mac(self, mac):
mac = mac.upper()
cache = self.load_cache()
if mac not in cache:
return False, "MAC адрес не найден в кэше сети."
ignored = self.load_mac_filter()
if mac in ignored:
return False, "MAC адрес уже присутствует в игнор-листе."
ignored.append(mac)
try:
with open(self.mac_filter_file, "w", encoding="utf-8") as f:
json.dump(ignored, f, indent=4)
return True, "MAC адрес успешно добавлен в игнор-лист."
except Exception as e:
return False, f"Ошибка сохранения: {e}"
def remove_ignored_mac(self, mac):
mac = mac.upper()
ignored = self.load_mac_filter()
if mac not in ignored:
return False, "MAC адрес отсутствует в игнор-листе."
ignored.remove(mac)
try:
with open(self.mac_filter_file, "w", encoding="utf-8") as f:
json.dump(ignored, f, indent=4)
return True, "MAC адрес успешно удалён из игнор-листа."
except Exception as e:
return False, f"Ошибка сохранения: {e}"
def _ensure_stats(self, dev, now):
"""
Гарантирует наличие полей статистики у устройства и выполняет сброс
суточных счётчиков при смене календарного дня.
"""
if not dev.get("ips"):
dev["ips"] = [dev["ip"]] if dev.get("ip") else []
if "connections_total" not in dev:
dev["connections_total"] = 0
if "connections_today" not in dev:
dev["connections_today"] = 0
if "online_seconds_total" not in dev:
dev["online_seconds_total"] = 0
if "online_seconds_today" not in dev:
dev["online_seconds_today"] = 0
if "online_start_ts" not in dev:
dev["online_start_ts"] = None
today = datetime.now().strftime("%Y-%m-%d")
if dev.get("stats_date") != today:
dev["stats_date"] = today
dev["connections_today"] = 0
dev["online_seconds_today"] = 0
if dev.get("connected") and dev.get("online_start_ts") is None:
dev["online_start_ts"] = now
return dev
def update_cache_and_diff(self, scanned_devices):
"""Сверка скана с кешем → diff {new, reconnected, disconnected, ip_changed}."""
baseline_cache = self.load_cache()
editable_cache = copy.deepcopy(baseline_cache)
now = time.time()
diff = {"new": {}, "reconnected": {}, "disconnected": {}, "ip_changed": {}}
for mac, dev in scanned_devices.items():
if mac not in editable_cache:
dev["first_appearance"] = now
dev["last_appearance"] = now
dev["last_disconnection"] = None
dev["ports"] = self.perform_port_scan(dev["ip"])
dev["connected"] = True
dev["name_custom"] = ""
dev["miss_count"] = 0
dev["stats_date"] = datetime.now().strftime("%Y-%m-%d")
dev["connections_total"] = 1
dev["connections_today"] = 1
dev["online_seconds_total"] = 0
dev["online_seconds_today"] = 0
dev["online_start_ts"] = now
editable_cache[mac] = dev
diff["new"][mac] = dev
else:
cached = editable_cache[mac]
dev["first_appearance"] = cached.get("first_appearance", now)
dev["last_disconnection"] = cached.get("last_disconnection")
dev["name_custom"] = cached.get("name_custom", "")
dev["miss_count"] = 0
# Переносим статистику из кеша.
dev["connections_total"] = cached.get("connections_total", 0)
dev["connections_today"] = cached.get("connections_today", 0)
dev["online_seconds_total"] = cached.get("online_seconds_total", 0)
dev["online_seconds_today"] = cached.get("online_seconds_today", 0)
dev["online_start_ts"] = cached.get("online_start_ts")
dev["stats_date"] = cached.get("stats_date")
self._ensure_stats(dev, now)
# Основной IP не дёргаем, пока он в обходе: у мульти-IP хоста
# иначе каждые 60 с прыгал бы тот адрес, что сканер выдал
# последним (и летели бы ложные ip_changed).
ips = dev.get("ips") or [dev.get("ip")]
cached_ip = cached.get("ip")
if cached_ip and cached_ip in ips:
dev["ip"] = cached_ip
elif ips:
dev["ip"] = ips[0]
dev["ips"] = ips
old_ip = cached.get("ip")
if old_ip and old_ip != dev.get("ip"):
ip_info = copy.deepcopy(dev)
ip_info["old_ip"] = old_ip
diff["ip_changed"][mac] = ip_info
if not cached.get("connected"):
dev["last_appearance"] = now
dev["ports"] = self.perform_port_scan(dev["ip"])
dev["connected"] = True
dev["connections_total"] += 1
dev["connections_today"] += 1
dev["online_start_ts"] = now
editable_cache[mac] = dev
diff["reconnected"][mac] = dev
else:
dev["last_appearance"] = cached.get("last_appearance", now)
dev["ports"] = cached.get("ports", [])
dev["connected"] = True
editable_cache[mac] = dev
# Пропавшим считаем после DEBOUNCE_COUNT пропусков (нет в nmap
# и не отвечает на пинг).
for mac in list(editable_cache.keys()):
if mac not in scanned_devices and editable_cache[mac].get("connected"):
dev = editable_cache[mac]
self._ensure_stats(dev, now)
ip = dev.get("ip")
reachable = bool(ip) and is_host_reachable(ip)
if reachable:
dev["miss_count"] = 0
else:
miss = dev.get("miss_count", 0) + 1
dev["miss_count"] = miss
if miss >= config.DEBOUNCE_COUNT:
online_start = dev.get("online_start_ts")
if online_start:
dev["online_seconds_total"] = dev.get("online_seconds_total", 0) + (now - online_start)
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
dev["online_seconds_today"] = dev.get("online_seconds_today", 0) + (now - max(online_start, today_start))
dev["online_start_ts"] = None
dev["last_disconnection"] = now
dev["connected"] = False
diff["disconnected"][mac] = dev
# Параллельные правки name_custom не затираем.
current_cache = self.load_cache()
if current_cache != baseline_cache:
for mac, current_dev in current_cache.items():
if mac in baseline_cache:
baseline_name = baseline_cache[mac].get("name_custom", "")
current_name = current_dev.get("name_custom", "")
if current_name != baseline_name:
if mac in editable_cache:
editable_cache[mac]["name_custom"] = current_name
else:
self._ensure_stats(current_dev, now)
editable_cache[mac] = current_dev
self.save_cache(editable_cache)
return diff
def produce_notification(self, diff):
"""Текст уведомления по diff."""
message = ""
def _id_line(mac, dev):
if mac.startswith("ip:"):
return "IP: %s (без MAC)\n" % dev.get("ip", "Unknown")
return "MAC: %s\n" % mac
if diff.get("new"):
for mac, dev in diff["new"].items():
rule = "new"
ip = dev.get("ip", "Unknown")
message += f"🚨 {ip} ({rule}):\n"
message += "```\n"
message += _id_line(mac, dev)
message += f"Name: {dev.get('name', 'Unknown')}\n"
message += f"Ports: {', '.join(dev.get('ports', []))}\n"
message += f"Type: {dev.get('type', 'Unknown')}\n"
message += f"First appearance: {datetime.fromtimestamp(dev.get('first_appearance')).strftime('%Y-%m-%d %H:%M:%S')}\n"
message += "```\n\n"
if diff.get("reconnected"):
for mac, dev in diff["reconnected"].items():
last_disconnection = dev.get("last_disconnection")
name_custom = dev.get("name_custom", "")
ip = dev.get("ip", "Unknown")
if last_disconnection is not None:
duration = format_duration(int(time.time() - last_disconnection))
rule = f"connected after {duration} offline"
else:
rule = "connected"
if name_custom:
message += f"🔄 {name_custom} ({rule}):\n"
else:
message += f"🔄 {ip} ({rule}):\n"
message += "```\n"
if name_custom:
message += f"IP: {ip}\n"
message += _id_line(mac, dev)
message += f"Name: {dev.get('name', 'Unknown')}\n"
message += f"Ports: {', '.join(dev.get('ports', []))}\n"
message += f"Type: {dev.get('type', 'Unknown')}\n"
message += f"First appearance: {datetime.fromtimestamp(dev.get('first_appearance')).strftime('%Y-%m-%d %H:%M:%S')}\n"
message += "```\n\n"
if diff.get("disconnected"):
for mac, dev in diff["disconnected"].items():
last_appearance = dev.get("last_appearance")
name_custom = dev.get("name_custom", "")
ip = dev.get("ip", "Unknown")
if last_appearance is not None:
duration = format_duration(int(time.time() - last_appearance))
rule = f"disconnected after {duration} online"
else:
rule = "disconnected"
if name_custom:
message += f"🔌 {name_custom} ({rule}):\n"
else:
message += f"🔌 {ip} ({rule}):\n"
message += "```\n"
if name_custom:
message += f"IP: {ip}\n"
message += _id_line(mac, dev)
message += f"Name: {dev.get('name', 'Unknown')}\n"
message += f"Ports: {', '.join(dev.get('ports', []))}\n"
message += f"Type: {dev.get('type', 'Unknown')}\n"
message += f"First appearance: {datetime.fromtimestamp(dev.get('first_appearance')).strftime('%Y-%m-%d %H:%M:%S')}\n"
message += "```\n\n"
if diff.get("ip_changed"):
for mac, dev in diff["ip_changed"].items():
name_custom = dev.get("name_custom", "")
ip = dev.get("ip", "Unknown")
old_ip = dev.get("old_ip", "Unknown")
title = name_custom if name_custom else ip
message += f"📍 {title} сменил IP:\n"
message += f" • Было: {old_ip}\n"
message += f" • Стало: {ip}\n"
message += f" • MAC: {mac}\n"
message += f" • Имя: {dev.get('name', 'Unknown')}\n\n"
return message
def load_message_queue(self):
"""Записи очереди: {"timestamp", "message", "type"}."""
if not os.path.exists(self.message_queue_file):
return []
try:
with open(self.message_queue_file, "r") as f:
return json.load(f)
except Exception as e:
logger.error(f"Ошибка загрузки очереди сообщений: {e}")
return []
def save_message_queue(self, queue):
try:
with open(self.message_queue_file, "w") as f:
json.dump(queue, f, indent=4)
except Exception as e:
logger.error(f"Ошибка сохранения очереди сообщений: {e}")
def generate_inline_buttons_from_raw_message(self, raw_message):
"""Кнопки переименования безымянных из текста (один MAC — одна кнопка)."""
try:
mac_pattern = r'(?:[0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}'
mac_addresses = re.findall(mac_pattern, raw_message)
if not mac_addresses:
return None
network_cache = self.load_cache()
add_buttons = []
seen = set()
for mac in mac_addresses:
mac_upper = mac.upper().replace("-", ":")
if mac_upper in seen:
continue
seen.add(mac_upper)
if mac_upper in network_cache:
dev = network_cache[mac_upper]
if not dev.get("name_custom", "").strip():
add_buttons.append(InlineKeyboardButton(f"📝 {mac_upper}", callback_data=f"nmap_setname:{mac_upper}"))
if add_buttons:
rows = [add_buttons[i:i+2] for i in range(0, len(add_buttons), 2)]
return InlineKeyboardMarkup(rows)
else:
return None
except Exception as e:
logger.error(f"Ошибка формирования inline-кнопок по сырому сообщению: {e}")
return None
def _send_with_retry(self, chat_id, text, **kwargs):
"""Отправка с ретраями (NOTIF_* из конфига)."""
delay = config.NOTIF_RETRY_BACKOFF
for attempt in range(1, config.NOTIF_MAX_RETRIES + 1):
try:
self.bot.send_message(chat_id=chat_id, text=text, **kwargs)
return True
except Exception as e:
logger.warning(
f"Отправка сообщения для {chat_id}: попытка {attempt}/{config.NOTIF_MAX_RETRIES} не удалась: {e}"
)
if attempt < config.NOTIF_MAX_RETRIES:
time.sleep(delay)
delay *= 2
return False
def send_notification(self, raw_message, notif_type, disable_notif=False):
"""Уведомление типа notif_type: fanout (TG-чанки + веб), очередь при недоставке."""
permission_mapping = {
"wireguard": "receive_notifications_wireguard",
"guacamole": "receive_notifications_guacamole",
"f2b": "receive_notifications_f2b",
"status": "receive_notifications_status",
"nmap": "receive_notifications_nmap"
}
permission_key = permission_mapping.get(notif_type)
if not permission_key:
logger.error(f"Неизвестный тип уведомлений: {notif_type}")
return
# Чтение→отправка→сохранение — атомарно (два потока).
with self._queue_lock:
queue = self.load_message_queue()
filtered_queue = [msg for msg in queue if msg.get("type") == notif_type]
# Длинные режем на чанки — иначе TG 'Message is too long'.
messages = []
if raw_message:
messages.append(raw_message)
for queued_msg in filtered_queue:
messages.append(queued_msg["message"] + " [Отправлено с задержкой, первоначальное время: " + queued_msg["timestamp"] + "]\n\n")
if not messages:
return
outgoing = []
for message in messages:
chunks = split_tg_message(message)
for ci, ch in enumerate(chunks):
outgoing.append((ch, ci == len(chunks) - 1))
additional_markup = self.generate_inline_buttons_from_raw_message("\n".join(messages))
acl_block_rows = []
if notif_type == "nmap":
new_macs = _extract_new_device_macs("\n".join(messages))
for i in range(0, len(new_macs), 2):
acl_block_rows.append([
InlineKeyboardButton(
f"📔 Блокировать {m}",
callback_data=f"acl_ask:{m}")
for m in new_macs[i:i+2]
])
logger.info(f"Отправка уведомления типа {notif_type}: "
f"{len(messages)} сообщ., {len(outgoing)} чанков, "
f"всего {sum(len(m) for m in messages)} символов")
# tg_extra: per-admin кнопки; чанки шлём здесь (fanout везёт
# цельный текст, чанки с MarkdownV2 — специфика TG).
has = self.rights_checker.has_right
api = self._notify_api
checker = self.rights_checker
def _tg_extra(chat_id, sender):
try:
qid = "telegram:%d" % int(chat_id)
except (TypeError, ValueError):
return
if has(qid, "nmap_manage") or has(qid, "all"):
reply_markup_sent = additional_markup
else:
reply_markup_sent = None
if acl_block_rows and (has(qid, "network_manage") or has(qid, "all")):
base_rows = list(reply_markup_sent.inline_keyboard) if reply_markup_sent else []
reply_markup_sent = InlineKeyboardMarkup(base_rows + acl_block_rows)
for chunk, is_last in outgoing:
ok = self._send_with_retry(
chat_id, escape_markdown_v2(chunk),
disable_notification=bool(disable_notif),
parse_mode="MarkdownV2",
reply_markup=reply_markup_sent if is_last else None)
if not ok:
raise RuntimeError("чанк не доставлен %s" % chat_id)
tg_ok = True
web_ok = True
failed = False
if api is not None:
try:
res = api.call(
"notify.fanout", permission_key, "\n\n".join(messages),
kind="nmap" if notif_type == "nmap" else "send",
retry=3, tg_extra=_tg_extra,
sender_name="network.scan", tg_skip_text=True)
# Пустые получатели — тоже успех (иначе очередь росла бы вечно).
tg_ok = True
web_ok = True
except Exception as e: # noqa: BLE001
logger.warning(f"notify.fanout {notif_type}: {e}")
failed = True
else:
# Без ctx.api (unit-тесты): прямой путь через self.bot.
for admin_id in self.admin_ids:
try:
can_receive, no_sound = checker.should_notify(
admin_id, permission_key)
except Exception: # noqa: BLE001
continue
if not can_receive:
continue
try:
for chunk, is_last in outgoing:
ok = self._send_with_retry(
admin_id, escape_markdown_v2(chunk),
disable_notification=bool(disable_notif or no_sound),
parse_mode="MarkdownV2")
if not ok:
failed = True
except Exception as e: # noqa: BLE001
logger.warning(f"Ошибка отправки уведомления для {admin_id}: {e}")
failed = True
if failed and raw_message:
unsent = self.load_message_queue()
if not any(m.get("type") == notif_type and m.get("message") == raw_message for m in unsent):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
unsent.append({"timestamp": timestamp, "message": raw_message, "type": notif_type})
self.save_message_queue(unsent)
elif not failed:
new_queue = [msg for msg in queue if msg.get("type") != notif_type]
self.save_message_queue(new_queue)
def start_long_checks(self):
"""Цикл: скан → diff → уведомления (новые — сразу, мелочи — склейка)."""
pending = []
pending_first_ts = None
while True:
logger.debug("Запуск долгих проверок: сканирование сети...")
scanned_devices = self.perform_network_scan()
diff = self.update_cache_and_diff(scanned_devices)
if self._on_diff is not None:
try:
self._on_diff(diff)
except Exception: # noqa: BLE001
logger.exception("on_diff hook failed")
significant = bool(diff.get("new"))
raw_message = self.produce_notification(diff)
now = time.time()
def flush_minor():
nonlocal pending, pending_first_ts
if pending:
self.send_notification("\n\n".join(pending), notif_type="nmap", disable_notif=True)
pending = []
pending_first_ts = None
if raw_message:
if significant:
flush_minor()
self.send_notification(raw_message, notif_type="nmap", disable_notif=False)
else:
pending.append(raw_message)
if pending_first_ts is None:
pending_first_ts = now
combined_len = sum(len(m) for m in pending)
if (combined_len >= config.AGGREGATION_MAX_CHARS
or (now - pending_first_ts) >= config.AGGREGATION_INTERVAL):
flush_minor()
else:
if pending and (now - pending_first_ts) >= config.AGGREGATION_INTERVAL:
flush_minor()
else:
with self._queue_lock:
nmap_queue = [msg for msg in self.load_message_queue() if msg.get("type") == "nmap"]
if nmap_queue:
self.send_notification("", notif_type="nmap", disable_notif=True)
time.sleep(self.long_check_interval)
def start_scan(self):
"""Фоновый цикл долгих проверок."""
Thread(target=self.start_long_checks).start()
store.py 5.6 КБ
"""IO кеша сканера и игнор-листа (только через эти функции)."""
import json
import os
def _paths():
from . import config as pkg_config
return pkg_config.CACHE_FILE_NETWORK, pkg_config.SCAN_NETWORK_IGNORED_MACS
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _write_json(path, data):
tmp = path + ".tmp"
os.makedirs(os.path.dirname(tmp) or ".", exist_ok=True)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
os.replace(tmp, path)
def load_devices():
cache_file, _ = _paths()
data = _read_json(cache_file)
return data if isinstance(data, dict) else {}
def save_devices(cache) -> None:
cache_file, _ = _paths()
_write_json(cache_file, cache or {})
def set_device_name(mac, name):
"""Кастомное имя. Вернуть (ok, err)."""
cache = load_devices()
if mac not in cache:
return False, "MAC не найден в кэше"
cache[mac]["name_custom"] = name
try:
save_devices(cache)
except Exception as e: # noqa: BLE001
return False, str(e)
return True, ""
def delete_device(mac):
"""Удалить устройство из кеша. Вернуть (ok, err).
Вернётся при следующем скане, если в сети (сброс истории, не бан)."""
cache = load_devices()
if mac not in cache:
return False, "MAC не найден в кэше"
try:
del cache[mac]
save_devices(cache)
except Exception as e: # noqa: BLE001
return False, str(e)
return True, ""
def load_ignored():
_, ignored_file = _paths()
data = _read_json(ignored_file)
return data if isinstance(data, list) else []
def device_ips(d):
"""Все IP устройства (у одного MAC их бывает несколько): основной первым."""
ips = list(d.get("ips") or [])
ip = d.get("ip")
if ip:
ips = [ip] + [x for x in ips if x != ip]
return ips
def devices_snapshot():
"""Снимок {total, online, devices[]} для панели мониторинга."""
import time as _time
def _fmt_ts(ts):
if not ts:
return None
return _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(ts))
def _fmt_duration(sec):
if sec is None or sec < 0:
return "—"
sec = int(sec)
if sec < 60:
return f"{sec} сек"
m, s = divmod(sec, 60)
h, m = divmod(m, 60)
d, h = divmod(h, 24)
parts = []
if d:
parts.append(f"{d} д")
if h:
parts.append(f"{h} ч")
if m:
parts.append(f"{m} мин")
if not parts and s:
parts.append(f"{s} сек")
return " ".join(parts) or "0 мин"
scan = load_devices()
now_ts = _time.time()
devices = []
for mac, d in scan.items():
connected = bool(d.get("connected"))
online_start = d.get("online_start_ts")
last_appearance = d.get("last_appearance")
last_disconnection = d.get("last_disconnection")
first_appearance = d.get("first_appearance")
if connected:
last_change_ts = online_start if online_start else (last_appearance or first_appearance)
else:
last_change_ts = last_disconnection if last_disconnection else (last_appearance or first_appearance)
duration_s = int(now_ts - last_change_ts) if last_change_ts else 0
if duration_s < 0:
duration_s = 0
devices.append({
"name": d.get("name_custom") or d.get("name") or mac,
"ip": d.get("ip"),
"ips": device_ips(d),
"mac": mac,
"online": connected,
"ports": d.get("ports") or [],
"last_seen": _fmt_ts(last_appearance),
"offline_since": _fmt_ts(last_disconnection),
"first_appearance": _fmt_ts(first_appearance),
"last_change": _fmt_ts(last_change_ts),
"last_change_ts": last_change_ts,
"duration_s": duration_s,
"duration_str": _fmt_duration(duration_s) if last_change_ts else "—",
"connections_total": d.get("connections_total"),
"connections_today": d.get("connections_today"),
"online_seconds_total": d.get("online_seconds_total"),
"online_seconds_today": d.get("online_seconds_today"),
})
devices.sort(key=lambda x: (not x["online"], (x["name"] or "").lower()))
return {"total": len(devices),
"online": sum(1 for d in devices if d["online"]),
"devices": devices}
def save_ignored(ignored) -> None:
_, ignored_file = _paths()
_write_json(ignored_file, ignored or [])
def set_ignored(action, mac):
"""add/remove MAC в игнор-листе. Вернуть (ignored|None, err)."""
ignored = load_ignored()
ignored_up = [x.upper() for x in ignored]
if action == "add":
if mac in ignored_up:
return None, "MAC уже в игнор-листе"
if mac not in load_devices():
return None, "MAC не найден в кэше сети"
ignored.append(mac)
else:
if mac not in ignored_up:
return None, "MAC нет в игнор-листе"
ignored = [x for x in ignored if x.upper() != mac]
try:
save_ignored(ignored)
except Exception as e: # noqa: BLE001
return None, str(e)
return ignored, ""
tg.py 15.5 КБ
"""TG-меню сканирования: статус/статистика, имена MAC, игнор-лист."""
import logging
import re
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import DispatcherHandlerStop
from . import store as _store
logger = logging.getLogger(__name__)
_VIEW_RIGHT = "view_net_status"
_EDIT_RIGHT = "nmap_manage"
_PAGE = 8
_MAC_RE = r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$"
def _page_size():
"""Строк на страницу (NET_TG_PAGE из конфига)."""
try:
from . import config as _cfg
return max(1, min(50, int(_cfg.NET_TG_PAGE)))
except Exception: # noqa: BLE001
return _PAGE
def _qid(uid):
s = str(uid)
return s if ":" in s else "telegram:%s" % s
def _display(d):
return (d.get("name_custom") or d.get("name") or d.get("mac")
or "Unknown")
def _ips(d):
"""Все IP устройства (у одного MAC бывает несколько): основной первым."""
ips = list(d.get("ips") or [])
ip = d.get("ip")
if ip:
ips = [ip] + [x for x in ips if x != ip]
return ", ".join(ips) or "Unknown"
class NetMenu:
def __init__(self, ctx):
self.ctx = ctx
def _can_view(self, user_id):
try:
return bool(self.ctx.rights.can(_qid(user_id), _VIEW_RIGHT))
except Exception: # noqa: BLE001
return False
def _can_edit(self, user_id):
try:
return bool(self.ctx.rights.can(_qid(user_id), _EDIT_RIGHT))
except Exception: # noqa: BLE001
return False
def _devices(self):
try:
snap = self.ctx.api.call("network.devices_snapshot") or {}
except Exception as e: # noqa: BLE001
logger.error("network TG snapshot: %s", e)
return []
devs = snap.get("devices") or []
return [d for d in devs if isinstance(d, dict)]
def _send(self, bot, chat_id, text, reply_markup=None):
try:
bot.send_message(chat_id=chat_id, text=text,
reply_markup=reply_markup)
except Exception as e: # noqa: BLE001
logger.error("network TG send: %s", e)
# -- меню --
def show_menu(self, update, context):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_view(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("📊 Статус сети",
callback_data="net_status")],
[InlineKeyboardButton("📈 Статистика устройств",
callback_data="net_stats")],
[InlineKeyboardButton("📝 Изменить имя для MAC",
callback_data="net_addname")],
[InlineKeyboardButton("🔇 Фильтр MAC",
callback_data="net_ignore")],
[InlineKeyboardButton("🏠 Главное меню",
callback_data="main_menu")],
])
self._send(context.bot, chat_id, "Меню сканирования:", kb)
# -- статус --
def show_status(self, update, context, page=1):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_view(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
devs = self._devices()
if not devs:
self._send(context.bot, chat_id, "Ошибка загрузки кэша сети.")
return
page_len = _page_size()
total_pages = max(1, (len(devs) + page_len - 1) // page_len)
page = max(1, min(page, total_pages))
chunk = devs[(page - 1) * page_len:page * page_len]
header = "Обзор сети"
if total_pages > 1:
header += " (стр. %s/%s)" % (page, total_pages)
lines = [header, ""]
for d in chunk:
st = "🔄" if d.get("online") else "🔌"
lines.append("%s %s (%s) - %s" % (
st, d.get("mac") or "?", _display(d),
_ips(d)))
lines += ["", "Выберете MAC для изменения имени."]
mac_buttons = []
for d in chunk:
mac = d.get("mac") or ""
if not mac:
continue
mac_buttons.append(InlineKeyboardButton(
_display(d), callback_data="net_setname:%s" % mac))
rows = [mac_buttons[i:i + 3] for i in range(0, len(mac_buttons), 3)]
nav = []
if page > 1:
nav.append(InlineKeyboardButton(
"⬅️", callback_data="net_status:%s" % (page - 1)))
nav.append(InlineKeyboardButton("%s/%s" % (page, total_pages),
callback_data="net_noop"))
if page < total_pages:
nav.append(InlineKeyboardButton(
"➡️", callback_data="net_status:%s" % (page + 1)))
rows.append(nav)
rows.append([InlineKeyboardButton("🔙 Назад",
callback_data="net_menu")])
self._send(context.bot, chat_id, "\n".join(lines),
InlineKeyboardMarkup(rows))
# -- статистика --
def show_stats(self, update, context, page=1):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_view(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
devs = self._devices()
if not devs:
self._send(context.bot, chat_id, "Ошибка загрузки кэша сети.")
return
online = sum(1 for d in devs if d.get("online"))
text = ("Всего устройств: %s · Онлайн: %s · Офлайн: %s\n\n" % (
len(devs), online, len(devs) - online))
page_len = _page_size()
total_pages = max(1, (len(devs) + page_len - 1) // page_len)
page = max(1, min(page, total_pages))
chunk = devs[(page - 1) * page_len:page * page_len]
lines = [text]
for d in chunk:
st = "🔄" if d.get("online") else "🔌"
lines.append("%s %s (%s)" % (st, _display(d),
_ips(d)))
lines.append(" коннектов: %s/%s · онлайн: %s" % (
d.get("connections_today", 0),
d.get("connections_total", 0),
_fmt_online(d.get("online_seconds_total", 0))))
if total_pages > 1:
lines.append("(стр. %s/%s)" % (page, total_pages))
nav = []
if page > 1:
nav.append(InlineKeyboardButton(
"⬅️", callback_data="net_stats:%s" % (page - 1)))
if page < total_pages:
nav.append(InlineKeyboardButton(
"➡️", callback_data="net_stats:%s" % (page + 1)))
rows = [nav] if nav else []
rows.append([InlineKeyboardButton("🔙 Назад",
callback_data="net_menu")])
self._send(context.bot, chat_id, "\n".join(lines),
InlineKeyboardMarkup(rows))
# -- переименование --
def ask_mac(self, update, context):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_edit(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
context.chat_data["awaiting"] = "net_addname"
self._send(context.bot, chat_id,
"Введите MAC адрес для которого хотите изменить имя:")
def ask_name(self, update, context, mac):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_edit(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
context.chat_data["awaiting"] = "net_setname"
context.chat_data["mac_for_setname"] = mac
self._send(context.bot, chat_id,
"Введите новое имя для MAC %s:" % mac)
# -- игнор --
def show_ignore(self, update, context):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_edit(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
try:
ignored = _store.load_ignored() or []
except Exception: # noqa: BLE001
ignored = []
text = ("🔇 Игнор-лист (%s):\n%s" % (
len(ignored),
"\n".join("• %s" % m for m in ignored) if ignored else "— пусто —"))
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("➕ Добавить MAC",
callback_data="net_ignore_add")],
[InlineKeyboardButton("❌ Удалить MAC",
callback_data="net_ignore_del")],
[InlineKeyboardButton("🔙 Назад", callback_data="net_menu")],
])
self._send(context.bot, chat_id, text, kb)
def ask_ignore(self, update, context, action):
query = update.callback_query
try:
query.answer()
except Exception: # noqa: BLE001
pass
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_edit(user_id):
self._send(context.bot, chat_id, "🚫 Доступ запрещён!")
return
context.chat_data["awaiting"] = ("net_ignore_add"
if action == "add"
else "net_ignore_del")
self._send(context.bot, chat_id,
"Введите MAC адрес для %s в игнор-лист:" % (
"добавления" if action == "add" else "удаления"))
# -- ввод текстом --
def on_text(self, update, context):
state = context.chat_data.get("awaiting")
if state not in ("net_addname", "net_setname", "net_ignore_add",
"net_ignore_del"):
return
context.chat_data.pop("awaiting", None)
try:
user_id = update.effective_user.id
chat_id = update.effective_chat.id
if not self._can_edit(user_id):
return
text = (update.message.text or "").strip()
if state == "net_addname":
if not re.match(_MAC_RE, text):
self._send(context.bot, chat_id,
"❌ Некорректный MAC. Формат: AA:BB:CC:DD:EE:FF.")
return
context.chat_data["awaiting"] = "net_setname"
context.chat_data["mac_for_setname"] = text.upper()
self._send(context.bot, chat_id,
"Введите новое имя для MAC %s:" % text.upper())
return
if state == "net_setname":
mac = context.chat_data.pop("mac_for_setname", "")
if not mac or not text:
self._send(context.bot, chat_id, "❌ Отменено.")
return
ok, err = _store.set_device_name(mac, text[:64])
self._send(context.bot, chat_id,
"✅ Имя сохранено: %s" % mac if ok
else "⚠️ %s" % (err or "ошибка"))
return
action = "add" if state == "net_ignore_add" else "remove"
if not re.match(_MAC_RE, text):
self._send(context.bot, chat_id,
"❌ Некорректный MAC. Формат: AA:BB:CC:DD:EE:FF.")
return
_, err = _store.set_ignored(action, text.upper())
self._send(context.bot, chat_id,
"✅ Игнор-лист обновлён." if not err
else "⚠️ %s" % err)
finally:
raise DispatcherHandlerStop()
def handle(self, update, context):
data = (update.callback_query.data or "")
try:
context.bot.delete_message(
update.callback_query.message.chat_id,
update.callback_query.message.message_id)
except Exception: # noqa: BLE001
pass
if data == "main_menu":
return
if data == "net_noop":
try:
update.callback_query.answer()
except Exception: # noqa: BLE001
pass
return
if data in ("net_menu", "net_status"):
if data == "net_menu":
self.show_menu(update, context)
else:
self.show_status(update, context, 1)
elif data.startswith("net_status:"):
try:
page = int(data.split(":", 1)[1])
except ValueError:
page = 1
self.show_status(update, context, page)
elif data in ("net_stats",):
self.show_stats(update, context, 1)
elif data.startswith("net_stats:"):
try:
page = int(data.split(":", 1)[1])
except ValueError:
page = 1
self.show_stats(update, context, page)
elif data == "net_addname":
self.ask_mac(update, context)
elif data.startswith("net_setname:"):
self.ask_name(update, context, data.split(":", 1)[1])
elif data.startswith("nmap_setname:"):
# Кнопки из уведомлений сканера — тот же диалог.
self.ask_name(update, context, data.split(":", 1)[1])
elif data == "net_ignore":
self.show_ignore(update, context)
elif data == "net_ignore_add":
self.ask_ignore(update, context, "add")
elif data == "net_ignore_del":
self.ask_ignore(update, context, "del")
raise DispatcherHandlerStop()
def _fmt_online(sec):
try:
sec = int(sec or 0)
except (TypeError, ValueError):
return "—"
if sec < 60:
return "%s сек" % sec
m, s = divmod(sec, 60)
h, m = divmod(m, 60)
d, h = divmod(h, 24)
parts = []
if d:
parts.append("%s д" % d)
if h:
parts.append("%s ч" % h)
if m:
parts.append("%s мин" % m)
return " ".join(parts) or "0 мин"
ui_tg.py 1.2 КБ
"""Кнопка «📡 Сканирование» + регистрация хендлеров меню на фасаде."""
TG_COMMANDS = {}
TG_MENU = [
{"id": "netscan", "title": "📡 Сканирование", "callback": "net_menu",
"right": "view_net_status"},
]
def register_ptb(facade, mod):
"""Меню сети на фасаде напрямую (колбэки net_*).
Группа -1 — раньше catch-all admin.button (группа 0). Ввод текста —
группа -5: в -6 уже сидит fail2ban.on_text (внутри группы срабатывает
только первый совпавший хендлер).
"""
from telegram.ext import CallbackQueryHandler, MessageHandler, Filters
from .tg import NetMenu
menu = NetMenu(mod.ctx)
mod.net_menu = menu
facade.add_handler(CallbackQueryHandler(
menu.handle,
pattern=(r"^(net_menu|net_status|net_stats|net_addname|"
r"net_setname:|net_ignore|net_noop|"
r"main_menu|nmap_setname:)")), group=-1)
facade.add_handler(MessageHandler(Filters.text, menu.on_text),
group=-5)
ui_web.py 4.4 КБ
"""GET /api/network/devices + /api/admin/nmap*."""
ROUTES = [
("GET", "/api/network/devices", "net_devices", {"right": None,
"desc": "Устройства сети для панели мониторинга (свои кеши)"}),
("GET", "/api/admin/nmap", "nmap", {"right": "nmap_manage",
"desc": "Устройства сканера + игнор-лист"}),
("POST", "/api/admin/nmap/name", "nmap_name", {"right": "nmap_manage",
"desc": "Кастомное имя {mac, name}"}),
("POST", "/api/admin/nmap/ignore", "nmap_ignore", {"right": "nmap_manage",
"desc": "Игнор-лист {action: add|remove, mac}"}),
("POST", "/api/network/nmap/delete", "nmap_delete", {"right": "nmap_manage",
"desc": "Удалить устройство из кеша {mac}"}),
]
SERVICE = [
{"key": "nmap_manage", "icon": "🔍", "title": "Сканер сети", "right": "nmap_manage",
"phase": 3, "desc": "Устройства, кастомные имена, игнор-лист"},
]
PANELS = []
MONITOR_PANELS = [
{"id": "net.devices", "title": "Сеть: устройства", "api": "/api/network/devices",
"visibility": "public", "refresh_s": 60},
]
_MAC_RE = r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$"
from . import store as _store
def handle_api(ctx, config, method, req):
import re as _re
from core.errors import UserError
if method == "net_devices":
try:
data = ctx.api.call("network.devices_snapshot")
except Exception as e: # noqa: BLE001
return 500, {"ok": False, "error": str(e)[:200]}
if not isinstance(data, dict):
data = {"total": 0, "online": 0, "devices": []}
data["ok"] = True
return data
from core.errors import UserError
if method == "nmap":
cache = _store.load_devices()
ignored = _store.load_ignored()
devices = []
for mac, d in cache.items():
devices.append({
"mac": mac, "ip": d.get("ip"), "ips": _store.device_ips(d),
"name": d.get("name") or "",
"name_custom": d.get("name_custom") or "",
"type": d.get("type") or "", "online": bool(d.get("connected")),
"ports": d.get("ports") or [],
"ignored": mac.upper() in [x.upper() for x in ignored],
})
devices.sort(key=lambda x: (not x["online"],
(x["name_custom"] or x["name"] or "").lower()))
return {"devices": devices, "ignored": ignored}
if method == "nmap_name":
body = req.get("body") or {}
mac = (body.get("mac") or "").strip().upper()
name = (body.get("name") or "").strip()
if not _re.match(_MAC_RE, mac):
return 400, {"ok": False, "error": "Некорректный MAC"}
if len(name) > 64:
return 400, {"ok": False, "error": "Имя слишком длинное (64)"}
ok, err = _store.set_device_name(mac, name)
if not ok:
code = 400 if "не найден" in err else 500
return code, {"ok": False, "error": err[:400]}
return {"ok": True, "mac": mac, "name": name}
if method == "nmap_ignore":
body = req.get("body") or {}
action = (body.get("action") or "").strip()
mac = (body.get("mac") or "").strip().upper()
if action not in ("add", "remove"):
return 400, {"ok": False, "error": "action add/remove"}
if not _re.match(_MAC_RE, mac):
return 400, {"ok": False, "error": "Некорректный MAC"}
ignored, err = _store.set_ignored(action, mac)
if err:
code = 400 if "MAC " in err or "уже" in err or "нет" in err else 500
return code, {"ok": False, "error": err[:400]}
return {"ok": True, "ignored": ignored}
if method == "nmap_delete":
body = req.get("body") or {}
mac = (body.get("mac") or "").strip()
is_ip_key = mac.lower().startswith("ip:")
if not is_ip_key:
mac = mac.upper()
if not (_re.match(_MAC_RE, mac) or is_ip_key):
return 400, {"ok": False, "error": "Некорректный MAC"}
ok, err = _store.delete_device(mac)
if not ok:
code = 400 if "не найден" in err else 500
return code, {"ok": False, "error": err[:400]}
return {"ok": True, "mac": mac}
raise UserError("неизвестный метод: %s" % method) История версий
| version | date | commit |
|---|---|---|
| 0.4.2 | 2026-10-20 | 64de3fc47719 |
| 0.4.1 | 2026-10-12 | 0e7068e27ad9 |
| 0.4.0 | 2026-10-10 | 078a7e706907 |
| 0.3.9 | 2026-09-17 | 47801cc7f98e |
| 0.3.8 | 2026-09-14 | ff34f9c62413 |
| 0.3.7 | 2026-09-14 | dd208f5dd461 |
| 0.3.6 | 2026-09-14 | 42a3a7207dcd |
| 0.3.5 | 2026-09-14 | c5b660a598e6 |
| 0.3.4 | 2026-09-14 | c515a4348823 |
| 0.3.3 | 2026-09-14 | c7ba9a668eae |
| 0.3.2 | 2026-09-14 | e8d36b3d5116 |
| 0.3.1 | 2026-09-12 | db9c47f797b3 |
| 0.3.0 | 2026-09-11 | f645c6d24b4d |
| 0.2.0 | 2026-09-11 | 1fa8180d936f |
| 0.1.0 | 2026-09-08 | 5c758d465ca4 |