#!/usr/bin/env python3
"""
═══════════════════════════════════════════════════════════════
  Демоэкзамен 2026 — Checker v13
  Multi-PVE · Модуль 1 + Модуль 2 (11+11 задач)
  svc_active fix · nslookup · VID check · Match User
═══════════════════════════════════════════════════════════════

  python3 checker_v13.py            # интерактивный
  python3 checker_v13.py --all      # всё
  python3 checker_v13.py --debug    # отладка
"""

import requests
import urllib3
import json
import time
import argparse
import re
import sys
import os
import getpass
from datetime import datetime
from dataclasses import dataclass, field
from typing import Optional, Tuple, Dict, List

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

DEFAULT_VMS = {
    "ISP": 100, "HQ-RTR": 101, "BR-RTR": 102,
    "HQ-SRV": 103, "BR-SRV": 104, "HQ-CLI": 105,
}
DOMAIN = "au-team.irpo"
GA_TIMEOUT = 15
GA_POLL = 1
CONFIG_FILE = os.path.expanduser("~/.demo_checker.json")
DEBUG = False

DNS_RECORDS = {
    "hq-rtr.au-team.irpo":  {"A": True, "PTR": True},
    "hq-srv.au-team.irpo":  {"A": True, "PTR": True},
    "hq-cli.au-team.irpo":  {"A": True, "PTR": True},
    "br-rtr.au-team.irpo":  {"A": True, "PTR": False},
    "br-srv.au-team.irpo":  {"A": True, "PTR": False},
    "docker.au-team.irpo":  {"A": True, "PTR": False},
    "web.au-team.irpo":     {"A": True, "PTR": False},
}


def dbg(msg):
    if DEBUG:
        print(f"    [DBG] {msg}")


# ═══════════════════════════════════════════════════════════════
#  MULTI-PVE CONFIG
# ═══════════════════════════════════════════════════════════════

def load_configs():
    """Загрузить все сохранённые PVE конфиги"""
    if not os.path.exists(CONFIG_FILE):
        return {}
    try:
        with open(CONFIG_FILE, "r") as f:
            data = json.load(f)
        # Поддержка старого формата (один конфиг → конвертируем)
        if "host" in data:
            key = data["host"]
            return {key: data}
        return data
    except Exception:
        return {}

def save_configs(configs):
    with open(CONFIG_FILE, "w") as f:
        json.dump(configs, f, indent=2, ensure_ascii=False)

def add_pve_config():
    """Добавить новый PVE сервер"""
    print("\n  ┌── Новый PVE сервер ──┐\n")
    host = input("  IP Proxmox: ").strip()
    if not host:
        return None
    port = input("  Порт API [8006]: ").strip() or "8006"
    node = input("  Имя ноды [pve]: ").strip() or "pve"
    token_id = input("  Token ID [root@pam!checker]: ").strip() or "root@pam!checker"
    token_secret = getpass.getpass("  Token Secret: ")
    name = input(f"  Название стенда [{host}]: ").strip() or host

    print("\n  VMID (Enter = по умолчанию):")
    vms = {}
    for vm_name, did in DEFAULT_VMS.items():
        val = input(f"    {vm_name} [{did}]: ").strip()
        vms[vm_name] = int(val) if val else did

    config = {
        "name": name, "host": host, "port": int(port),
        "node": node, "token_id": token_id, "token_secret": token_secret, "vms": vms,
    }

    configs = load_configs()
    configs[host] = config
    save_configs(configs)
    print(f"  ✅ Сохранён: {name} ({host})")
    return config

def select_pve():
    """Выбрать PVE сервер или добавить новый"""
    configs = load_configs()

    print()
    print("╔══════════════════════════════════════════════════════╗")
    print("║         Демоэкзамен 2026 — Checker v13              ║")
    print("╚══════════════════════════════════════════════════════╝")

    if configs:
        print("\n  Сохранённые PVE серверы:")
        items = list(configs.items())
        for i, (key, cfg) in enumerate(items, 1):
            name = cfg.get("name", key)
            host = cfg.get("host", key)
            node = cfg.get("node", "pve")
            has_secret = "🔑" if cfg.get("token_secret") else "🔒"
            print(f"    {i}. {has_secret} {name} ({host}:{cfg.get('port',8006)}, node={node})")
        print(f"    {len(items)+1}. ➕ Добавить новый")
        print(f"    {len(items)+2}. 🗑️  Удалить")

        choice = input(f"\n  Выбор [1]: ").strip() or "1"

        if choice.isdigit():
            idx = int(choice) - 1
            if idx < len(items):
                config = items[idx][1]
                if not config.get("token_secret"):
                    config["token_secret"] = getpass.getpass("  Token Secret: ")
                else:
                    print(f"  🔑 Token загружен из конфига")
                return config
            elif idx == len(items):
                config = add_pve_config()
                if config:
                    return config
            elif idx == len(items) + 1:
                # Delete
                print("\n  Удалить какой? (номер):")
                d = input("  > ").strip()
                if d.isdigit() and int(d)-1 < len(items):
                    del_key = items[int(d)-1][0]
                    del configs[del_key]
                    save_configs(configs)
                    print(f"  🗑️  Удалён")
                return select_pve()
    else:
        print("\n  Нет сохранённых PVE серверов.")

    config = add_pve_config()
    return config


def select_module():
    """Выбрать модуль для проверки"""
    print()
    print("  ┌──────────────────────────────────────────┐")
    print("  │  Модуль проверки                        │")
    print("  ├──────────────────────────────────────────┤")
    print("  │  1. Модуль 1 (сеть, сервисы) — 11 задач │")
    print("  │  2. Модуль 2 (система, apps) — 11 задач │")
    print("  └──────────────────────────────────────────┘")
    m = input("\n  Выбор [1]: ").strip() or "1"
    return int(m) if m.isdigit() else 1


def select_mode(vms: dict, module: int = 1):
    max_task = 11
    print()
    print("  ┌─────────────────────────────────────────┐")
    print("  │  1. Все устройства, все задания          │")
    print("  │  2. Выбрать устройства                  │")
    print("  │  3. Выбрать задания                     │")
    print("  │  4. Выбрать устройства + задания        │")
    print("  └─────────────────────────────────────────┘")
    mode = input("\n  Выбор [1]: ").strip() or "1"

    all_vms = list(vms.keys())
    all_tasks = [str(i) for i in range(1, max_task + 1)]
    sel_vms, sel_tasks = all_vms[:], all_tasks[:]

    if mode in ("2", "4"):
        print(f"\n  VM: {', '.join(all_vms)}")
        s = input("  Выбор (через запятую): ").strip()
        if s:
            sel_vms = [v.strip().upper() for v in s.split(",") if v.strip().upper() in vms]

    if mode in ("3", "4"):
        print(f"\n  Задания: {', '.join(all_tasks)}")
        s = input("  Выбор (через запятую): ").strip()
        if s:
            sel_tasks = [t.strip() for t in s.split(",")]

    print(f"\n  → VM: {', '.join(sel_vms)}")
    print(f"  → Задания: {', '.join(sel_tasks)}\n")
    return sel_vms, sel_tasks


# ═══════════════════════════════════════════════════════════════
#  PROXMOX API — form data POST, не JSON
# ═══════════════════════════════════════════════════════════════

class PVE:
    def __init__(self, host, port, token_id, token_secret, node, vms):
        self.base = f"https://{host}:{port}/api2/json"
        self.node = node
        self.vms = vms
        self.s = requests.Session()
        self.s.headers["Authorization"] = f"PVEAPIToken={token_id}={token_secret}"
        self.s.verify = False

    def get(self, path):
        r = self.s.get(f"{self.base}{path}")
        r.raise_for_status()
        return r.json().get("data")

    def post(self, path, **kwargs):
        """POST с form-data (не JSON!) — так Proxmox API работает стабильнее"""
        r = self.s.post(f"{self.base}{path}", data=kwargs)
        r.raise_for_status()
        return r.json().get("data")

    def vm_status(self, vmid):
        return self.get(f"/nodes/{self.node}/qemu/{vmid}/status/current")

    def vm_config(self, vmid):
        return self.get(f"/nodes/{self.node}/qemu/{vmid}/config")

    def agent_ping(self, vmid):
        try:
            self.post(f"/nodes/{self.node}/qemu/{vmid}/agent/ping")
            return True
        except Exception:
            return False

    def exec_cmd(self, vmid, cmd) -> Tuple[Optional[int], str, str]:
        """
        Выполнить shell-команду внутри VM.
        Использует stdin (input-data) для передачи команды в /bin/sh
        """
        methods = [
            # Метод 1: /bin/sh + stdin (самый надёжный)
            {"command": "/bin/sh", "input-data": cmd + "\n"},
            # Метод 2: прямая команда (для простых случаев)
            {"command": cmd},
        ]

        for i, payload in enumerate(methods):
            try:
                dbg(f"exec method {i}: {payload.get('command','')[:40]}")
                r = self.s.post(
                    f"{self.base}/nodes/{self.node}/qemu/{vmid}/agent/exec",
                    data=payload
                )
                r.raise_for_status()
                result = r.json().get("data", {})
                pid = result.get("pid")
                if pid is None:
                    dbg(f"method {i}: no pid, resp={result}")
                    continue

                dbg(f"method {i}: pid={pid}, polling...")

                elapsed = 0
                while elapsed < GA_TIMEOUT:
                    time.sleep(GA_POLL)
                    elapsed += GA_POLL
                    try:
                        st = self.get(
                            f"/nodes/{self.node}/qemu/{vmid}/agent/exec-status?pid={pid}"
                        )
                        if st.get("exited"):
                            ec = st.get("exitcode", -1)
                            stdout = st.get("out-data", "")
                            stderr = st.get("err-data", "")
                            dbg(f"method {i}: OK ec={ec} out={len(stdout)}b")
                            return (ec, stdout, stderr)
                    except Exception as pe:
                        dbg(f"poll err: {pe}")

                dbg(f"method {i}: timeout")
                return (None, "", "timeout")

            except Exception as e:
                dbg(f"method {i} fail: {e}")
                continue

        return (None, "", "all methods failed")

    def sh(self, vm_name, cmd):
        vmid = self.vms[vm_name]
        return self.exec_cmd(vmid, cmd)


# ═══════════════════════════════════════════════════════════════
#  СТРУКТУРЫ
# ═══════════════════════════════════════════════════════════════

@dataclass
class Check:
    name: str
    passed: bool
    detail: str = ""

@dataclass
class Task:
    id: str
    title: str
    checks: list = field(default_factory=list)

    @property
    def score(self):
        return sum(1 for c in self.checks if c.passed)

    @property
    def total(self):
        return len(self.checks)


@dataclass
class VMNet:
    vm_name: str
    pve_ifaces: Dict[str, dict] = field(default_factory=dict)
    os_ifaces: Dict[str, dict] = field(default_factory=dict)
    mac_map: Dict[str, str] = field(default_factory=dict)
    routes: str = ""
    ip_addr_raw: str = ""


@dataclass
class Discovery:
    vm_nets: Dict[str, VMNet] = field(default_factory=dict)
    vlans: Dict[int, dict] = field(default_factory=dict)
    tunnel_hq_ip: str = ""
    tunnel_br_ip: str = ""
    tunnel_type: str = ""
    dns_ip: str = ""


# ═══════════════════════════════════════════════════════════════
#  CHECKER
# ═══════════════════════════════════════════════════════════════

