import os import re import shlex import shutil import socket import subprocess import sys import urllib.error import urllib.parse import urllib.request from typing import Any _HOST_RE = re.compile(r"^[a-zA-Z0-9.\-:_]+$") def clean_target_host(raw: Any) -> str: value = str(raw or "").strip() if not value: return "" if value.startswith("http://") or value.startswith("https://"): parsed = urllib.parse.urlparse(value) return (parsed.netloc or "").strip().strip("/") return value.strip().strip("/") def validate_target_host(raw: Any) -> str: host = clean_target_host(raw) if not host: raise ValueError("IP/хост не указан") if len(host) > 255 or not _HOST_RE.fullmatch(host): raise ValueError("Недопустимый формат IP/хоста") return host def normalize_extra_target(item: Any) -> dict: if not isinstance(item, dict): raise ValueError("Элемент списка дополнительных точек должен быть объектом") role = str(item.get("role") or "").strip()[:64] host = validate_target_host(item.get("host")) title = str(item.get("title") or role or host).strip()[:100] or host return {"role": role, "host": host, "title": title} def normalize_extra_targets(raw_items: Any) -> list[dict]: if raw_items is None: return [] if not isinstance(raw_items, list): raise ValueError("Список дополнительных точек должен быть массивом") out: list[dict] = [] for item in raw_items: out.append(normalize_extra_target(item)) if len(out) >= 40: break return out def _tool_exists(name: str) -> bool: return bool(shutil.which(name)) def _run_command( args: list[str], timeout_sec: int, max_output_lines: int | None = 20, ) -> dict: try: proc = subprocess.run( args, capture_output=True, text=True, timeout=max(1, int(timeout_sec)), check=False, ) output = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "") lines = [x for x in output.splitlines() if x.strip()] if max_output_lines is not None: lines = lines[: max(1, int(max_output_lines))] return { "ok": proc.returncode == 0, "exit_code": int(proc.returncode), "command": " ".join(shlex.quote(x) for x in args), "lines": lines, } except subprocess.TimeoutExpired: return { "ok": False, "exit_code": None, "command": " ".join(shlex.quote(x) for x in args), "error": "timeout", "lines": [], } except FileNotFoundError: return { "ok": False, "exit_code": None, "command": " ".join(shlex.quote(x) for x in args), "error": "not_available", "lines": [], } except Exception as exc: return { "ok": False, "exit_code": None, "command": " ".join(shlex.quote(x) for x in args), "error": str(exc), "lines": [], } def ping_host(host: str, timeout_sec: int = 2) -> dict: safe_host = validate_target_host(host) if sys.platform.startswith("darwin"): args = ["ping", "-n", "-c", "1", "-W", str(max(1000, timeout_sec * 1000)), safe_host] else: args = ["ping", "-n", "-c", "1", "-W", str(max(1, timeout_sec)), safe_host] result = _run_command(args, timeout_sec=max(2, timeout_sec + 1)) result["host"] = safe_host return result def trace_host( host: str, timeout_sec: int = 8, prefer_mtr: bool = False, max_output_lines: int | None = 80, ) -> dict: safe_host = validate_target_host(host) tool = "traceroute" args: list[str] | None = None if prefer_mtr and _tool_exists("mtr"): tool = "mtr" args = ["mtr", "--report", "--report-cycles", "3", "--no-dns", safe_host] elif _tool_exists("traceroute"): args = ["traceroute", "-n", "-w", "1", "-q", "1", "-m", "10", safe_host] elif _tool_exists("tracepath"): tool = "tracepath" args = ["tracepath", "-n", safe_host] if not args: return { "ok": False, "host": safe_host, "tool": None, "error": "not_available", "lines": [], } result = _run_command( args, timeout_sec=max(3, timeout_sec), max_output_lines=max_output_lines, ) result["host"] = safe_host result["tool"] = tool return result def tcp_probe(host: str, port: int, timeout_sec: int = 2) -> dict: safe_host = validate_target_host(host) safe_port = int(port) if safe_port < 1 or safe_port > 65535: raise ValueError("Некорректный TCP-порт") try: with socket.create_connection((safe_host, safe_port), timeout=max(1.0, float(timeout_sec))): return {"ok": True, "host": safe_host, "port": safe_port} except Exception as exc: return {"ok": False, "host": safe_host, "port": safe_port, "error": str(exc)} def http_probe(url: str, timeout_sec: int = 3) -> dict: value = str(url or "").strip() if not value.startswith("http://") and not value.startswith("https://"): raise ValueError("URL должен начинаться с http:// или https://") req = urllib.request.Request(value, method="GET") try: with urllib.request.urlopen(req, timeout=max(1.0, float(timeout_sec))) as resp: status = int(resp.status) return {"ok": 200 <= status < 400, "url": value, "status_code": status} except urllib.error.HTTPError as err: return {"ok": False, "url": value, "status_code": int(err.code), "error": str(err)} except Exception as exc: return {"ok": False, "url": value, "status_code": None, "error": str(exc)}