gonets/SRV/services/ip_service.py

240 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сервис проверки и применения IP-настроек через NetworkManager (D-Bus).
Назначение:
- Перед применением конфигурации проверяет занятость IP адреса, сканируя сегмент сети средствами ARP (через `NetworkScanner`).
- Применяет изменения профиля подключения через `sdbus_async.networkmanager` и выполняет переподключение интерфейса.
Требования/ограничения:
- Доступ к системной шине D-Bus и права на изменения сетевых профилей.
- Структура входных данных должна соответствовать схеме `ipv4_updates` для корректного обновления вложенных полей у профиля.
"""
import asyncio
import logging
from typing import Dict, Any, Optional
import subprocess
from sdbus_async.networkmanager import (
NetworkManager,
NetworkManagerSettings,
NetworkConnectionSettings,
NetworkDeviceGeneric,
)
import sdbus
from sdbus_async.networkmanager.settings import ConnectionProfile
from services.arp_service import NetworkScanner
from utils.i18n import _load_translations
from babel.support import Translations
from fastapi import Depends
logger = logging.getLogger(__name__)
class IPService:
"""Сервис проверки доступности IP-адреса и применения настроек сети.
Предоставляет асинхронные методы для проверки занятости IP-адреса с использованием
ARP-сканирования и применения настроек через NetworkManager.
"""
def __init__(self):
"""Инициализирует сервис IP.
Создаёт объект ARP-сканера и устанавливает системную шину D-Bus как
по умолчанию.
"""
self.arp_scanner = NetworkScanner()
self.bus = sdbus.sd_bus_open_system()
sdbus.set_default_bus(self.bus)
def _reset_bus(self):
"""Перезапускает соединение с системной шиной D-Bus.
Закрывает текущее соединение и создаёт новое, устанавливая его как
по умолчанию.
"""
new_bus = sdbus.sd_bus_open_system()
sdbus.set_default_bus(new_bus)
if hasattr(self, "bus") and self.bus is not None:
self.bus = None
self.bus = new_bus
async def check_ip_config(self, args: Dict[str, Any], translations: Translations = None) -> Dict[str, Any]:
"""Проверяет доступность IP-адреса перед его применением.
Выполняет ARP-сканирование сети для проверки, занят ли указанный IP-адрес.
Перезапускает шину D-Bus перед выполнением.
Args:
args (Dict[str, Any]): Словарь с данными, включая "ipv4_updates"
с полем "ipv4.address-data" для нового IP-адреса.
translations (Translations): Объект переводов.
Returns:
Dict[str, Any]: Словарь с ключами:
- "available" (bool): Доступен ли IP-адрес.
- "message" (str): Сообщение о результате проверки.
Raises:
Exception: Если произошла ошибка при сканировании ARP.
"""
translations = translations or _load_translations("ru")
self._reset_bus()
new_ip = args["ipv4_updates"]["ipv4.address-data"][0]["address"]
logger.info("check_ip_config: start ARP scan for %s", new_ip)
try:
clients = await asyncio.wait_for(
asyncio.to_thread(self.arp_scanner.scan_network),
timeout=10,
)
except asyncio.TimeoutError as exc:
logger.error("check_ip_config: ARP scan timed out for %s", new_ip)
return self._fallback_ping(new_ip, translations)
except Exception as exc:
logger.exception("check_ip_config: ARP scan failed for %s", new_ip)
return self._fallback_ping(new_ip, translations, error=str(exc))
if any(client["ip"] == new_ip for client in clients):
return {
"available": False,
"message": translations.gettext("IP-адрес {ip} уже занят.").format(ip=new_ip)
}
return {
"available": True,
"message": translations.gettext("Настройки сетевого профиля доступны для использования.")
}
def _fallback_ping(self, ip: str, translations: Translations, error: Optional[str] = None) -> Dict[str, Any]:
"""Резервная проверка через ping, если ARP-скан недоступен.
Не блокирует применение: если ping не отвечает, считаем IP свободным,
но возвращаем предупреждение.
"""
try:
logger.info("check_ip_config: fallback ping for %s after ARP failure (%s)", ip, error or "timeout")
result = subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode == 0:
return {
"available": False,
"message": translations.gettext("IP-адрес {ip} отвечает на ping").format(ip=ip),
}
except Exception as exc:
logger.warning("check_ip_config: ping fallback failed for %s: %s", ip, exc)
return {
"available": True,
"message": translations.gettext("Не удалось проверить ARP, IP {ip} считается свободным").format(ip=ip),
}
async def apply_ip_config(self, args: Dict[str, Any], translations: Translations = None) -> None:
"""Применяет проверенные настройки IP к указанному соединению.
Перезапускает шину D-Bus, обновляет настройки соединения через NetworkManager,
отключает и переподключает устройство к сети.
Args:
args (Dict[str, Any]): Словарь с данными, включая:
- "connection_id" (str): Идентификатор соединения.
- "ipv4_updates" (Dict): Обновления настроек IPv4.
translations (Translations): Объект переводов.
Raises:
ValueError: Если соединение с указанным ID не найдено.
RuntimeError: Если устройство для интерфейса не найдено.
Note:
Требуются права для изменения настроек через NetworkManager.
"""
translations = translations or _load_translations("ru")
self._reset_bus()
nm = NetworkManager()
settings = NetworkManagerSettings()
logger.info("apply_ip_config: start for connection_id=%s", args.get("connection_id"))
try:
connection_paths = await asyncio.wait_for(
settings.get_connections_by_id(args["connection_id"]),
timeout=5,
)
except asyncio.TimeoutError as exc:
logger.error("apply_ip_config: timeout while fetching connection %s", args.get("connection_id"))
raise RuntimeError(
translations.gettext("NetworkManager не ответил при поиске профиля")
) from exc
if not connection_paths:
raise ValueError(translations.gettext("Подключение {connection_id} не найдено").format(connection_id=args["connection_id"]))
connection = NetworkConnectionSettings(connection_paths[0])
try:
profile = await asyncio.wait_for(connection.get_profile(), timeout=5)
except asyncio.TimeoutError as exc:
logger.error("apply_ip_config: timeout while reading profile %s", args.get("connection_id"))
raise RuntimeError(
translations.gettext("NetworkManager не ответил при чтении профиля")
) from exc
settings_dict = profile.to_settings_dict()
for key_path, value in args["ipv4_updates"].items():
section, *props = key_path.split(".")
current = settings_dict[section]
for prop in props[:-1]:
current = current.setdefault(prop, {})
current[props[-1]] = value
new_profile = ConnectionProfile.from_settings_dict(settings_dict)
try:
await asyncio.wait_for(connection.update(new_profile.to_dbus()), timeout=5)
except asyncio.TimeoutError as exc:
logger.error("apply_ip_config: timeout while updating profile %s", args.get("connection_id"))
raise RuntimeError(
translations.gettext("NetworkManager не ответил при обновлении профиля")
) from exc
logger.info("apply_ip_config: settings updated for %s", args.get("connection_id"))
interface_name: Optional[str] = settings_dict["connection"].get("interface-name")
devices = await asyncio.wait_for(nm.get_devices(), timeout=5)
# Если interface-name не задан, пытаемся найти устройство по активному подключению
if not interface_name:
for device_path in devices:
device = NetworkDeviceGeneric(device_path)
try:
applied = await asyncio.wait_for(device.get_applied_connection(), timeout=3)
connection_info = applied[0].get("connection", {})
active_id = connection_info.get("id", ("s", None))[1]
if active_id == args["connection_id"]:
interface_name = await asyncio.wait_for(device.interface, timeout=3)
break
except Exception as exc:
logger.debug("apply_ip_config: unable to derive interface from device %s: %s", device_path, exc)
continue
if not interface_name:
raise RuntimeError(
translations.gettext("Не удалось определить интерфейс для профиля {connection_id}").format(
connection_id=args["connection_id"]
)
)
for device_path in devices:
device = NetworkDeviceGeneric(device_path)
if await asyncio.wait_for(device.interface, timeout=3) == interface_name:
await asyncio.wait_for(device.disconnect(), timeout=5)
logger.info("apply_ip_config: disconnected from %s", interface_name)
await asyncio.wait_for(
nm.activate_connection(connection=connection_paths[0], device=device_path),
timeout=10,
)
logger.info("apply_ip_config: reconnected to %s", args.get("connection_id"))
return
raise RuntimeError(translations.gettext("Устройство для интерфейса {interface} не найдено").format(interface=interface_name))