class Checker:
    def __init__(self, api: PVE, sel_vms: List[str], sel_tasks: List[str]):
        self.api = api
        self.sel_vms = sel_vms
        self.sel_tasks = sel_tasks
        self.tasks: list[Task] = []
        self.d = Discovery()

    def sh(self, vm, cmd):
        return self.api.sh(vm, cmd)

    def has(self, text, *kw):
        if not text:
            return False
        t = text.lower()
        return all(k.lower() in t for k in kw)

    def has_any(self, text, *kw):
        if not text:
            return False
        t = text.lower()
        return any(k.lower() in t for k in kw)

    def vm_on(self, vm):
        return vm in self.sel_vms

    def clean(self, text, maxlen=80):
        """Убираем кракозябры (битый UTF-8 от guest agent), оставляем ASCII + базовую кириллицу"""
        if not text:
            return ""
        # Оставляем только printable ASCII и пробелы
        out = re.sub(r'[^\x20-\x7E]', '', text)
        # Убираем лишние пробелы
        out = re.sub(r'\s+', ' ', out).strip()
        return out[:maxlen]

    def svc_active(self, text):
        """Проверяет что systemctl is-active вернул точно 'active', а не 'inactive'"""
        if not text:
            return False
        return any(l.strip() == "active" for l in text.split("\n"))

    # ─────────────────────────────────────────
    #  PREFLIGHT
    # ─────────────────────────────────────────

    def preflight(self):
        print("═" * 65)
        print("  PREFLIGHT")
        print("═" * 65)
        ok = True
        for name in self.sel_vms:
            vmid = self.api.vms.get(name)
            if not vmid:
                print(f"  {name:10s}: ❌ VMID не задан")
                ok = False
                continue
            try:
                st = self.api.vm_status(vmid)
                running = st.get("status") == "running"
                agent = self.api.agent_ping(vmid) if running else False
                if running and agent:
                    print(f"  {name:10s} (VMID {vmid}): ✅ OK")
                elif running:
                    print(f"  {name:10s} (VMID {vmid}): ⚠️  agent не отвечает")
                    ok = False
                else:
                    print(f"  {name:10s} (VMID {vmid}): ❌ остановлена")
                    ok = False
            except Exception as e:
                print(f"  {name:10s} (VMID {vmid}): ❌ {e}")
                ok = False

        # Quick exec test on first available VM
        print()
        for name in self.sel_vms:
            vmid = self.api.vms.get(name)
            if not vmid:
                continue
            try:
                st = self.api.vm_status(vmid)
                if st.get("status") != "running":
                    continue
                print(f"  🔧 Тест exec на {name}...")
                ec, out, err = self.api.sh(name, "echo OK_TEST_123")
                if out and "OK_TEST_123" in out:
                    print(f"  ✅ exec работает: echo вернул '{out.strip()}'")
                else:
                    print(f"  ❌ exec проблема: ec={ec} out='{out}' err='{err}'")
                break
            except Exception as e:
                print(f"  ❌ exec тест ошибка: {e}")
                break

        print()
        return ok

    # ─────────────────────────────────────────
    #  DISCOVERY
    # ─────────────────────────────────────────

    def parse_ip_addr(self, text):
        """Парсинг ip addr show → dict[iface_name] = {mac, ips, state}"""
        result = {}
        cur = ""
        for line in (text or "").split("\n"):
            m = re.match(r'^\d+:\s+(\S+?)[@:]', line)
            if m:
                cur = m.group(1)
                if cur != "lo":
                    result[cur] = {"mac": "", "ips": [], "state": ""}
                    if "UP" in line:
                        result[cur]["state"] = "UP"
                continue
            if cur == "lo" or cur == "":
                continue
            if cur not in result:
                continue

            m = re.search(r'link/ether\s+([0-9a-fA-F:]+)', line)
            if m:
                result[cur]["mac"] = m.group(1).lower()

            m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)', line)
            if m:
                result[cur]["ips"].append({
                    "ip": m.group(1),
                    "prefix": int(m.group(2)),
                    "dynamic": "dynamic" in line.lower(),
                })
        return result

    def parse_pve_net(self, cfg):
        """Парсинг конфигурации VM из Proxmox → net0/net1/... с MAC и bridge"""
        result = {}
        for key, val in (cfg or {}).items():
            if not key.startswith("net") or not isinstance(val, str):
                continue
            parts = {}
            for chunk in val.split(","):
                if "=" in chunk:
                    k, v = chunk.split("=", 1)
                    parts[k.strip()] = v.strip()
            mac = ""
            for model in ("virtio", "e1000", "rtl8139", "vmxnet3"):
                if model in parts:
                    mac = parts[model].lower()
                    break
            result[key] = {
                "mac": mac,
                "bridge": parts.get("bridge", ""),
                "tag": parts.get("tag", ""),
            }
        return result

    def discover(self):
        print("═" * 65)
        print("  DISCOVERY: Сбор информации")
        print("═" * 65)

        for vm in self.sel_vms:
            vmid = self.api.vms.get(vm)
            if not vmid:
                continue

            info = VMNet(vm_name=vm)

            # PVE config
            try:
                cfg = self.api.vm_config(vmid)
                info.pve_ifaces = self.parse_pve_net(cfg)
            except Exception as e:
                print(f"  {vm}: ⚠️  PVE config: {e}")

            # OS: ip addr show
            try:
                ec, out, err = self.sh(vm, "ip addr show")
                dbg(f"{vm} ip addr: ec={ec} len={len(out or '')} err={err[:50] if err else ''}")
                if ec is not None and out:
                    info.ip_addr_raw = out
                    info.os_ifaces = self.parse_ip_addr(out)
                else:
                    print(f"  {vm}: ⚠️  ip addr show не удался (ec={ec}, err={err})")
            except Exception as e:
                print(f"  {vm}: ⚠️  ip addr: {e}")

            # OS: ip route show
            try:
                ec, out, _ = self.sh(vm, "ip route show")
                if ec is not None and out:
                    info.routes = out.strip()
            except Exception:
                pass

            # MAC correlation
            for pve_net, pve_data in info.pve_ifaces.items():
                for os_name, os_data in info.os_ifaces.items():
                    if pve_data["mac"] == os_data["mac"]:
                        info.mac_map[pve_net] = os_name
                        break

            self.d.vm_nets[vm] = info

            # Print
            print(f"\n  📡 {vm} (VMID {vmid}):")
            for pve_net, pve_data in info.pve_ifaces.items():
                os_name = info.mac_map.get(pve_net, "—")
                os_data = info.os_ifaces.get(os_name, {})
                ips = ", ".join(f"{i['ip']}/{i['prefix']}" for i in os_data.get("ips", [])) or "нет IP"
                tag = f" tag={pve_data['tag']}" if pve_data.get("tag") else ""
                print(f"    {pve_net} [{pve_data['bridge']}{tag}] MAC={pve_data['mac']}")
                print(f"      └→ {os_name}: {ips}")

            # Show OS-only interfaces (VLANs, tunnels)
            mapped = set(info.mac_map.values())
            for os_name, os_data in info.os_ifaces.items():
                if os_name not in mapped and os_data.get("ips"):
                    ips = ", ".join(f"{i['ip']}/{i['prefix']}" for i in os_data["ips"])
                    print(f"    [виртуальный] {os_name} → {ips}")

            if info.routes:
                for rline in info.routes.split("\n")[:5]:
                    print(f"    🔀 {rline}")

        # VLAN discovery on HQ-RTR
        if "HQ-RTR" in self.sel_vms:
            self._discover_vlans()

        # Tunnel discovery
        if "HQ-RTR" in self.sel_vms or "BR-RTR" in self.sel_vms:
            self._discover_tunnel()

        # DNS IP — ищем IP HQ-SRV в серверной подсети (192.168.100.x), не внешний
        if "HQ-SRV" in self.sel_vms:
            info = self.d.vm_nets.get("HQ-SRV")
            if info:
                all_ips = []
                for data in info.os_ifaces.values():
                    for ip in data.get("ips", []):
                        addr = ip["ip"]
                        if not addr.startswith("127.") and not addr.startswith("fe80"):
                            all_ips.append(addr)
                # Приоритет: 192.168.100.x > 192.168.x > любой приватный
                for prefix in ["192.168.100.", "192.168.", "10.", "172."]:
                    match = [a for a in all_ips if a.startswith(prefix)]
                    if match:
                        self.d.dns_ip = match[0]
                        break
                if not self.d.dns_ip and all_ips:
                    self.d.dns_ip = all_ips[0]
            if self.d.dns_ip:
                print(f"\n  🌐 DNS (HQ-SRV): {self.d.dns_ip}")

        print()

    def _discover_vlans(self):
        info = self.d.vm_nets.get("HQ-RTR")
        if not info:
            return

        for os_name, os_data in info.os_ifaces.items():
            # Match VLAN interfaces: eth0.100, ens19.200, ens19.100@ens19, vlan100, etc.
            clean_name = os_name.split("@")[0]  # убираем @parent
            for pattern in [
                re.match(r'.*\.(\d+)$', clean_name),           # eth0.100
                re.match(r'vlan(\d+)$', clean_name),            # vlan100
            ]:
                if pattern:
                    vid = int(pattern.group(1))
                    if vid in (100, 200, 999) and os_data.get("ips"):
                        ip_info = os_data["ips"][0]
                        prefix = ip_info["prefix"]
                        total = 2 ** (32 - prefix)
                        usable = total - 2
                        self.d.vlans[vid] = {
                            "iface": clean_name,
                            "ip": ip_info["ip"],
                            "prefix": prefix,
                            "total": total,
                            "usable": usable,
                        }

        if self.d.vlans:
            print(f"\n  🏷️  VLAN на HQ-RTR:")
            for vid, v in sorted(self.d.vlans.items()):
                print(f"    VLAN {vid}: {v['iface']} = {v['ip']}/{v['prefix']} "
                      f"({v['total']} адр., {v['usable']} хостов)")

    def _discover_tunnel(self):
        for vm, attr in [("HQ-RTR", "tunnel_hq_ip"), ("BR-RTR", "tunnel_br_ip")]:
            if not self.vm_on(vm):
                continue
            info = self.d.vm_nets.get(vm)
            if not info:
                continue
            # Look for tunnel/gre/ipip interfaces
            for os_name, os_data in info.os_ifaces.items():
                is_tun = any(x in os_name.lower() for x in ("tun", "gre", "ipip"))
                if is_tun and os_data.get("ips"):
                    setattr(self.d, attr, os_data["ips"][0]["ip"])
                    break

            # Also check ip tunnel show
            ec, out, _ = self.sh(vm, "ip tunnel show 2>/dev/null")
            if out:
                if "gre" in out.lower():
                    self.d.tunnel_type = "GRE"
                elif "ipip" in out.lower():
                    self.d.tunnel_type = "IPIP"

        if self.d.tunnel_hq_ip or self.d.tunnel_br_ip:
            print(f"\n  🔗 Туннель ({self.d.tunnel_type or '?'}):")
            if self.d.tunnel_hq_ip:
                print(f"    HQ-RTR: {self.d.tunnel_hq_ip}")
            if self.d.tunnel_br_ip:
                print(f"    BR-RTR: {self.d.tunnel_br_ip}")

    # ═══════════════════════════════════════════════
    #  TASK 1: Базовая настройка
    # ═══════════════════════════════════════════════

    def task1(self):
        # 1a: hostnames
        t = Task("1a", "Имена устройств (FQDN)")
        names = {"ISP":"isp","HQ-RTR":"hq-rtr","BR-RTR":"br-rtr",
                 "HQ-SRV":"hq-srv","BR-SRV":"br-srv","HQ-CLI":"hq-cli"}
        for vm, short in names.items():
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "hostnamectl --static 2>/dev/null || hostname -f 2>/dev/null || hostname")
            h = out.strip().lower() if out else ""
            fqdn = f"{short}.{DOMAIN}"
            ok = fqdn == h or short == h
            t.checks.append(Check(f"{vm}: {fqdn}", ok, f"факт: {h}"))
        self.tasks.append(t)

        # 1b: IP + subnets
        t2 = Task("1b", "IP-адресация (авто-обнаружение)")

        if self.vm_on("ISP"):
            info = self.d.vm_nets.get("ISP")
            if info:
                all_ips = [(n, ip) for n, d in info.os_ifaces.items() for ip in d.get("ips", [])]
                has_hq = any(ip["ip"].startswith("172.16.1.") for _, ip in all_ips)
                has_br = any(ip["ip"].startswith("172.16.2.") for _, ip in all_ips)
                t2.checks.append(Check("ISP → HQ (172.16.1.x)", has_hq))
                t2.checks.append(Check("ISP → BR (172.16.2.x)", has_br))
                for name, ip in all_ips:
                    if ip["ip"].startswith("172.16.1."):
                        t2.checks.append(Check("ISP→HQ маска /28", ip["prefix"]==28, f"факт: /{ip['prefix']}"))
                    if ip["ip"].startswith("172.16.2."):
                        t2.checks.append(Check("ISP→BR маска /28", ip["prefix"]==28, f"факт: /{ip['prefix']}"))

        for vm, prefix in [("HQ-RTR", "172.16.1."), ("BR-RTR", "172.16.2.")]:
            if not self.vm_on(vm): continue
            info = self.d.vm_nets.get(vm)
            if not info: continue
            found = any(ip["ip"].startswith(prefix) for d in info.os_ifaces.values() for ip in d.get("ips",[]))
            t2.checks.append(Check(f"{vm} → ISP ({prefix}x)", found))

        for vm in ["HQ-SRV", "BR-SRV", "HQ-CLI"]:
            if not self.vm_on(vm): continue
            info = self.d.vm_nets.get(vm)
            if not info: continue
            ips = [ip["ip"] for d in info.os_ifaces.values() for ip in d.get("ips",[]) if not ip["ip"].startswith("127.")]
            priv = any(i.startswith("10.") or i.startswith("192.168.") or re.match(r'^172\.(1[6-9]|2\d|3[01])\.', i) for i in ips)
            t2.checks.append(Check(f"{vm}: приватный IP", bool(ips) and priv, ", ".join(ips)))

        # Subnet sizes from auto-discovered VLANs
        v = self.d.vlans
        if 100 in v:
            t2.checks.append(Check(f"VLAN100 (SRV): ≤32 адр.", v[100]["total"]<=32,
                                    f"/{v[100]['prefix']}={v[100]['total']} адр."))
        if 200 in v:
            t2.checks.append(Check(f"VLAN200 (CLI): ≥16 адресов", v[200]["total"]>=16,
                                    f"/{v[200]['prefix']}={v[200]['total']} адр."))
        if 999 in v:
            t2.checks.append(Check(f"VLAN999 (MGMT): ≤8 адр.", v[999]["total"]<=8,
                                    f"/{v[999]['prefix']}={v[999]['total']} адр."))

        # BR-SRV subnet
        if self.vm_on("BR-RTR"):
            info = self.d.vm_nets.get("BR-RTR")
            if info:
                for d in info.os_ifaces.values():
                    for ip in d.get("ips", []):
                        if ip["ip"].startswith("172.16.") or ip["ip"].startswith("10."): continue
                        if ip["ip"].startswith("127."): continue
                        total = 2**(32-ip["prefix"])
                        t2.checks.append(Check(f"BR-SRV сеть: ≤16 адр.", total<=16,
                                                f"{ip['ip']}/{ip['prefix']}={total} адр."))
                        break
                    else: continue
                    break

        self.tasks.append(t2)

        # 1c: MAC correlation
        t3 = Task("1c", "MAC-корреляция Proxmox ↔ ОС")
        for vm in self.sel_vms:
            info = self.d.vm_nets.get(vm)
            if not info: continue
            for pve_net, pve_data in info.pve_ifaces.items():
                os_name = info.mac_map.get(pve_net)
                if os_name:
                    t3.checks.append(Check(
                        f"{vm}: {pve_net}[{pve_data['bridge']}] ↔ {os_name}", True,
                        f"MAC={pve_data['mac']}"))
                else:
                    t3.checks.append(Check(
                        f"{vm}: {pve_net}[{pve_data['bridge']}] — не найден в ОС", False,
                        f"MAC={pve_data['mac']}"))
        self.tasks.append(t3)

    # ═══════════════════════════════════════════════
    #  TASK 2: ISP
    # ═══════════════════════════════════════════════

    def task2(self):
        t = Task("2", "ISP: Интернет и NAT")
        if not self.vm_on("ISP"):
            t.checks.append(Check("ISP не выбран", False)); self.tasks.append(t); return

        info = self.d.vm_nets.get("ISP")
        has_dyn = False
        if info:
            has_dyn = any(ip.get("dynamic") for d in info.os_ifaces.values() for ip in d.get("ips",[]))
        t.checks.append(Check("DHCP на внешнем интерфейсе", has_dyn))

        routes = info.routes if info else ""
        t.checks.append(Check("Default route", "default" in routes, routes.split('\n')[0][:60] if routes else ""))

        ec, out, _ = self.sh("ISP", "iptables -t nat -L POSTROUTING -nv 2>/dev/null; nft list ruleset 2>/dev/null")
        t.checks.append(Check("NAT (masquerade/SNAT)", self.has_any(out, "masquerade", "snat")))

        ec, out, _ = self.sh("ISP", "ping -c2 -W2 77.88.8.8 2>/dev/null")
        t.checks.append(Check("Ping интернет", ec == 0))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 3: Users
    # ═══════════════════════════════════════════════

    def task3(self):
        t = Task("3", "Учётные записи")
        for vm in ["HQ-SRV", "BR-SRV"]:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "id sshuser 2>/dev/null")
            t.checks.append(Check(f"{vm}: sshuser", ec==0 and self.has(out,"uid="), self.clean(out, 50)))
            t.checks.append(Check(f"{vm}: UID=2026", self.has(out, "uid=2026")))
            # NOPASSWD — проверяем все варианты (ALT Linux: control sudo, wheel, sudoers)
            ec, out, _ = self.sh(vm,
                "grep -rhi 'sshuser\\|ALL.*NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null; "
                "sudo -l -U sshuser 2>/dev/null; "
                "control sudo 2>/dev/null; "
                "groups sshuser 2>/dev/null")
            nopasswd = self.has_any(out, "nopasswd", "not.*password", "all")
            # Если пользователь в wheel — тоже ОК для ALT
            in_wheel = self.has_any(out, "wheel")
            t.checks.append(Check(f"{vm}: sshuser sudo NOPASSWD",
                                   nopasswd or in_wheel,
                                   self.clean(out)))

        for vm in ["HQ-RTR", "BR-RTR"]:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "id net_admin 2>/dev/null")
            t.checks.append(Check(f"{vm}: net_admin", ec==0 and self.has(out,"uid=")))
            ec, out, _ = self.sh(vm,
                "grep -rhi 'net_admin\\|ALL.*NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null; "
                "sudo -l -U net_admin 2>/dev/null; "
                "control sudo 2>/dev/null; "
                "groups net_admin 2>/dev/null")
            nopasswd = self.has_any(out, "nopasswd", "not.*password", "all")
            in_wheel = self.has_any(out, "wheel")
            t.checks.append(Check(f"{vm}: net_admin sudo NOPASSWD",
                                   nopasswd or in_wheel,
                                   self.clean(out)))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 4: VLAN
    # ═══════════════════════════════════════════════

    def task4(self):
        t = Task("4", "VLAN коммутация HQ")
        if not self.vm_on("HQ-RTR"):
            t.checks.append(Check("HQ-RTR не выбран", False)); self.tasks.append(t); return

        v = self.d.vlans
        for vid, desc in [(100,"SRV"),(200,"CLI"),(999,"MGMT")]:
            found = vid in v
            det = f"{v[vid]['iface']}={v[vid]['ip']}/{v[vid]['prefix']}" if found else ""
            t.checks.append(Check(f"VLAN {vid} ({desc})", found, det))

        # Router on a stick + проверка VID
        info = self.d.vm_nets.get("HQ-RTR")
        if info and self.vm_on("HQ-RTR"):
            # Определяем parent из уже найденных VLAN
            parents = set()
            for vid, vdata in self.d.vlans.items():
                iface = vdata.get("iface", "")
                # ens19.100 → ens19
                m = re.match(r'^(\S+?)\.\d+$', iface)
                if m:
                    parents.add(m.group(1))

            # Ищем по os_ifaces — оба формата
            if not parents:
                for os_name in info.os_ifaces:
                    # Формат 1: ens20.100 или ens20.100@ens20
                    m = re.match(r'^(\S+?)\.\d+(?:@\S+)?$', os_name)
                    if m:
                        parents.add(m.group(1))
                        continue
                    # Формат 2: vlan100@ens20 → parent = ens20
                    m = re.match(r'^vlan\d+@(\S+)$', os_name)
                    if m:
                        parents.add(m.group(1))

            # Фоллбэк: через ip -br link show
            if not parents:
                ec, out_link, _ = self.sh("HQ-RTR",
                    "ip -br link show | grep -iE 'vlan|\\.[0-9]+' | awk '{print $1}'")
                if out_link:
                    for line in out_link.split("\n"):
                        name = line.strip()
                        # ens20.100@ens20
                        m = re.match(r'^(\S+?)\.\d+(?:@\S+)?$', name)
                        if m:
                            parents.add(m.group(1))
                            continue
                        # vlan100@ens20
                        m = re.match(r'^vlan\d+@(\S+)$', name)
                        if m:
                            parents.add(m.group(1))

            t.checks.append(Check("Router-on-a-stick (1 физ. интерфейс)",
                                   len(parents) == 1 and bool(parents),
                                   f"parent: {', '.join(parents)}" if parents else ""))

            # Проверяем VID через ip -d link show на сабинтерфейсах
            ec, out, _ = self.sh("HQ-RTR",
                "ip -d link show 2>/dev/null | grep -E 'vlan protocol|vlan id'; "
                "cat /proc/net/vlan/config 2>/dev/null")
            found_vids = set()
            if out:
                found_vids = set(int(x) for x in re.findall(r'vlan protocol \S+ id (\d+)', out))
                if not found_vids:
                    found_vids = set(int(x) for x in re.findall(r'vlan id (\d+)', out))
                if not found_vids:
                    # /proc/net/vlan/config format: "ens19.100 | 100 | ens19"
                    found_vids = set(int(x) for x in re.findall(r'\|\s*(\d+)\s*\|', out))
            if not found_vids:
                # Fallback: берём из имён интерфейсов
                found_vids = set(self.d.vlans.keys())

            for vid, desc in [(100,"SRV"),(200,"CLI"),(999,"MGMT")]:
                t.checks.append(Check(f"VID {vid} ({desc}) на сабинтерфейсе",
                                       vid in found_vids,
                                       f"найдено: {sorted(found_vids)}" if found_vids else ""))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 5: SSH
    # ═══════════════════════════════════════════════

    def task5(self):
        t = Task("5", "SSH настройка")
        for vm in ["HQ-SRV", "BR-SRV"]:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm,
                "cat /etc/openssh/sshd_config /etc/ssh/sshd_config 2>/dev/null; "
                "find /etc/openssh/sshd_config.d /etc/ssh/sshd_config.d -type f 2>/dev/null | xargs cat 2>/dev/null")
            active = "\n".join(l for l in (out or "").split("\n") if l.strip() and not l.strip().startswith("#")).lower()

            t.checks.append(Check(f"{vm}: Port 2026", "port 2026" in active))
            # AllowUsers sshuser ИЛИ Match User sshuser
            allow_ok = ("allowusers" in active and "sshuser" in active) or \
                       ("match user" in active and "sshuser" in active)
            t.checks.append(Check(f"{vm}: AllowUsers/Match sshuser", allow_ok))
            t.checks.append(Check(f"{vm}: MaxAuthTries 2", "maxauthtries 2" in active))

            bp = ""
            for line in active.split("\n"):
                if line.strip().startswith("banner "):
                    parts = line.strip().split(None, 1)
                    bp = parts[1] if len(parts) > 1 else ""
                    break
            bok = False
            if bp:
                ec2, out2, _ = self.sh(vm, f"cat {bp} 2>/dev/null")
                bok = self.has(out2, "authorized access only")
            if not bok:
                ec2, out2, _ = self.sh(vm,
                    "cat /etc/openssh/banner /etc/ssh/banner /etc/issue.net "
                    "/etc/openssh/sshd_banner /etc/ssh/sshd_banner 2>/dev/null")
                bok = self.has(out2, "authorized access only")
            t.checks.append(Check(f"{vm}: Banner 'Authorized access only'", bok))

            ec3, out3, _ = self.sh(vm, "ss -tlnp | grep :2026")
            t.checks.append(Check(f"{vm}: sshd слушает :2026", self.has(out3, ":2026")))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 6: Tunnel
    # ═══════════════════════════════════════════════

    def task6(self):
        t = Task("6", "IP туннель HQ↔BR")
        for vm in ["HQ-RTR", "BR-RTR"]:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "ip tunnel show 2>/dev/null; ip -d link show type gre 2>/dev/null; ip -d link show type ipip 2>/dev/null")
            t.checks.append(Check(f"{vm}: туннель создан", self.has_any(out, "gre","ipip","tunnel")))
            ttype = "GRE" if self.has(out,"gre") else "IPIP" if self.has(out,"ipip") else "?"
            t.checks.append(Check(f"{vm}: тип={ttype}", ttype != "?"))

            tip = self.d.tunnel_hq_ip if vm=="HQ-RTR" else self.d.tunnel_br_ip
            t.checks.append(Check(f"{vm}: IP на туннеле", bool(tip), tip))

            ec2, out2, _ = self.sh(vm, "ip link show | grep -iE 'tun|gre' | grep -i UP")
            t.checks.append(Check(f"{vm}: туннель UP", self.has(out2, "up")))

        if self.vm_on("HQ-RTR") and self.d.tunnel_br_ip:
            ec, out, _ = self.sh("HQ-RTR", f"ping -c2 -W3 {self.d.tunnel_br_ip} 2>/dev/null")
            t.checks.append(Check("Ping HQ→BR", ec==0))
        if self.vm_on("BR-RTR") and self.d.tunnel_hq_ip:
            ec, out, _ = self.sh("BR-RTR", f"ping -c2 -W3 {self.d.tunnel_hq_ip} 2>/dev/null")
            t.checks.append(Check("Ping BR→HQ", ec==0))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 7: Dynamic routing
    # ═══════════════════════════════════════════════

    def task7(self):
        t = Task("7", "Динамическая маршрутизация")
        for vm in ["HQ-RTR", "BR-RTR"]:
            if not self.vm_on(vm): continue

            # 1. Daemon active
            ec, out, _ = self.sh(vm,
                "systemctl is-active frr 2>/dev/null; "
                "systemctl is-active bird 2>/dev/null; "
                "ps aux | grep -E 'ospf|isis|zebra|bird' | grep -v grep")
            lines = [l.strip() for l in (out or "").split("\n") if l.strip()]
            # Первые 2 строки — systemctl (может быть "active" или "inactive"/"unknown")
            svc_active = any(l == "active" for l in lines[:2])
            # ps aux — ищем процессы
            ps_lines = lines[2:] if len(lines) > 2 else []
            ps_active = any(self.has_any(l, "ospfd", "isisd", "bird", "zebra") for l in ps_lines)
            t.checks.append(Check(f"{vm}: daemon active",
                                   svc_active or ps_active))

            # 2. Config file
            ec, out, _ = self.sh(vm,
                "cat /etc/frr/frr.conf /etc/frr/ospfd.conf /etc/frr/isisd.conf "
                "/etc/bird/bird.conf /etc/bird.conf 2>/dev/null")
            cfg = out or ""

            # Protocol detection
            proto = "OSPF" if self.has_any(cfg,"router ospf","protocol ospf") \
                    else "IS-IS" if self.has_any(cfg,"router isis") else "?"
            t.checks.append(Check(f"{vm}: протокол={proto}", proto != "?"))

            # 3. Password auth in config
            t.checks.append(Check(f"{vm}: парольная защита",
                                   self.has_any(cfg, "password","authentication","message-digest","auth")))

            # 4. OSPF only on tunnel — check via vtysh
            ec_v, out_v, _ = self.sh(vm,
                "vtysh -c 'show ip ospf interface' 2>/dev/null || "
                "birdc show protocol all 2>/dev/null")
            if out_v:
                # Find which interfaces have OSPF
                ospf_ifaces = re.findall(r'(\S+)\s+is\s+(?:up|down)', out_v)
                tunnel_names = [i for i in ospf_ifaces if any(x in i.lower() for x in ("tun","gre","ipip"))]
                t.checks.append(Check(f"{vm}: OSPF на туннеле",
                                       len(tunnel_names) >= 1,
                                       f"ospf_if={','.join(ospf_ifaces)}" if ospf_ifaces else ""))
            else:
                # Fallback: config check
                t.checks.append(Check(f"{vm}: на туннельном IF",
                                       self.has_any(cfg, "tun","gre")))

            # 5. OSPF neighbor — check via vtysh
            ec_n, out_n, _ = self.sh(vm, "vtysh -c 'show ip ospf neighbor' 2>/dev/null")
            if out_n and "Neighbor" in out_n:
                # Parse neighbor state
                neighbors = re.findall(r'(\d+\.\d+\.\d+\.\d+)\s+\d+\s+(\S+)', out_n)
                has_full = any("Full" in state or "2-Way" in state for _, state in neighbors)
                t.checks.append(Check(f"{vm}: OSPF сосед (Full/2-Way)",
                                       has_full,
                                       f"neighbors: {', '.join(f'{ip}({st})' for ip, st in neighbors)}" if neighbors else ""))
            else:
                dbg(f"{vm}: vtysh neighbor unavailable, skipping")

            # 6. OSPF routes in table
            ec_r, out_r, _ = self.sh(vm,
                "vtysh -c 'show ip route ospf' 2>/dev/null || "
                "ip route show | grep 'proto ospf\\|proto isis\\|proto bird'")
            if out_r:
                has_routes = self.has_any(out_r, "O>","O ","proto ospf","proto isis","proto bird")
                t.checks.append(Check(f"{vm}: маршруты получены", has_routes,
                                       out_r.strip().split('\n')[0][:70] if out_r else ""))

        # 7. Functional: cross-ping offices
        if self.vm_on("HQ-SRV") and self.vm_on("BR-SRV"):
            br_ip = ""
            br_info = self.d.vm_nets.get("BR-SRV")
            if br_info:
                for d in br_info.os_ifaces.values():
                    for ip in d.get("ips",[]):
                        if not ip["ip"].startswith("127."): br_ip = ip["ip"]; break
                    if br_ip: break
            if br_ip:
                ec, out, _ = self.sh("HQ-SRV", f"ping -c2 -W3 {br_ip} 2>/dev/null")
                t.checks.append(Check(f"HQ-SRV→BR-SRV ({br_ip})", ec==0))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 8: NAT
    # ═══════════════════════════════════════════════

    def task8(self):
        t = Task("8", "NAT на маршрутизаторах")
        for vm in ["HQ-RTR", "BR-RTR"]:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "iptables -t nat -L POSTROUTING -nv 2>/dev/null; nft list ruleset 2>/dev/null")
            t.checks.append(Check(f"{vm}: NAT", self.has_any(out, "masquerade","snat")))
        for vm in ["HQ-SRV", "BR-SRV", "HQ-CLI"]:
            if not self.vm_on(vm): continue
            ec, _, _ = self.sh(vm, "ping -c2 -W3 77.88.8.8 2>/dev/null")
            t.checks.append(Check(f"{vm}: ping интернет", ec==0))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 9: DHCP
    # ═══════════════════════════════════════════════

    def task9(self):
        t = Task("9", "DHCP для HQ-CLI")
        if self.vm_on("HQ-RTR"):
            ec, out, _ = self.sh("HQ-RTR", "systemctl is-active dhcpd dnsmasq isc-dhcp-server kea-dhcp4 2>/dev/null")
            t.checks.append(Check("HQ-RTR: DHCP active", self.svc_active(out)))

            ec, out, _ = self.sh("HQ-RTR",
                "cat /etc/dhcp/dhcpd.conf /etc/dhcpd.conf /etc/dnsmasq.conf /etc/kea/kea-dhcp4.conf 2>/dev/null; "
                "find /etc/dnsmasq.d/ -type f 2>/dev/null | xargs cat 2>/dev/null")
            cfg = out or ""
            t.checks.append(Check("DHCP: domain au-team.irpo", self.has(cfg, "au-team.irpo")))
            if self.d.dns_ip:
                t.checks.append(Check(f"DHCP: DNS={self.d.dns_ip}", self.has(cfg, self.d.dns_ip)))
            t.checks.append(Check("DHCP: gateway", self.has_any(cfg, "routers","gateway")))
            t.checks.append(Check("DHCP: range/pool", self.has_any(cfg, "range","pool")))

        if self.vm_on("HQ-CLI"):
            info = self.d.vm_nets.get("HQ-CLI")
            has_dyn = False
            if info:
                has_dyn = any(ip.get("dynamic") for d in info.os_ifaces.values() for ip in d.get("ips",[]))
            t.checks.append(Check("HQ-CLI: IP по DHCP", has_dyn))

            ec, out, _ = self.sh("HQ-CLI", "cat /etc/resolv.conf 2>/dev/null")
            t.checks.append(Check("HQ-CLI: DNS-суффикс", self.has(out, "au-team.irpo")))
            if self.d.dns_ip:
                t.checks.append(Check(f"HQ-CLI: DNS={self.d.dns_ip}", self.has(out, self.d.dns_ip)))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 10: DNS
    # ═══════════════════════════════════════════════

    def task10(self):
        t = Task("10", "DNS на HQ-SRV")
        dns = self.d.dns_ip
        if not self.vm_on("HQ-SRV"):
            t.checks.append(Check("HQ-SRV не выбран", False))
            self.tasks.append(t); return

        # ── Серверная часть: кто и что слушает ──

        # Detect DNS software
        ec, out, _ = self.sh("HQ-SRV",
            "systemctl is-active named 2>/dev/null; "
            "systemctl is-active bind9 2>/dev/null; "
            "systemctl is-active bind 2>/dev/null; "
            "systemctl is-active dnsmasq 2>/dev/null; "
            "systemctl is-active pdns 2>/dev/null")
        lines = [l.strip() for l in (out or "").split("\n")]
        services = ["named","bind9","bind","dnsmasq","pdns"]
        dns_sw = "?"
        for i, svc in enumerate(services):
            if i < len(lines) and lines[i] == "active":
                if svc in ("named","bind9","bind"):
                    dns_sw = "bind"
                elif svc == "dnsmasq":
                    dns_sw = "dnsmasq"
                elif svc == "pdns":
                    dns_sw = "pdns"
                break

        t.checks.append(Check(f"DNS сервер active ({dns_sw})", dns_sw != "?"))

        # Кто слушает :53
        ec, out53, _ = self.sh("HQ-SRV", "ss -ulnp | grep :53")
        t.checks.append(Check("Слушает :53", self.has(out53, ":53"),
                               self.clean(out53, 60)))

        # ── Клиентская часть: проверяем записи с HQ-CLI ──

        if not self.vm_on("HQ-CLI"):
            t.checks.append(Check("HQ-CLI не выбран — ручная проверка DNS", False,
                                   self.clean(out53, 60)))
            self.tasks.append(t); return

        # Проверяем есть ли IP у HQ-CLI (DHCP поднялся?)
        ec, out_ip, _ = self.sh("HQ-CLI", "ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v 127.0.0")
        cli_ips = [ip for ip in (out_ip or "").split() if not ip.startswith("127.")]
        cli_has_ip = len(cli_ips) > 0

        if not cli_has_ip or not dns:
            t.checks.append(Check("⚠️  HQ-CLI без IP — требуется ручная проверка DNS", False,
                                   f"Слушает: {self.clean(out53, 50)}"))
            self.tasks.append(t); return

        # A записи — с клиента
        for fqdn, info in DNS_RECORDS.items():
            if info.get("A"):
                ec, out, _ = self.sh("HQ-CLI",
                    f"dig +short {fqdn} @{dns} A 2>/dev/null || "
                    f"nslookup {fqdn} {dns} 2>/dev/null")
                raw = out.strip() if out else ""
                # Парсим IP из dig
                ips = [l.strip() for l in raw.split("\n")
                       if l.strip() and re.match(r'^\d+\.\d+\.\d+\.\d+$', l.strip())]
                if not ips:
                    # Парсим из nslookup
                    ips = re.findall(r'Address:\s*(\d+\.\d+\.\d+\.\d+)', raw)
                    ips = [ip for ip in ips if ip != dns]
                t.checks.append(Check(f"A: {fqdn}", len(ips) >= 1,
                                       f"→{', '.join(ips)}" if ips else ""))

        # PTR записи — с клиента
        for fqdn, info in DNS_RECORDS.items():
            if info.get("PTR"):
                # Сначала получаем IP
                ec, out, _ = self.sh("HQ-CLI",
                    f"dig +short {fqdn} @{dns} A 2>/dev/null || "
                    f"nslookup {fqdn} {dns} 2>/dev/null")
                raw = out.strip() if out else ""
                ips = [l.strip() for l in raw.split("\n")
                       if l.strip() and re.match(r'^\d+\.\d+\.\d+\.\d+$', l.strip())]
                if not ips:
                    ips = re.findall(r'Address:\s*(\d+\.\d+\.\d+\.\d+)', raw)
                    ips = [ip for ip in ips if ip != dns]
                if ips:
                    short_name = fqdn.split('.')[0]
                    found_ptr = False
                    found_ip = ""
                    found_result = ""
                    for ip in ips:
                        ec2, out2, _ = self.sh("HQ-CLI",
                            f"dig +short -x {ip} @{dns} 2>/dev/null || "
                            f"nslookup {ip} {dns} 2>/dev/null")
                        ptr = out2.strip() if out2 else ""
                        if short_name in ptr.lower():
                            found_ptr = True
                            found_ip = ip
                            # Чистим результат
                            m = re.search(r'name\s*=\s*(\S+)', ptr, re.I)
                            found_result = m.group(1) if m else ptr.split("\n")[0]
                            break
                    t.checks.append(Check(f"PTR: {found_ip or ips[0]}→{fqdn}",
                                           found_ptr, f"→{found_result}"))
                else:
                    t.checks.append(Check(f"PTR: {fqdn}", False, "нет A"))

        # Forwarder — конфиг на сервере
        if dns_sw == "bind":
            ec, out, _ = self.sh("HQ-SRV",
                "grep -rh 'forwarders\\|forward' /etc/bind/ /etc/named/ /var/named/ /etc/named.conf 2>/dev/null "
                "| grep -v '//' | grep -v '#'")
            t.checks.append(Check("Forwarder настроен (bind)",
                                   self.has_any(out, "forwarders","forward")))
        elif dns_sw == "dnsmasq":
            ec, out, _ = self.sh("HQ-SRV",
                "cat /etc/dnsmasq.conf 2>/dev/null; "
                "find /etc/dnsmasq.d/ -type f 2>/dev/null | xargs cat 2>/dev/null "
                "| grep -v '#' | grep -iE 'server|resolv'")
            ec2, out2, _ = self.sh("HQ-SRV", "cat /etc/resolv.conf 2>/dev/null")
            has_upstream = self.has_any(out, "server=", "resolv-file") or self.has(out2, "nameserver")
            t.checks.append(Check("Forwarder настроен (dnsmasq)", has_upstream))
        else:
            ec, out, _ = self.sh("HQ-SRV",
                "grep -rh 'forwarders\\|forward\\|server=' "
                "/etc/bind/ /etc/named/ /etc/dnsmasq.conf /etc/named.conf 2>/dev/null")
            t.checks.append(Check("Forwarder настроен", self.has_any(out, "forwarders","forward","server=")))

        # Зоны — конфиг на сервере
        if dns_sw == "bind":
            ec, out, _ = self.sh("HQ-SRV",
                "grep -rh 'zone' /etc/bind/ /etc/named/ /etc/named.conf 2>/dev/null "
                "| grep -v '//' | grep -v '#' | grep -vi 'localhost\\|127\\|0.in-addr\\|255' | head -10")
            zones = re.findall(r'zone\s+"([^"]+)"', out or "")
            t.checks.append(Check("Bind: зоны настроены",
                                   self.has(out, "au-team.irpo") or len(zones) > 0,
                                   ", ".join(zones) if zones else ""))
        elif dns_sw == "dnsmasq":
            ec, out, _ = self.sh("HQ-SRV",
                "cat /etc/dnsmasq.conf 2>/dev/null; "
                "find /etc/dnsmasq.d/ -type f 2>/dev/null | xargs cat 2>/dev/null; "
                "cat /etc/hosts 2>/dev/null "
                "| grep -iE 'address=|host-record|au-team' | head -10")
            t.checks.append(Check("Dnsmasq: записи настроены",
                                   self.has_any(out, "address=","host-record","au-team"),
                                   self.clean(out, 50)))

        # Forward ya.ru — с клиента
        ec, out, _ = self.sh("HQ-CLI",
            f"nslookup ya.ru {dns} 2>/dev/null || "
            f"dig +short ya.ru @{dns} 2>/dev/null")
        forward_ok = bool(out) and not self.has(out, "NXDOMAIN") and not self.has(out, "SERVFAIL")
        # Дополнительно: должен быть IP в ответе
        if forward_ok:
            forward_ok = bool(re.search(r'\d+\.\d+\.\d+\.\d+', out))
        t.checks.append(Check("Forward: ya.ru (с клиента)", forward_ok,
                               self.clean(out, 40)))

        # Общая проверка с клиента
        ec, out, _ = self.sh("HQ-CLI",
            f"nslookup hq-srv.au-team.irpo {dns} 2>/dev/null || "
            f"dig +short hq-srv.au-team.irpo @{dns} 2>/dev/null")
        t.checks.append(Check("HQ-CLI→DNS: hq-srv",
                               bool(out) and not self.has(out, "NXDOMAIN") and
                               bool(re.search(r'\d+\.\d+\.\d+\.\d+', out or "")),
                               self.clean(out, 40)))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 11: Timezone
    # ═══════════════════════════════════════════════

    def task11(self):
        t = Task("11", "Часовой пояс")
        tzs = {}
        for vm in self.sel_vms:
            ec, out, _ = self.sh(vm, "timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || readlink /etc/localtime")
            tz = out.strip() if out else "?"
            tzs[vm] = tz
            ok = tz != "?" and "utc" not in tz.lower() and "gmt" not in tz.lower()
            t.checks.append(Check(f"{vm}: {tz}", ok))
        t.checks.append(Check("Одинаковый TZ", len(set(tzs.values()))==1, str(set(tzs.values()))))
        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  RUN + REPORT
    # ═══════════════════════════════════════════════

    def run(self):
        task_map = {
            "1": self.task1, "2": self.task2, "3": self.task3,
            "4": self.task4, "5": self.task5, "6": self.task6,
            "7": self.task7, "8": self.task8, "9": self.task9,
            "10": self.task10, "11": self.task11,
        }
        for tid in self.sel_tasks:
            fn = task_map.get(tid)
            if fn:
                print(f"  ▶ Задание {tid}...")
                try:
                    fn()
                except Exception as e:
                    self.tasks.append(Task(tid, f"ОШИБКА", [Check(str(e), False)]))

    def report(self):
        tp, ta = 0, 0
        print()
        print("╔═════════════════════════════════════════════════════════╗")
        print("║              ОТЧЁТ — МОДУЛЬ 1                         ║")
        print(f"║  {datetime.now().strftime('%Y-%m-%d %H:%M:%S'):^51s}  ║")
        print(f"║  VM: {', '.join(self.sel_vms):^49s}  ║")
        print("╚═════════════════════════════════════════════════════════╝")

        for task in self.tasks:
            tp += task.score; ta += task.total
            ico = "✅" if task.score==task.total else "⚠️ " if task.score>0 else "❌"
            print(f"\n{ico} Задание {task.id}: {task.title}  [{task.score}/{task.total}]")
            print("─" * 58)
            for c in task.checks:
                i = "  ✅" if c.passed else "  ❌"
                line = f"{i} {c.name}"
                if c.detail: line += f"  ({c.detail})"
                print(line)

        pct = (tp/ta*100) if ta else 0
        bar = "█" * int(30*pct/100) + "░" * (30-int(30*pct/100))
        print(f"\n{'═'*58}\n  ИТОГО: {tp}/{ta} ({pct:.0f}%)  [{bar}]\n{'═'*58}")

        # Save
        import io
        buf = io.StringIO()
        # Re-print to buffer (simplified)
        buf.write(f"Модуль 1 — {datetime.now()}\nVM: {', '.join(self.sel_vms)}\n\n")
        for task in self.tasks:
            buf.write(f"[{task.score}/{task.total}] {task.id}: {task.title}\n")
            for c in task.checks:
                buf.write(f"  {'✓' if c.passed else '✗'} {c.name} {c.detail}\n")
        buf.write(f"\nИТОГО: {tp}/{ta} ({pct:.0f}%)\n")
        path = "/tmp/module1_report.txt"
        with open(path, "w") as f:
            f.write(buf.getvalue())
        print(f"\n  📄 Отчёт: {path}")


