183 lines
5.9 KiB
Python
183 lines
5.9 KiB
Python
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)}
|
|
|