# ═══════════════════════════════════════════════════════════════
#  MODULE 2 CHECKER
# ═══════════════════════════════════════════════════════════════

class Module2Checker:
    """
    Модуль 2: Samba DC, RAID, NFS, Chrony, Ansible, Docker, Web, DNAT, Nginx
    """
    def __init__(self, api: PVE, sel_vms: List[str], sel_tasks: List[str]):
        self.api = api
        self.sel_vms = sel_vms
        self.sel_tasks = sel_tasks
        self.tasks: list[Task] = []

    def sh(self, vm, cmd):
        return self.api.sh(vm, cmd)

    def has(self, text, *kw):
        if not text: return False
        t = text.lower()
        return all(k.lower() in t for k in kw)

    def has_any(self, text, *kw):
        if not text: return False
        t = text.lower()
        return any(k.lower() in t for k in kw)

    def vm_on(self, vm):
        return vm in self.sel_vms

    def clean(self, text, maxlen=80):
        if not text: return ""
        out = re.sub(r'[^\x20-\x7E]', '', text)
        out = re.sub(r'\s+', ' ', out).strip()
        return out[:maxlen]

    def svc_active(self, text):
        if not text: return False
        return any(l.strip() == "active" for l in text.split("\n"))

    # ─────────────────────────────────────────
    #  PREFLIGHT
    # ─────────────────────────────────────────

    def preflight(self):
        print("═" * 65)
        print("  PREFLIGHT — МОДУЛЬ 2")
        print("═" * 65)
        for name in self.sel_vms:
            vmid = self.api.vms.get(name)
            if not vmid:
                print(f"  {name:10s}: ❌ VMID не задан")
                continue
            try:
                st = self.api.vm_status(vmid)
                running = st.get("status") == "running"
                agent = self.api.agent_ping(vmid) if running else False
                if running and agent:
                    print(f"  {name:10s} (VMID {vmid}): ✅ OK")
                elif running:
                    print(f"  {name:10s} (VMID {vmid}): ⚠️  agent не отвечает")
                else:
                    print(f"  {name:10s} (VMID {vmid}): ❌ остановлена")
            except Exception as e:
                print(f"  {name:10s}: ❌ {e}")
        print()

    def discover(self):
        """Минимальный discovery — IP адреса для проверок"""
        print("═" * 65)
        print("  DISCOVERY — МОДУЛЬ 2")
        print("═" * 65)

        self.vm_ips = {}
        for vm in self.sel_vms:
            try:
                ec, out, _ = self.sh(vm, "hostname -I 2>/dev/null")
                ips = out.strip().split() if out else []
                # Берём первый не-loopback IP
                ip = next((i for i in ips if not i.startswith("127.")), "")
                self.vm_ips[vm] = ip
                if ip:
                    print(f"  {vm}: {ip}")
            except Exception:
                self.vm_ips[vm] = ""
        print()

    # ═══════════════════════════════════════════════
    #  TASK 1: Samba DC
    # ═══════════════════════════════════════════════

    def task1(self):
        t = Task("1", "Контроллер домена Samba DC")

        if not self.vm_on("BR-SRV"):
            t.checks.append(Check("BR-SRV не выбран", False))
            self.tasks.append(t); return

        # 1a. Samba AD service active
        ec, out, _ = self.sh("BR-SRV", "systemctl is-active samba 2>/dev/null; samba-tool domain info 127.0.0.1 2>/dev/null")
        t.checks.append(Check("BR-SRV: Samba AD active", self.svc_active(out) or self.has(out, "Domain")))

        # 1b. Domain name
        ec, out, _ = self.sh("BR-SRV", "samba-tool domain info 127.0.0.1 2>/dev/null; cat /etc/samba/smb.conf 2>/dev/null")
        t.checks.append(Check("Домен au-team.irpo",
                               self.has_any(out, "au-team.irpo", "AU-TEAM"),
                               self.clean(out, 60)))

        # 1c. HQ-CLI joined to domain
        if self.vm_on("HQ-CLI"):
            ec, out, _ = self.sh("HQ-CLI",
                "realm list 2>/dev/null; "
                "cat /etc/krb5.conf 2>/dev/null | grep -i 'default_realm\\|au-team'; "
                "cat /etc/sssd/sssd.conf 2>/dev/null | grep -i 'domain\\|au-team'; "
                "net ads info 2>/dev/null")
            t.checks.append(Check("HQ-CLI: в домене au-team.irpo",
                                   self.has_any(out, "au-team", "AU-TEAM"),
                                   self.clean(out, 60)))
        else:
            t.checks.append(Check("HQ-CLI: не выбран", False))

        # 1d. Users hquser1-5 exist
        ec, out, _ = self.sh("BR-SRV",
            "for u in hquser1 hquser2 hquser3 hquser4 hquser5; do "
            "samba-tool user show $u 2>/dev/null && echo FOUND_$u; done")
        for i in range(1, 6):
            t.checks.append(Check(f"Пользователь hquser{i}",
                                   self.has(out, f"FOUND_hquser{i}")))

        # 1e. Group hq exists with users
        ec, out, _ = self.sh("BR-SRV",
            "samba-tool group listmembers hq 2>/dev/null")
        members = [l.strip() for l in (out or "").split("\n") if l.strip()]
        t.checks.append(Check("Группа hq существует", len(members) > 0,
                               f"членов: {len(members)}"))
        has_all = all(f"hquser{i}" in (out or "").lower() for i in range(1, 6))
        t.checks.append(Check("hquser1-5 в группе hq", has_all,
                               ", ".join(members[:5]) if members else ""))

        # 1f. Users can auth on HQ-CLI
        if self.vm_on("HQ-CLI"):
            ec, out, _ = self.sh("HQ-CLI",
                "id hquser1@au-team.irpo 2>/dev/null || "
                "id hquser1 2>/dev/null || "
                "getent passwd hquser1 2>/dev/null")
            t.checks.append(Check("HQ-CLI: hquser1 аутентификация",
                                   self.has_any(out, "uid=", "hquser1"),
                                   self.clean(out, 60)))

        # 1g. Sudo limited: cat, grep, id only
        if self.vm_on("HQ-CLI"):
            # Check sudoers config on HQ-CLI (may be from domain or local)
            ec, out, _ = self.sh("HQ-CLI",
                "grep -rhi 'hq\\|hquser' /etc/sudoers /etc/sudoers.d/ 2>/dev/null; "
                "cat /etc/sudoers.d/*hq* /etc/sudoers.d/*domain* 2>/dev/null")
            # Also check via sudo -l for hquser1
            ec2, out2, _ = self.sh("HQ-CLI",
                "sudo -l -U hquser1 2>/dev/null")
            combined = (out or "") + "\n" + (out2 or "")
            has_cat = self.has_any(combined, "/bin/cat", "/usr/bin/cat", "cat")
            has_grep = self.has_any(combined, "/bin/grep", "/usr/bin/grep", "grep")
            has_id = self.has_any(combined, "/bin/id", "/usr/bin/id", " id")
            t.checks.append(Check("Sudo: cat, grep, id для группы hq",
                                   has_cat and has_grep and has_id,
                                   self.clean(combined, 80)))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 2: RAID
    # ═══════════════════════════════════════════════

    def task2(self):
        t = Task("2", "Файловое хранилище RAID")

        if not self.vm_on("HQ-SRV"):
            t.checks.append(Check("HQ-SRV не выбран", False))
            self.tasks.append(t); return

        # 2a. md0 exists
        ec, out, _ = self.sh("HQ-SRV", "cat /proc/mdstat 2>/dev/null")
        t.checks.append(Check("md0 существует", self.has(out, "md0"),
                               self.clean(out, 60)))

        # 2b. RAID level 0
        ec, out, _ = self.sh("HQ-SRV", "mdadm --detail /dev/md0 2>/dev/null")
        t.checks.append(Check("RAID уровень 0",
                               self.has_any(out, "raid0", "raid level : raid0", "level : 0"),
                               self.clean(out, 60)))

        # 2c. Two disks
        disks = re.findall(r'/dev/\w+', out or "")
        active = [d for d in disks if d != "/dev/md0"]
        t.checks.append(Check("2 диска в массиве",
                               len(active) >= 2,
                               f"диски: {', '.join(active)}"))

        # 2d. mdadm.conf
        ec, out, _ = self.sh("HQ-SRV",
            "cat /etc/mdadm.conf /etc/mdadm/mdadm.conf 2>/dev/null | grep -i 'array\\|md0'")
        t.checks.append(Check("mdadm.conf",
                               self.has_any(out, "md0", "ARRAY"),
                               self.clean(out, 50)))

        # 2e. ext4 filesystem
        ec, out, _ = self.sh("HQ-SRV", "blkid /dev/md0* 2>/dev/null; blkid /dev/md0p1 2>/dev/null")
        t.checks.append(Check("Файловая система ext4",
                               self.has(out, "ext4"),
                               self.clean(out, 60)))

        # 2f. Mounted at /raid
        ec, out, _ = self.sh("HQ-SRV", "mount | grep raid; df -h /raid 2>/dev/null")
        t.checks.append(Check("Смонтирован в /raid",
                               self.has(out, "/raid"),
                               self.clean(out, 60)))

        # 2g. Automount (fstab)
        ec, out, _ = self.sh("HQ-SRV", "grep -v '#' /etc/fstab | grep -i 'md0\\|raid'")
        t.checks.append(Check("Автомонтирование (fstab)",
                               self.has_any(out, "md0", "/raid"),
                               self.clean(out, 60)))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 3: NFS
    # ═══════════════════════════════════════════════

    def task3(self):
        t = Task("3", "Сетевая файловая система NFS")

        if not self.vm_on("HQ-SRV"):
            t.checks.append(Check("HQ-SRV не выбран", False))
            self.tasks.append(t); return

        # 3a. NFS server active
        ec, out, _ = self.sh("HQ-SRV",
            "systemctl is-active nfs-server nfs 2>/dev/null; "
            "systemctl is-active nfs-kernel-server 2>/dev/null")
        t.checks.append(Check("HQ-SRV: NFS сервер active",
                               self.svc_active(out)))

        # 3b. Export /raid/nfs
        ec, out, _ = self.sh("HQ-SRV", "exportfs -v 2>/dev/null; cat /etc/exports 2>/dev/null")
        t.checks.append(Check("Экспорт /raid/nfs",
                               self.has(out, "/raid/nfs"),
                               self.clean(out, 60)))

        # 3c. Access restricted to HQ-CLI network — ищем rw именно в строке /raid/nfs
        raid_line = ""
        for line in (out or "").split("\n"):
            if "/raid/nfs" in line:
                raid_line = line
                break
        t.checks.append(Check("Доступ: rw для сети HQ-CLI",
                               self.has(raid_line, "rw"),
                               self.clean(raid_line, 60)))

        # 3d. Directory exists
        ec, out, _ = self.sh("HQ-SRV", "ls -ld /raid/nfs 2>/dev/null")
        t.checks.append(Check("/raid/nfs существует",
                               ec == 0 and self.has(out, "nfs")))

        # 3e. HQ-CLI: mounted at /mnt/nfs
        if self.vm_on("HQ-CLI"):
            ec, out, _ = self.sh("HQ-CLI", "mount | grep nfs; df -h /mnt/nfs 2>/dev/null")
            t.checks.append(Check("HQ-CLI: /mnt/nfs смонтирован",
                                   self.has(out, "/mnt/nfs"),
                                   self.clean(out, 60)))

            # 3f. Automount on HQ-CLI
            ec, out, _ = self.sh("HQ-CLI",
                "grep -v '#' /etc/fstab | grep -i nfs; "
                "systemctl is-active autofs 2>/dev/null; "
                "cat /etc/auto.master /etc/auto.nfs 2>/dev/null | grep nfs")
            t.checks.append(Check("HQ-CLI: автомонтирование NFS",
                                   self.has_any(out, "nfs", "autofs", "auto"),
                                   self.clean(out, 60)))

            # 3g. Functional: write test
            ec, out, _ = self.sh("HQ-CLI",
                "touch /mnt/nfs/checker_test_$$ 2>/dev/null && "
                "rm -f /mnt/nfs/checker_test_$$ 2>/dev/null && echo NFS_WRITE_OK")
            t.checks.append(Check("HQ-CLI: запись в NFS работает",
                                   self.has(out, "NFS_WRITE_OK")))
        else:
            t.checks.append(Check("HQ-CLI: не выбран", False))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 4: Chrony / NTP
    # ═══════════════════════════════════════════════

    def task4(self):
        t = Task("4", "Служба времени Chrony")

        isp_ips = []

        # 4a. ISP: chrony server active
        if self.vm_on("ISP"):
            ec, out, _ = self.sh("ISP",
                "systemctl is-active chronyd 2>/dev/null; "
                "systemctl is-active chrony 2>/dev/null")
            t.checks.append(Check("ISP: chronyd active", self.svc_active(out)))

            # Собираем IP ISP (без grep -P, ALT Linux может не поддерживать)
            ec, out_ip, _ = self.sh("ISP",
                "ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v 127.0.0")
            if out_ip:
                isp_ips = [ip.strip() for ip in out_ip.split("\n") if ip.strip()]

            # 4b. Stratum — grep фильтрует комментарии на стороне shell
            ec, out, _ = self.sh("ISP",
                "grep -hv '^#' /etc/chrony.conf /etc/chrony/chrony.conf 2>/dev/null "
                "| grep -i 'local stratum'")
            t.checks.append(Check("ISP: stratum 5",
                                   self.has(out, "local stratum") and self.has(out, "5"),
                                   self.clean(out, 50)))

            # 4c. Allow clients
            ec, out, _ = self.sh("ISP",
                "grep -hv '^#' /etc/chrony.conf /etc/chrony/chrony.conf 2>/dev/null "
                "| grep -i 'allow'")
            t.checks.append(Check("ISP: allow клиентов",
                                   self.has(out, "allow"),
                                   self.clean(out, 50)))

        # 4d. Clients — проверяем что синхронизируются с IP ISP
        clients = [("HQ-SRV", "HQ-SRV"), ("HQ-CLI", "HQ-CLI"),
                   ("BR-RTR", "BR-RTR"), ("BR-SRV", "BR-SRV")]
        for vm, label in clients:
            if not self.vm_on(vm): continue
            ec, out, _ = self.sh(vm, "chronyc tracking 2>/dev/null")

            ec2, out2, _ = self.sh(vm,
                "systemctl is-active chronyd chrony 2>/dev/null")
            svc_ok = self.svc_active(out2)

            # Проверяем что Reference ID содержит IP ISP
            # chrony может показать IP как hostname, но hex ID всегда есть
            # 172.16.1.1 → AC100101
            synced_to_isp = False
            if isp_ips and out:
                for ip in isp_ips:
                    # Прямое совпадение IP
                    if ip in out:
                        synced_to_isp = True
                        break
                    # Hex Reference ID: 172.16.1.1 → AC100101
                    parts = ip.split(".")
                    if len(parts) == 4:
                        try:
                            hex_id = "".join(f"{int(p):02X}" for p in parts)
                            if hex_id in out.upper():
                                synced_to_isp = True
                                break
                        except ValueError:
                            pass

            if isp_ips:
                t.checks.append(Check(f"{label}: chrony → ISP",
                                       svc_ok and synced_to_isp,
                                       self.clean(out, 50)))
            else:
                has_source = self.has_any(out, "stratum", "reference")
                t.checks.append(Check(f"{label}: chrony клиент",
                                       svc_ok and has_source,
                                       self.clean(out, 50)))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 5: Ansible
    # ═══════════════════════════════════════════════

    def task5(self):
        t = Task("5", "Ansible на BR-SRV")

        if not self.vm_on("BR-SRV"):
            t.checks.append(Check("BR-SRV не выбран", False))
            self.tasks.append(t); return

        # 5a. Ansible installed
        ec, out, _ = self.sh("BR-SRV", "ansible --version 2>/dev/null")
        t.checks.append(Check("Ansible установлен",
                               self.has(out, "ansible"),
                               self.clean(out, 40)))

        # 5b. Working directory /etc/ansible
        ec, out, _ = self.sh("BR-SRV", "ls /etc/ansible/ 2>/dev/null")
        t.checks.append(Check("Каталог /etc/ansible",
                               ec == 0 and bool(out and out.strip())))

        # 5c. Inventory file exists
        ec, out, _ = self.sh("BR-SRV",
            "cat /etc/ansible/hosts /etc/ansible/inventory /etc/ansible/inventory.yml "
            "/etc/ansible/inventory.ini 2>/dev/null")
        inv = out or ""

        # 5d. Inventory contains required hosts
        hosts_needed = ["HQ-SRV", "HQ-CLI", "HQ-RTR", "BR-RTR"]
        for h in hosts_needed:
            # Check by name or IP
            found = self.has_any(inv, h.lower(), h)
            t.checks.append(Check(f"Inventory: {h}", found))

        # 5e. ansible.cfg
        ec, out, _ = self.sh("BR-SRV",
            "cat /etc/ansible/ansible.cfg 2>/dev/null")
        t.checks.append(Check("ansible.cfg настроен",
                               self.has_any(out, "inventory", "host_key_checking"),
                               self.clean(out, 50)))

        # 5f. Functional: ping all
        ec, out, _ = self.sh("BR-SRV",
            "cd /etc/ansible && ansible all -m ping 2>/dev/null | head -30")
        pongs = (out or "").lower().count("pong")
        t.checks.append(Check(f"Ansible ping → pong (все хосты)",
                               pongs >= 4,
                               f"{pongs} ответов pong"))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 6: Docker (BR-SRV)
    # ═══════════════════════════════════════════════

    def task6(self):
        t = Task("6", "Docker приложение на BR-SRV")

        if not self.vm_on("BR-SRV"):
            t.checks.append(Check("BR-SRV не выбран", False))
            self.tasks.append(t); return

        # 6a. Docker active
        ec, out, _ = self.sh("BR-SRV", "systemctl is-active docker 2>/dev/null")
        t.checks.append(Check("Docker active", self.svc_active(out)))

        # 6b. Container testapp running
        ec, out, _ = self.sh("BR-SRV",
            "docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null")
        t.checks.append(Check("Контейнер testapp",
                               self.has_any(out, "testapp", "tespapp"),
                               self.clean(out, 60)))

        # 6c. Container db running
        t.checks.append(Check("Контейнер db",
                               self.has(out, "db"),
                               self.clean(out, 60)))

        # 6d. Port 8080 exposed
        ec, out2, _ = self.sh("BR-SRV",
            "docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null; "
            "ss -tlnp | grep 8080")
        t.checks.append(Check("Порт 8080",
                               self.has(out2, "8080"),
                               self.clean(out2, 60)))

        # 6e. Docker compose/yaml exists
        ec, out, _ = self.sh("BR-SRV",
            "find / -maxdepth 4 -name 'docker-compose*' -o -name 'compose.yml' -o -name 'compose.yaml' 2>/dev/null | head -5")
        t.checks.append(Check("Docker compose файл",
                               bool(out and out.strip()),
                               self.clean(out, 60)))

        # 6f. DB config: testdb, user testc
        ec, out, _ = self.sh("BR-SRV",
            "find / -maxdepth 4 \\( -name 'docker-compose*' -o -name 'compose.yml' -o -name 'compose.yaml' \\) 2>/dev/null "
            "| head -3 | xargs cat 2>/dev/null")
        t.checks.append(Check("БД: testdb, user testc",
                               self.has(out, "testdb") and self.has(out, "testc"),
                               self.clean(out, 60)))

        # 6g. App accessible on 8080
        ec, out, _ = self.sh("BR-SRV",
            "curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null")
        code = out.strip() if out else ""
        t.checks.append(Check("Приложение отвечает на :8080",
                               code in ("200", "301", "302"),
                               f"HTTP {code}"))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 7: Web app (HQ-SRV)
    # ═══════════════════════════════════════════════

    def task7(self):
        t = Task("7", "Веб-приложение на HQ-SRV")

        if not self.vm_on("HQ-SRV"):
            t.checks.append(Check("HQ-SRV не выбран", False))
            self.tasks.append(t); return

        # 7a. Apache active
        ec, out, _ = self.sh("HQ-SRV",
            "systemctl is-active httpd apache2 httpd2 2>/dev/null")
        t.checks.append(Check("Apache active", self.svc_active(out)))

        # 7b. MariaDB active
        ec, out, _ = self.sh("HQ-SRV",
            "systemctl is-active mariadb mysqld mysql 2>/dev/null")
        t.checks.append(Check("MariaDB active", self.svc_active(out)))

        # 7c. Database webdb exists
        ec, out, _ = self.sh("HQ-SRV",
            "mysql -e 'SHOW DATABASES;' 2>/dev/null || "
            "mariadb -e 'SHOW DATABASES;' 2>/dev/null")
        t.checks.append(Check("БД webdb",
                               self.has(out, "webdb"),
                               self.clean(out, 40)))

        # 7d. User webc with access
        ec, out, _ = self.sh("HQ-SRV",
            "mysql -e \"SELECT user,host FROM mysql.user WHERE user='webc';\" 2>/dev/null || "
            "mariadb -e \"SELECT user,host FROM mysql.user WHERE user='webc';\" 2>/dev/null")
        t.checks.append(Check("Пользователь БД webc",
                               self.has(out, "webc"),
                               self.clean(out, 40)))

        # 7e. index.php in web root
        ec, out, _ = self.sh("HQ-SRV",
            "find /var/www -name 'index.php' 2>/dev/null; "
            "find /srv/www -name 'index.php' 2>/dev/null")
        t.checks.append(Check("index.php в каталоге Apache",
                               self.has(out, "index.php"),
                               self.clean(out, 60)))

        # 7f. logo/images in web root
        ec, out, _ = self.sh("HQ-SRV",
            "find /var/www -name 'logo.png' -o -name 'images' 2>/dev/null; "
            "find /srv/www -name 'logo.png' -o -name 'images' 2>/dev/null; "
            "ls /var/www/html/logo.png /var/www/html/images/ 2>/dev/null")
        t.checks.append(Check("Файлы приложения (logo.png)",
                               self.has_any(out, "logo.png", "images"),
                               self.clean(out, 60)))

        # 7g. App accessible
        ec, out, _ = self.sh("HQ-SRV",
            "curl -s -o /dev/null -w '%{http_code}' http://localhost 2>/dev/null; "
            "curl -s -o /dev/null -w '%{http_code}' http://localhost:80 2>/dev/null")
        code = (out or "").strip()[-3:] if out else ""
        t.checks.append(Check("Веб-приложение отвечает",
                               code in ("200", "301", "302"),
                               f"HTTP {code}"))

        # 7h. PHP works (not showing source)
        ec, out, _ = self.sh("HQ-SRV",
            "curl -s http://localhost/index.php 2>/dev/null | head -5")
        no_php_source = out and "<?php" not in out
        t.checks.append(Check("PHP обрабатывается",
                               bool(out and out.strip()) and no_php_source))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 8: Port forwarding (DNAT)
    # ═══════════════════════════════════════════════

    def task8(self):
        t = Task("8", "Статическая трансляция портов")

        # Helper to check DNAT rules
        def check_dnat(vm, port, desc):
            if not self.vm_on(vm): return
            ec, out, _ = self.sh(vm,
                "iptables -t nat -L PREROUTING -nv 2>/dev/null; "
                "iptables -t nat -L OUTPUT -nv 2>/dev/null; "
                "nft list ruleset 2>/dev/null")
            has_dnat = self.has(out, str(port)) and self.has_any(out, "dnat", "redirect", "dport")
            t.checks.append(Check(f"{vm}: DNAT :{port} ({desc})",
                                   has_dnat,
                                   self.clean(out, 60)))

        # 8a. BR-RTR: 8080 → testapp (BR-SRV)
        check_dnat("BR-RTR", 8080, "→ testapp BR-SRV")

        # 8b. HQ-RTR: 8080 → web app (HQ-SRV)
        check_dnat("HQ-RTR", 8080, "→ web HQ-SRV")

        # 8c. HQ-RTR: 2026 → ssh HQ-SRV
        check_dnat("HQ-RTR", 2026, "→ SSH HQ-SRV")

        # 8d. BR-RTR: 2026 → ssh BR-SRV
        check_dnat("BR-RTR", 2026, "→ SSH BR-SRV")

        # 8e. Functional: test from ISP
        if self.vm_on("ISP"):
            # Test web on HQ-RTR:8080
            if self.vm_on("HQ-RTR"):
                ec, out, _ = self.sh("HQ-RTR", "ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep '172.16' | head -1")
                hq_rtr_ip = out.strip() if out else ""
                if hq_rtr_ip:
                    ec, out, _ = self.sh("ISP",
                        f"curl -s -o /dev/null -w '%{{http_code}}' --connect-timeout 3 http://{hq_rtr_ip}:8080 2>/dev/null")
                    code = out.strip() if out else ""
                    t.checks.append(Check("ISP→HQ-RTR:8080 (web)",
                                           code in ("200", "301", "302"),
                                           f"HTTP {code}"))

            # Test web on BR-RTR:8080
            if self.vm_on("BR-RTR"):
                ec, out, _ = self.sh("BR-RTR", "ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep '172.16' | head -1")
                br_rtr_ip = out.strip() if out else ""
                if br_rtr_ip:
                    ec, out, _ = self.sh("ISP",
                        f"curl -s -o /dev/null -w '%{{http_code}}' --connect-timeout 3 http://{br_rtr_ip}:8080 2>/dev/null")
                    code = out.strip() if out else ""
                    t.checks.append(Check("ISP→BR-RTR:8080 (testapp)",
                                           code in ("200", "301", "302"),
                                           f"HTTP {code}"))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 9: Nginx reverse proxy (ISP)
    # ═══════════════════════════════════════════════

    def task9(self):
        t = Task("9", "Nginx обратный прокси на ISP")

        if not self.vm_on("ISP"):
            t.checks.append(Check("ISP не выбран", False))
            self.tasks.append(t); return

        # 9a. Nginx active
        ec, out, _ = self.sh("ISP", "systemctl is-active nginx 2>/dev/null")
        t.checks.append(Check("Nginx active", self.svc_active(out)))

        # 9b. Full nginx config — grep по всему /etc/nginx/ надёжнее чем cat с glob
        ec, out, _ = self.sh("ISP",
            "grep -rh 'server_name\\|proxy_pass\\|auth_basic\\|htpasswd\\|location\\|upstream' "
            "/etc/nginx/ 2>/dev/null | grep -v '#'")
        # Также попробуем find+cat для sites-available
        ec2, out2, _ = self.sh("ISP",
            "find /etc/nginx/sites-available /etc/nginx/conf.d -type f 2>/dev/null "
            "| xargs cat 2>/dev/null")
        cfg = (out or "") + "\n" + (out2 or "")

        t.checks.append(Check("Конфиг: web.au-team.irpo",
                               self.has(cfg, "web.au-team.irpo"),
                               self.clean(cfg, 60)))

        t.checks.append(Check("Конфиг: docker.au-team.irpo",
                               self.has(cfg, "docker.au-team.irpo"),
                               self.clean(cfg, 60)))

        t.checks.append(Check("proxy_pass настроен",
                               self.has(cfg, "proxy_pass")))

        # 9e. Functional: web.au-team.irpo (401 = auth required = OK по заданию 10!)
        ec, out, _ = self.sh("ISP",
            "curl -s -o /dev/null -w '%{http_code}' "
            "-H 'Host: web.au-team.irpo' http://127.0.0.1 2>/dev/null")
        code = out.strip() if out else ""
        # 200 = работает без auth, 401 = auth настроена (задание 10), обе ситуации ОК
        t.checks.append(Check("web.au-team.irpo → HQ-SRV",
                               code in ("200", "301", "302", "401"),
                               f"HTTP {code}" + (" (auth)" if code == "401" else "")))

        # 9f. Functional: docker.au-team.irpo
        ec, out, _ = self.sh("ISP",
            "curl -s -o /dev/null -w '%{http_code}' "
            "-H 'Host: docker.au-team.irpo' http://127.0.0.1 2>/dev/null")
        code = out.strip() if out else ""
        t.checks.append(Check("docker.au-team.irpo → testapp",
                               code in ("200", "301", "302"),
                               f"HTTP {code}"))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 10: Nginx basic auth
    # ═══════════════════════════════════════════════

    def task10(self):
        t = Task("10", "Web-based аутентификация (nginx)")

        if not self.vm_on("ISP"):
            t.checks.append(Check("ISP не выбран", False))
            self.tasks.append(t); return

        # 10a. htpasswd file exists
        ec, out, _ = self.sh("ISP", "cat /etc/nginx/.htpasswd 2>/dev/null")
        t.checks.append(Check("/etc/nginx/.htpasswd существует",
                               bool(out and out.strip()),
                               self.clean(out, 50)))

        # 10b. User WEB in htpasswd
        t.checks.append(Check("Пользователь WEB в .htpasswd",
                               self.has_any(out, "WEB:", "web:"),
                               self.clean(out, 40)))

        # 10c. Nginx config has auth_basic
        ec, cfg, _ = self.sh("ISP",
            "grep -rh 'auth_basic\\|htpasswd\\|server_name' "
            "/etc/nginx/ 2>/dev/null | grep -v '#'; "
            "find /etc/nginx/sites-available /etc/nginx/conf.d -type f 2>/dev/null "
            "| xargs cat 2>/dev/null")
        t.checks.append(Check("auth_basic настроен",
                               self.has(cfg, "auth_basic"),
                               self.clean(cfg, 50)))

        # 10d. auth_basic_user_file
        t.checks.append(Check("auth_basic_user_file → .htpasswd",
                               self.has(cfg, "htpasswd")))

        # 10e. Without auth → 401
        ec, out, _ = self.sh("ISP",
            "curl -s -o /dev/null -w '%{http_code}' "
            "-H 'Host: web.au-team.irpo' http://127.0.0.1 2>/dev/null")
        code = out.strip() if out else ""
        t.checks.append(Check("Без аутент. → 401",
                               code == "401",
                               f"HTTP {code}"))

        # 10f. With auth → 200
        ec, out, _ = self.sh("ISP",
            "curl -s -o /dev/null -w '%{http_code}' "
            "-u 'WEB:P@ssw0rd' "
            "-H 'Host: web.au-team.irpo' http://127.0.0.1 2>/dev/null")
        code = out.strip() if out else ""
        t.checks.append(Check("С аутент. WEB:P@ssw0rd → 200",
                               code in ("200", "301", "302"),
                               f"HTTP {code}"))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  TASK 11: Yandex Browser
    # ═══════════════════════════════════════════════

    def task11(self):
        t = Task("11", "Яндекс Браузер на HQ-CLI")

        if not self.vm_on("HQ-CLI"):
            t.checks.append(Check("HQ-CLI не выбран", False))
            self.tasks.append(t); return

        # 11a. Yandex browser installed
        ec, out, _ = self.sh("HQ-CLI",
            "which yandex-browser yandex-browser-stable 2>/dev/null; "
            "rpm -qa 2>/dev/null | grep -i yandex; "
            "dpkg -l 2>/dev/null | grep -i yandex; "
            "ls /usr/bin/yandex-browser* /opt/yandex/browser* 2>/dev/null; "
            "flatpak list 2>/dev/null | grep -i yandex")
        t.checks.append(Check("Яндекс Браузер установлен",
                               self.has_any(out, "yandex-browser", "yandex"),
                               self.clean(out, 60)))

        # 11b. Desktop file / binary exists
        ec, out2, _ = self.sh("HQ-CLI",
            "find /usr/share/applications -iname '*yandex*' 2>/dev/null; "
            "find /opt -iname '*yandex*browser*' -type f 2>/dev/null | head -3")
        t.checks.append(Check("Ярлык/файлы браузера",
                               self.has_any(out2, "yandex"),
                               self.clean(out2, 60)))

        self.tasks.append(t)

    # ═══════════════════════════════════════════════
    #  RUN + REPORT
    # ═══════════════════════════════════════════════

    def run(self):
        task_map = {
            "1": self.task1, "2": self.task2, "3": self.task3,
            "4": self.task4, "5": self.task5, "6": self.task6,
            "7": self.task7, "8": self.task8, "9": self.task9,
            "10": self.task10, "11": self.task11,
        }
        for tid in self.sel_tasks:
            fn = task_map.get(tid)
            if fn:
                print(f"  ▶ Задание {tid}...")
                try:
                    fn()
                except Exception as e:
                    self.tasks.append(Task(tid, f"ОШИБКА", [Check(str(e), False)]))

    def report(self):
        tp, ta = 0, 0
        print()
        print("╔═════════════════════════════════════════════════════════╗")
        print("║              ОТЧЁТ — МОДУЛЬ 2                         ║")
        print(f"║  {datetime.now().strftime('%Y-%m-%d %H:%M:%S'):^51s}  ║")
        print(f"║  VM: {', '.join(self.sel_vms):^49s}  ║")
        print("╚═════════════════════════════════════════════════════════╝")

        for task in self.tasks:
            tp += task.score; ta += task.total
            ico = "✅" if task.score==task.total else "⚠️ " if task.score>0 else "❌"
            print(f"\n{ico} Задание {task.id}: {task.title}  [{task.score}/{task.total}]")
            print("─" * 58)
            for c in task.checks:
                i = "  ✅" if c.passed else "  ❌"
                line = f"{i} {c.name}"
                if c.detail: line += f"  ({c.detail})"
                print(line)

        pct = (tp/ta*100) if ta else 0
        bar = "█" * int(30*pct/100) + "░" * (30-int(30*pct/100))
        print(f"\n{'═'*58}\n  ИТОГО: {tp}/{ta} ({pct:.0f}%)  [{bar}]\n{'═'*58}")

        import io
        buf = io.StringIO()
        buf.write(f"Модуль 2 — {datetime.now()}\nVM: {', '.join(self.sel_vms)}\n\n")
        for task in self.tasks:
            buf.write(f"[{task.score}/{task.total}] {task.id}: {task.title}\n")
            for c in task.checks:
                buf.write(f"  {'✓' if c.passed else '✗'} {c.name} {c.detail}\n")
        buf.write(f"\nИТОГО: {tp}/{ta} ({pct:.0f}%)\n")
        path = "/tmp/module2_report.txt"
        with open(path, "w") as f:
            f.write(buf.getvalue())
        print(f"\n  📄 Отчёт: {path}")


# ═══════════════════════════════════════════════════════════════
#  MAIN
# ═══════════════════════════════════════════════════════════════

def main():
    global DEBUG
    parser = argparse.ArgumentParser(description="Демоэкзамен 2026 checker v11")
    parser.add_argument("--host", help="IP Proxmox")
    parser.add_argument("--port", type=int, default=8006)
    parser.add_argument("--node", default="pve")
    parser.add_argument("--token-id", default="root@pam!checker")
    parser.add_argument("--token-secret")
    parser.add_argument("--all", action="store_true")
    parser.add_argument("--vm", help="ISP,HQ-RTR,...")
    parser.add_argument("--task", help="1,5,10,...")
    parser.add_argument("--module", type=int, default=0, help="1 or 2")
    parser.add_argument("--config", help="JSON config file")
    parser.add_argument("--debug", action="store_true")
    parser.add_argument("--add-pve", action="store_true", help="Добавить PVE сервер")
    args = parser.parse_args()

    DEBUG = args.debug

    # Add PVE
    if args.add_pve:
        add_pve_config()
        return

    # Load config
    if args.config:
        with open(args.config) as f: config = json.load(f)
        if not config.get("token_secret"): config["token_secret"] = getpass.getpass("Secret: ")
    elif args.host and args.token_secret:
        config = {"host":args.host,"port":args.port,"node":args.node,
                  "token_id":args.token_id,"token_secret":args.token_secret,"vms":DEFAULT_VMS}
    else:
        config = select_pve()
        if not config:
            print("  Нет конфигурации, выход")
            return

    vms = config.get("vms", DEFAULT_VMS)

    # Module selection
    module = args.module
    if module == 0:
        module = select_module()

    # VM/task selection
    max_task = 11
    if args.all:
        sv, st = list(vms.keys()), [str(i) for i in range(1, max_task + 1)]
    elif args.vm or args.task:
        sv = [v.strip() for v in args.vm.split(",")] if args.vm else list(vms.keys())
        st = [t.strip() for t in args.task.split(",")] if args.task else [str(i) for i in range(1, max_task + 1)]
    else:
        sv, st = select_mode(vms, module)

    api = PVE(config["host"], config.get("port",8006),
              config.get("token_id","root@pam!checker"),
              config["token_secret"], config.get("node","pve"), vms)

    if module == 1:
        ck = Checker(api, sv, st)
        ck.preflight()
        ck.discover()
        print("═" * 58)
        print("  ПРОВЕРКА — МОДУЛЬ 1")
        print("═" * 58)
        ck.run()
        ck.report()
    elif module == 2:
        m2 = Module2Checker(api, sv, st)
        m2.preflight()
        m2.discover()
        print("═" * 58)
        print("  ПРОВЕРКА — МОДУЛЬ 2")
        print("═" * 58)
        m2.run()
        m2.report()
    else:
        print("  Неизвестный модуль")


if __name__ == "__main__":
    main()
