Демо-экзамен 2026 · Модуль 1
КОД 09.02.06-1-2026 · 1 ч. · 25 баллов
← Demo 2026 Модуль 2 →
Образец задания

Модуль 1. Настройка сетевой инфраструктуры

Разработать и настроить инфраструктуру ИКС согласно предложенной топологии (Рисунок 1). 11 пунктов.
  1. 1

    Произведите базовую настройку устройств

    • Настройте имена устройств согласно топологии. Используйте полное доменное имя.
    • На всех устройствах необходимо сконфигурировать IPv4:
      • IP-адрес должен быть из приватного диапазона, в случае, если сеть локальная, согласно RFC1918
      • Локальная сеть в сторону HQ-SRV (VLAN 100) должна вмещать не более 32 адресов
      • Локальная сеть в сторону HQ-CLI (VLAN 200) должна вмещать не менее 16 адресов
      • Локальная сеть для управления (VLAN 999) должна вмещать не более 8 адресов
      • Локальная сеть в сторону BR-SRV должна вмещать не более 16 адресов
    • Сведения об адресах занесите в таблицу 2, в качестве примера используйте Прил_3_О1_КОД 09.02.06-1-2026-М1
  2. 2

    Настройте доступ к сети Интернет на маршрутизаторе ISP

    • Настройте адресацию на интерфейсах:
      • Интерфейс, подключенный к магистральному провайдеру, получает адрес по DHCP
      • Настройте маршрут по умолчанию, если это необходимо
    • Настройте интерфейс в сторону HQ-RTR, интерфейс подключен к сети 172.16.1.0/28
    • Настройте интерфейс в сторону BR-RTR, интерфейс подключен к сети 172.16.2.0/28
    • На ISP настройте динамическую сетевую трансляцию портов для доступа к сети Интернет HQ-RTR и BR-RTR
  3. 3

    Создайте локальные учётные записи

    • На серверах HQ-SRV и BR-SRV создайте пользователя sshuser:
      • Пароль пользователя sshuser — P@ssw0rd
      • Идентификатор пользователя (UID) — 2026
      • sshuser должен иметь возможность запускать sudo без ввода пароля
    • На маршрутизаторах HQ-RTR и BR-RTR создайте пользователя net_admin:
      • Пароль пользователя net_admin — P@ssw0rd
      • При настройке ОС на базе Linux — запускать sudo без ввода пароля
      • При настройке ОС отличных от Linux — пользователь должен обладать максимальными привилегиями
  4. 4

    Настройте коммутацию в сегменте HQ

    • Трафик HQ-SRV должен принадлежать VLAN 100
    • Трафик HQ-CLI должен принадлежать VLAN 200
    • Предусмотреть возможность передачи трафика управления в VLAN 999
    • Реализовать на HQ-RTR маршрутизацию трафика всех указанных VLAN с использованием одного сетевого адаптера ВМ/физического порта
    • Сведения о настройке коммутации внесите в отчёт
  5. 5

    Настройте безопасный удалённый доступ на серверах HQ-SRV и BR-SRV

    • Для подключения используйте порт 2026
    • Разрешите подключения исключительно пользователю sshuser
    • Ограничьте количество попыток входа до двух
    • Настройте баннер «Authorized access only»
  6. 6

    IP-туннель между офисами HQ и BR

    • На маршрутизаторах HQ-RTR и BR-RTR необходимо сконфигурировать ip-туннель
    • На выбор технологии: GRE или IP in IP
    • Сведения о туннеле занесите в отчёт
  7. 7

    Обеспечьте динамическую маршрутизацию

    • На маршрутизаторах HQ-RTR и BR-RTR: сети одного офиса должны быть доступны из другого
    • Используйте link state протокол на усмотрение участника (OSPF / IS-IS)
    • Разрешите выбранный протокол только на интерфейсах ip-туннеля
    • Маршрутизаторы должны делиться маршрутами только друг с другом
    • Обеспечьте защиту выбранного протокола посредством парольной защиты
    • Сведения о настройке и защите протокола занесите в отчёт
  8. 8

    Динамическая трансляция адресов

    • Настройте динамическую трансляцию адресов на HQ-RTR и BR-RTR для обоих офисов в сторону ISP
    • Все устройства в офисах должны иметь доступ к сети Интернет
  9. 9

    DHCP для сети в сторону HQ-CLI

    • Настройте нужную подсеть
    • В качестве сервера DHCP выступает маршрутизатор HQ-RTR
    • Клиентом является машина HQ-CLI
    • Исключите из выдачи адрес маршрутизатора
    • Адрес шлюза по умолчанию — адрес маршрутизатора HQ-RTR
    • Адрес DNS-сервера для машины HQ-CLI — адрес сервера HQ-SRV
    • DNS-суффикс — au-team.irpo
    • Сведения о настройке протокола занесите в отчёт
  10. 10

    Инфраструктура разрешения доменных имён

    • Основной DNS-сервер реализован на HQ-SRV
    • Сервер должен обеспечивать разрешение имён в сетевые адреса устройств и обратно в соответствии с таблицей 3
    • В качестве DNS сервера пересылки используйте любой общедоступный DNS сервер (77.88.8.7, 77.88.8.3 или другие)
  11. 11

    Настройте часовой пояс

    • На всех устройствах (за исключением виртуального коммутатора, в случае его использования) согласно месту проведения экзамена
Топология сети · интерактив

Схема — Рисунок 1

Два офиса (HQ, BR) через провайдера ISP. GRE/IPIP-туннель между маршрутизаторами. Домен au-team.irpo.
ISP Роутер HQ-SW (trunk) Сервер Клиент
HQ BR uplink DHCP 172.16.1.0/28 172.16.2.0/28 trunk v100,200,999 VLAN 100 (/27) VLAN 200 (/28) GRE tunnel (10.10.10.0/30) Internet ISP HQ-RTR BR-RTR HQ-SW BR-SRV HQ-SRV HQ-CLI
ISP → HQ: 172.16.1.0/28 · ISP → BR: 172.16.2.0/28 · VLAN 100 (SRV, /27, ≤32 адр.) · VLAN 200 (CLI, /28, ≥16 адр.) · VLAN 999 (MGMT, /29, ≤8 адр.) · BR-LAN (/28, ≤16 адр.)
ISP
Альт JeOS · 1 ГБ RAM · 1 ядро · 5 ГБ
HQ-RTR
EcoRouter (4 ГБ/4 ядра) или Альт JeOS (1 ГБ/1 ядро) · 10 ГБ
BR-RTR
EcoRouter (4 ГБ/4 ядра) или Альт JeOS (1 ГБ/1 ядро) · 10 ГБ
HQ-SRV
Альт Сервер · 2 ГБ RAM · 1 ядро · 10 ГБ
BR-SRV
Альт Сервер · 2 ГБ RAM · 1 ядро · 10 ГБ
HQ-CLI
Альт Рабочая Станция · 2 ГБ RAM · 2 ядра · 15 ГБ
Генераторы скриптов

Автоматизация настройки

По одному генератору bash-скрипта на каждое устройство. Заполни параметры — получи готовый setup.sh. Генераторы встроены в эту страницу — работают даже без доступа к серверу.
Python-утилиты

Развёртывание и проверка

Два скрипта для полной автоматизации: deployer разворачивает всё через Proxmox Guest Agent, checker проверяет соответствие критериям.
🚀

deployer_m3.py

Автодеплой Модуля 1 — разворачивает конфигурацию на всех VM через Proxmox Guest Agent. Порядок: ISP → HQ-RTR → HQ-SRV → BR-RTR → BR-SRV → HQ-CLI
532 строк · 18 КБ
#!/usr/bin/env python3
"""
═══════════════════════════════════════════════════════════════
  Демоэкзамен 2026 — Автодеплой Модуля 1
  Разворачивает конфигурацию на всех VM через Proxmox Guest Agent
═══════════════════════════════════════════════════════════════

  Порядок: ISP → HQ-RTR → HQ-SRV → BR-RTR → BR-SRV → HQ-CLI

  Для каждой VM:
    1. (если нужно) Добавляем vmbr0 адаптер через Proxmox API
    2. Получаем DHCP на внешнем интерфейсе
    3. curl setup.sh с demo.digit-shop.ru
    4. Запускаем (если ошибка timezone — перезапуск)
    5. Ждём завершения
"""

import requests
import urllib3
import json
import time
import re
import sys
import os
import getpass
import argparse

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

CONFIG_FILE = os.path.expanduser("~/.demo_checker.json")
SETUP_BASE = "https://demo.digit-shop.ru/generated"
GA_TIMEOUT = 120  # setup scripts can take a while
GA_POLL = 2

# VM deployment order and config
DEPLOY_ORDER = [
    {
        "name": "ISP",
        "vmid": 100,
        "slug": "isp",
        "needs_vmbr0": False,   # already has vmbr0 on net0
        "dhcp_cmd": "dhcpcd ens19 2>/dev/null || dhclient ens19 2>/dev/null; sleep 3",
        "wait_after": 5,
    },
    {
        "name": "HQ-RTR",
        "vmid": 101,
        "slug": "hq-rtr",
        "needs_vmbr0": True,    # add temp vmbr0 for internet
        "dhcp_cmd": None,       # will be set dynamically after vmbr0 added
        "wait_after": 5,
    },
    {
        "name": "HQ-SRV",
        "vmid": 103,
        "slug": "hq-srv",
        "needs_vmbr0": True,    # has net1[vmbr0] but may need DHCP
        "dhcp_cmd": None,
        "wait_after": 5,
    },
    {
        "name": "BR-RTR",
        "vmid": 102,
        "slug": "br-rtr",
        "needs_vmbr0": True,
        "dhcp_cmd": None,
        "wait_after": 5,
    },
    {
        "name": "BR-SRV",
        "vmid": 104,
        "slug": "br-srv",
        "needs_vmbr0": True,
        "dhcp_cmd": None,
        "wait_after": 5,
    },
    {
        "name": "HQ-CLI",
        "vmid": 105,
        "slug": "hq-cli",
        "needs_vmbr0": True,
        "dhcp_cmd": None,
        "wait_after": 5,
    },
]


class PVE:
    def __init__(self, host, port, token_id, token_secret, node):
        self.base = f"https://{host}:{port}/api2/json"
        self.node = node
        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):
        r = self.s.post(f"{self.base}{path}", data=kwargs)
        r.raise_for_status()
        return r.json().get("data")

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

    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:
            return False

    def exec_cmd(self, vmid, cmd, timeout=GA_TIMEOUT):
        """Execute command via guest agent with stdin method"""
        try:
            r = self.s.post(
                f"{self.base}/nodes/{self.node}/qemu/{vmid}/agent/exec",
                data={"command": "/bin/sh", "input-data": cmd + "\n"}
            )
            r.raise_for_status()
            result = r.json().get("data", {})
            pid = result.get("pid")
            if pid is None:
                return (None, "", "no pid")

            elapsed = 0
            while elapsed < 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"):
                        return (st.get("exitcode", -1),
                                st.get("out-data", ""),
                                st.get("err-data", ""))
                except:
                    pass

            return (None, "", "timeout")
        except Exception as e:
            return (None, "", str(e))

    def add_net(self, vmid, net_key, bridge, model="virtio"):
        """Add network adapter to VM"""
        try:
            self.put(
                f"/nodes/{self.node}/qemu/{vmid}/config",
                **{net_key: f"{model},bridge={bridge}"}
            )
            return True
        except Exception as e:
            print(f"    ⚠️  Не удалось добавить {net_key}: {e}")
            return False

    def remove_net(self, vmid, net_key):
        """Remove network adapter from VM"""
        try:
            self.put(
                f"/nodes/{self.node}/qemu/{vmid}/config",
                delete=net_key
            )
            return True
        except:
            return False

    def find_free_net(self, vmid):
        """Find first unused netX slot"""
        cfg = self.vm_config(vmid)
        for i in range(10):
            if f"net{i}" not in cfg:
                return f"net{i}"
        return None

    def has_vmbr0(self, vmid):
        """Check if VM already has vmbr0"""
        cfg = self.vm_config(vmid)
        for key, val in cfg.items():
            if key.startswith("net") and isinstance(val, str) and "vmbr0" in val:
                return True
        return False


def load_config():
    """Load PVE config (same as checker)"""
    if not os.path.exists(CONFIG_FILE):
        return None
    try:
        with open(CONFIG_FILE, "r") as f:
            data = json.load(f)
        if "host" in data:
            return data
        # Multi-PVE format
        items = list(data.values())
        if len(items) == 1:
            return items[0]
        # Select
        print("\n  Сохранённые PVE серверы:")
        for i, cfg in enumerate(items, 1):
            print(f"    {i}. {cfg.get('name', cfg.get('host'))}")
        ch = input(f"  Выбор [1]: ").strip() or "1"
        return items[int(ch)-1] if ch.isdigit() and int(ch) <= len(items) else items[0]
    except:
        return None


def wait_agent(api, vmid, name, timeout=60):
    """Wait for guest agent to become available"""
    print(f"    ⏳ Жду agent на {name}...", end="", flush=True)
    for i in range(timeout // 3):
        if api.agent_ping(vmid):
            print(" ✅")
            return True
        time.sleep(3)
        print(".", end="", flush=True)
    print(" ❌ timeout")
    return False


def find_new_iface(api, vmid):
    """Find the interface connected to vmbr0 by looking for one without IP"""
    ec, out, _ = api.exec_cmd(vmid, "ip -br addr show | grep -v lo | grep -v '@'")
    if not out:
        return None
    # Find interfaces, pick the last one (newest) or one without IP
    lines = [l.strip() for l in out.split("\n") if l.strip()]
    for line in reversed(lines):
        parts = line.split()
        if len(parts) >= 1:
            iface = parts[0]
            # If it has no IP (only name + state), it's likely the new one
            if len(parts) <= 2 or not any("." in p for p in parts[2:]):
                return iface
    # Fallback: return last interface
    if lines:
        return lines[-1].split()[0]
    return None


def deploy_vm(api, vm_cfg, vms_config, keep_vmbr0=False):
    """Deploy setup script on a single VM"""
    name = vm_cfg["name"]
    vmid = vms_config.get(name, vm_cfg["vmid"])
    slug = vm_cfg["slug"]

    print(f"\n{'═'*60}")
    print(f"  🚀 {name} (VMID {vmid})")
    print(f"{'═'*60}")

    # 1. Check agent
    if not wait_agent(api, vmid, name):
        print(f"    ❌ Agent недоступен, пропускаю {name}")
        return False

    # 2. Add vmbr0 if needed
    added_net = None
    new_iface = None
    if vm_cfg["needs_vmbr0"]:
        if api.has_vmbr0(vmid):
            print(f"    ℹ️  vmbr0 уже есть")
        else:
            net_key = api.find_free_net(vmid)
            if net_key:
                # Запоминаем интерфейсы ДО добавления
                ec, before, _ = api.exec_cmd(vmid,
                    "ip -br link show | grep -v lo | awk '{print $1}'")
                ifaces_before = set((before or "").split())

                print(f"    📡 Добавляю {net_key}=vmbr0...")
                if api.add_net(vmid, net_key, "vmbr0"):
                    added_net = net_key
                    print(f"    ✅ {net_key} добавлен, жду появления интерфейса...")

                    # Ждём появления нового интерфейса в ОС (до 30 сек)
                    for attempt in range(15):
                        time.sleep(2)
                        ec, after, _ = api.exec_cmd(vmid,
                            "ip -br link show | grep -v lo | awk '{print $1}'")
                        ifaces_after = set((after or "").split())
                        new_ifaces = ifaces_after - ifaces_before
                        if new_ifaces:
                            new_iface = new_ifaces.pop()
                            print(f"    ✅ Новый интерфейс: {new_iface}")
                            break
                        print(f"    .", end="", flush=True)
                    else:
                        print(f"\n    ⚠️  Интерфейс не появился за 30 сек")
            else:
                print(f"    ⚠️  Нет свободных net-слотов")

    # 3. Get DHCP on the new interface (or all)
    if new_iface:
        print(f"    📡 DHCP на {new_iface}...")
        ec, out, _ = api.exec_cmd(vmid,
            f"ip link set {new_iface} up && "
            f"dhcpcd {new_iface} 2>/dev/null || dhclient {new_iface} 2>/dev/null; "
            f"sleep 3",
            timeout=30)
    elif vm_cfg.get("dhcp_cmd"):
        print(f"    📡 DHCP: {vm_cfg['dhcp_cmd'][:50]}...")
        ec, out, err = api.exec_cmd(vmid, vm_cfg["dhcp_cmd"], timeout=30)
    else:
        # Fallback: try all interfaces
        print(f"    📡 DHCP на всех интерфейсах...")
        ec, out, _ = api.exec_cmd(vmid,
            "for iface in $(ip -br link show | grep -v lo | awk '{print $1}'); do "
            "  ip link set $iface up 2>/dev/null; "
            "  dhcpcd $iface 2>/dev/null || dhclient $iface 2>/dev/null; "
            "done; sleep 3",
            timeout=30)

    # 4. Verify internet
    print(f"    🌐 Проверка интернета...")
    ec, out, _ = api.exec_cmd(vmid, "ping -c1 -W3 77.88.8.8 2>/dev/null && echo INET_OK", timeout=15)
    if "INET_OK" not in (out or ""):
        # Try again with explicit DHCP
        print(f"    ⚠️  Нет интернета, пробую DHCP на всех интерфейсах...")
        ec, out, _ = api.exec_cmd(vmid,
            "for iface in $(ip -br link show | grep -v lo | awk '{print $1}'); do "
            "  ip link set $iface up 2>/dev/null; "
            "  dhcpcd $iface 2>/dev/null || dhclient $iface 2>/dev/null; "
            "done; sleep 5; ping -c1 -W3 77.88.8.8 2>/dev/null && echo INET_OK",
            timeout=30)
        if "INET_OK" not in (out or ""):
            print(f"    ❌ Нет интернета на {name}! Пробую продолжить...")

    # 5. Download setup script
    url = f"{SETUP_BASE}/{slug}/setup.sh"
    print(f"    📥 Скачиваю: {url}")
    ec, out, err = api.exec_cmd(vmid,
        f"cd /root && curl -sSfO {url} && chmod +x setup.sh && echo DOWNLOAD_OK",
        timeout=30)
    if "DOWNLOAD_OK" not in (out or ""):
        print(f"    ❌ Не удалось скачать: {(err or out or '')[:100]}")
        return False
    print(f"    ✅ Скрипт скачан")

    # 6. Run setup script (first attempt)
    print(f"    ▶️  Запуск setup.sh (попытка 1)...")
    ec, out, err = api.exec_cmd(vmid, "cd /root && ./setup.sh 2>&1", timeout=GA_TIMEOUT)
    combined = (out or "") + (err or "")

    # 7. Check for timezone error → re-run
    if "Failed to set time zone" in combined or "No such file" in combined or ec != 0:
        print(f"    ⚠️  Ошибка timezone или другая, перезапуск...")
        time.sleep(3)
        print(f"    ▶️  Запуск setup.sh (попытка 2)...")
        ec, out, err = api.exec_cmd(vmid, "cd /root && ./setup.sh 2>&1", timeout=GA_TIMEOUT)
        combined = (out or "") + (err or "")

        # Third attempt if still failing
        if ec != 0:
            print(f"    ⚠️  Ещё ошибка (ec={ec}), попытка 3...")
            time.sleep(3)
            ec, out, err = api.exec_cmd(vmid, "cd /root && ./setup.sh 2>&1", timeout=GA_TIMEOUT)
            combined = (out or "") + (err or "")

    if ec == 0:
        print(f"    ✅ {name} — setup.sh завершён успешно!")
    else:
        print(f"    ⚠️  {name} — setup.sh ec={ec}")
        # Show last lines of output
        lines = combined.strip().split("\n")
        for line in lines[-5:]:
            clean = re.sub(r'[^\x20-\x7E]', '', line).strip()
            if clean:
                print(f"       {clean[:80]}")

    # 8. Wait a bit for services to settle
    if vm_cfg.get("wait_after"):
        time.sleep(vm_cfg["wait_after"])

    # 9. Убираем временный vmbr0
    if added_net and not keep_vmbr0:
        print(f"    🗑️  Удаляю {added_net} (vmbr0) из Proxmox...")
        if api.remove_net(vmid, added_net):
            print(f"    ✅ {added_net} удалён")
        else:
            print(f"    ⚠️  Не удалось удалить {added_net}")

        # Перезагрузка сети только на HQ-CLI (DHCP клиент, нужно обновить)
        if name == "HQ-CLI":
            print(f"    🔄 Перезагрузка сети на HQ-CLI...")
            api.exec_cmd(vmid,
                "systemctl restart network 2>/dev/null; "
                "systemctl restart networking 2>/dev/null; "
                "systemctl restart NetworkManager 2>/dev/null; "
                "sleep 3",
                timeout=30)
            print(f"    ✅ Сеть перезагружена")

    return True


def main():
    parser = argparse.ArgumentParser(description="Автодеплой Модуля 1")
    parser.add_argument("--vm", help="Только конкретные VM (ISP,HQ-RTR,...)")
    parser.add_argument("--skip", help="Пропустить VM (ISP,HQ-CLI,...)")
    parser.add_argument("--no-vmbr0", action="store_true", help="Не добавлять vmbr0")
    parser.add_argument("--keep-vmbr0", action="store_true", help="Не удалять vmbr0 после деплоя")
    parser.add_argument("--clean-vmbr0", action="store_true", help="Удалить ВСЕ vmbr0 (кроме ISP) без деплоя")
    parser.add_argument("--dry-run", action="store_true", help="Только показать план")
    parser.add_argument("--timeout", type=int, default=120, help="Таймаут exec (сек)")
    args = parser.parse_args()

    global GA_TIMEOUT
    GA_TIMEOUT = args.timeout

    print()
    print("╔══════════════════════════════════════════════════════╗")
    print("║     Демоэкзамен 2026 — Автодеплой Модуля 1         ║")
    print("╚══════════════════════════════════════════════════════╝")

    # Load config
    config = load_config()
    if not config:
        print("  ❌ Нет сохранённого конфига. Сначала запустите checker_v6.py")
        return

    if not config.get("token_secret"):
        config["token_secret"] = getpass.getpass("  Token Secret: ")

    vms_config = config.get("vms", {})
    print(f"\n  🖥️  PVE: {config.get('name', config['host'])} ({config['host']})")

    # Filter VMs
    deploy_list = DEPLOY_ORDER[:]
    if args.vm:
        selected = [v.strip().upper() for v in args.vm.split(",")]
        deploy_list = [v for v in deploy_list if v["name"] in selected]
    if args.skip:
        skipped = [v.strip().upper() for v in args.skip.split(",")]
        deploy_list = [v for v in deploy_list if v["name"] not in skipped]

    if args.no_vmbr0:
        for v in deploy_list:
            v["needs_vmbr0"] = False

    # Show plan
    if not args.clean_vmbr0:
        print(f"\n  📋 План деплоя ({len(deploy_list)} VM):")
        for v in deploy_list:
            vmbr = " +vmbr0" if v["needs_vmbr0"] else ""
            vmid = vms_config.get(v["name"], v["vmid"])
            print(f"    {v['name']:10s} (VMID {vmid}){vmbr}")
            print(f"      → {SETUP_BASE}/{v['slug']}/setup.sh")

    if args.dry_run:
        print("\n  (dry-run, выход)")
        return

    if not args.clean_vmbr0:
        confirm = input(f"\n  Начать деплой? [Y/n]: ").strip().lower()
        if confirm not in ("", "y", "yes", "д", "да"):
            print("  Отмена")
            return

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

    # Mode: clean-vmbr0 only
    if args.clean_vmbr0:
        print(f"\n  🧹 Удаляю vmbr0 со всех VM (кроме ISP)...")
        for vm_cfg in deploy_list:
            name = vm_cfg["name"]
            if name == "ISP":
                continue
            vmid = vms_config.get(name, vm_cfg["vmid"])
            cfg = api.vm_config(vmid)
            removed = False
            for key, val in cfg.items():
                if key.startswith("net") and isinstance(val, str) and "vmbr0" in val:
                    print(f"    {name}: удаляю {key} (vmbr0)...")
                    api.remove_net(vmid, key)
                    removed = True
            if removed:
                # Перезагрузка сети только на HQ-CLI
                if name == "HQ-CLI" and api.agent_ping(vmid):
                    api.exec_cmd(vmid,
                        "systemctl restart network 2>/dev/null; "
                        "systemctl restart networking 2>/dev/null; "
                        "systemctl restart NetworkManager 2>/dev/null",
                        timeout=30)
                    print(f"    {name}: ✅ vmbr0 удалён, сеть перезагружена")
                else:
                    print(f"    {name}: ✅ vmbr0 удалён")
            else:
                print(f"    {name}: нет vmbr0")
        return

    # Deploy each VM
    results = {}
    for vm_cfg in deploy_list:
        try:
            ok = deploy_vm(api, vm_cfg, vms_config, keep_vmbr0=args.keep_vmbr0)
            results[vm_cfg["name"]] = ok
        except Exception as e:
            print(f"    ❌ Ошибка: {e}")
            results[vm_cfg["name"]] = False

    # Summary
    print(f"\n{'═'*60}")
    print(f"  РЕЗУЛЬТАТЫ ДЕПЛОЯ")
    print(f"{'═'*60}")
    for name, ok in results.items():
        ico = "✅" if ok else "❌"
        print(f"  {ico} {name}")

    ok_count = sum(1 for v in results.values() if v)
    total = len(results)
    print(f"\n  Готово: {ok_count}/{total}")

    if ok_count == total:
        print(f"\n  💡 Теперь запустите проверку:")
        print(f"     python3 checker_v6.py --module 1 --all")


if __name__ == "__main__":
    main()

checker_v16.py

Checker Модулей 1+2 (11+11 задач) — через Proxmox Guest Agent проверяет соответствие всех пунктов задания. Флаги: --all, --debug.
2268 строк · 93 КБ
#!/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()
Таблица 7 · ГИА ДЭ БУ

Критерии оценки · Модуль 1

Распределение 26.00 баллов по категориям оценивания согласно КОД.

Модуль 1. Выполнение работ по проектированию сетевой инфраструктуры

26.00балл.
  1. Участие в приёмо-сдаточных испытаниях компьютерных сетей и сетевого оборудования различного уровня и в оценке качества и экономической эффективности сетевой топологии
    1.00
  2. Осуществление выбора технологии, инструментальных средств и средств вычислительной техники при организации процесса разработки и исследования объектов профессиональной деятельности
    14.00
  3. Выполнение проектирования кабельной структуры компьютерной сети
    7.00
  4. Обеспечение защиты информации в сети с использованием программно-аппаратных средств
    2.00
  5. Осуществление поиска, анализа и интерпретации информации, необходимой для выполнения задач профессиональной деятельности
    2.00
Пошаговое руководство · Demo2026

Инструкция · Модуль 1

Полная пошаговая настройка 6 устройств с генератором команд. Меняй параметры в блоке «Параметры конфигурации» — все команды ниже обновятся автоматически.
Область покрытия инструкции. Команды ниже — реализация для ALT Linux JeOS (на ISP, HQ-RTR, BR-RTR). Варианты для Eltex и EcoRouter будут добавлены позже. Автоматизация для Модуля 2 (Samba DC, RAID, NFS, Ansible, Docker, Apache+MariaDB, Nginx) — тоже в планах.

⚙ Параметры конфигурации

Измени нужные значения и жми Применить — все команды ниже обновятся автоматически. Настройки сохраняются в браузере.

🌐 Домен

🔗 ISP интерфейсы

🏢 HQ — сети и IP

🏪 BR — сети и IP

🚇 GRE + OSPF

🔐 Учётные записи

🌍 DNS

0

Подготовка гипервизора

PROXMOX

Перед установкой виртуальных машин подготовим Proxmox: включим поддержку VLAN на бридже и повесим VLAN-теги на сетевые адаптеры VM, где это нужно по заданию. Это нужно сделать до первого запуска VM.

Создание самих виртуальных машин (6 VM: ISP / HQ-RTR / BR-RTR / HQ-SRV / BR-SRV / HQ-CLI) — в отдельном разделе, будет позже. Здесь только подготовка Proxmox перед запуском.
0.1Включить VLAN-aware на Linux Bridge
В Proxmox откройте Datacenter → Node → Network → vmbr0 (или тот бридж, который используют VM). Нажмите Edit, установите галочку «VLAN aware», примените (Apply Configuration). Без неё VLAN-теги внутри VM не будут работать.
Если бридж уже используется работающими VM — Proxmox может запросить перезагрузку хоста, чтобы изменения применились.
0.2VLAN Tag на HQ-SRV и HQ-CLI — до запуска VM
Для HQ-SRV и HQ-CLI теги VLAN ставим на уровне Proxmox, а не внутри гостевой ОС. Гипервизор сам тегирует пакеты при отправке и снимает тег при получении — внутри VM интерфейс работает как обычный нетегированный.
ВАЖНО: настроить VLAN Tag ДО первого запуска VM. Если VM стартует без тега — она попадёт в нативный VLAN бриджа и не увидит остальную сеть.
Для каждой VM через веб-интерфейс Proxmox:
proxmox
VM (HQ-SRV) → Hardware → Network Device (net0) → Edit
    ├ Bridge:   vmbr0
    ├ VLAN Tag: 100     ← проставить!
    └ OK

VM (HQ-CLI) → Hardware → Network Device (net0) → Edit
    ├ Bridge:   vmbr0
    ├ VLAN Tag: 200     ← проставить!
    └ OK
Для HQ-RTR тег не ставим — к нему приходит весь trunk со всеми VLAN, и он сам их разбирает через sub-interfaces (ens19.100, ens19.200, ens19.999).
0.3Проверка поддержки VLAN на физическом интерфейсе
Если Proxmox стоит на железе — убедитесь что физический интерфейс сервера (и сетевая карта свитча) поддерживает VLAN (802.1Q). Без этого теги не пройдут и VM не получит сеть.
bash
# На хосте Proxmox — проверяем загружен ли модуль 8021q
lsmod | grep 8021q
# Если пусто — загружаем:
modprobe 8021q
echo "8021q" >> /etc/modules

# Проверяем что бридж действительно VLAN-aware:
cat /sys/class/net/vmbr0/bridge/vlan_filtering
# Должно быть: 1
На свитче (если используется физ. инфраструктура) порты тоже должны быть в trunk-режиме с разрешёнными VLAN 100/200/999.
1

1. ISP — маршрутизатор провайдера

ISP · ALT JeOS

ISP — маршрутизатор провайдера. Раздаёт интернет в офисы HQ и BR через NAT. 3 интерфейса: WAN (DHCP от аплинка), канал к HQ-RTR, канал к BR-RTR.

1.1Задать hostname
bash
hostnamectl set-hostname isp.{{domain}}
1.2WAN — DHCP от провайдера
Интерфейс {{isp_wan_iface}} получает адрес автоматически. Настраиваем через etcnet (стандарт ALT):
bash
mkdir -p /etc/net/ifaces/{{isp_wan_iface}}
cat > /etc/net/ifaces/{{isp_wan_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=dhcp
SYSTEMD_BOOTPROTO=dhcp4
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

systemctl restart network
sleep 3
ping -c 2 -W 3 77.88.8.8 && echo "Интернет работает"
1.3Часовой пояс
bash
timedatectl set-timezone {{timezone}}
timedatectl  # проверить
1.4Интерфейс к HQ-RTR ({{isp_hq_net}})
bash
mkdir -p /etc/net/ifaces/{{isp_hq_iface}}
cat > /etc/net/ifaces/{{isp_hq_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{isp_hq_ip}}/28" > /etc/net/ifaces/{{isp_hq_iface}}/ipv4address
1.5Интерфейс к BR-RTR ({{isp_br_net}})
bash
mkdir -p /etc/net/ifaces/{{isp_br_iface}}
cat > /etc/net/ifaces/{{isp_br_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{isp_br_ip}}/28" > /etc/net/ifaces/{{isp_br_iface}}/ipv4address

systemctl restart network && sleep 2
ip -br a  # проверка: все 3 интерфейса должны быть UP
1.6IP forwarding (чтобы пакеты маршрутизировались)
bash
# Включаем forwarding в sysctl
if grep -q "^net.ipv4.ip_forward" /etc/net/sysctl.conf; then
    sed -i 's/^net.ipv4.ip_forward.*/net.ipv4.ip_forward = 1/' /etc/net/sysctl.conf
else
    echo "net.ipv4.ip_forward = 1" >> /etc/net/sysctl.conf
fi
sysctl -w net.ipv4.ip_forward=1
1.7Установить iptables
bash
apt-get update
apt-get install -y iptables
1.8Настроить NAT (MASQUERADE)
Трафик из сетей HQ и BR «маскируется» под адрес WAN-интерфейса ISP — так они попадают в интернет.
bash
# Чистим на всякий случай
iptables -t nat -F POSTROUTING

# HQ → WAN
iptables -t nat -A POSTROUTING -s {{isp_hq_net}} -o {{isp_wan_iface}} -j MASQUERADE

# BR → WAN
iptables -t nat -A POSTROUTING -s {{isp_br_net}} -o {{isp_wan_iface}} -j MASQUERADE

# Проверить
iptables -t nat -L POSTROUTING -n -v
1.9Сохранить правила и включить автозапуск
bash
mkdir -p /etc/sysconfig
iptables-save > /etc/sysconfig/iptables
systemctl enable --now iptables
Проверка: с HQ-RTR или BR-RTR (после их настройки) должен пинговаться 77.88.8.8.
2

2. HQ-RTR — маршрутизатор HQ

HQ-RTR · ALT JeOS

HQ-RTR — роутер головного офиса. WAN к ISP, trunk-порт к виртуальному свитчу с тремя VLAN: 100 (серверы), 200 (клиенты), 999 (управление). VLAN настраиваем внутри ALT через sub-interfaces (ens19.100, ens19.200, ens19.999).

2.1Hostname
bash
hostnamectl set-hostname hq-rtr.{{domain}}
2.2WAN к ISP ({{hqrtr_wan_ip}}/28)
bash
mkdir -p /etc/net/ifaces/{{isp_hq_iface}}
cat > /etc/net/ifaces/{{isp_hq_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{hqrtr_wan_ip}}/28" > /etc/net/ifaces/{{isp_hq_iface}}/ipv4address
echo "default via {{isp_hq_ip}}" > /etc/net/ifaces/{{isp_hq_iface}}/ipv4route
2.3DNS для выхода в интернет (временно)
bash
cat > /etc/resolv.conf << 'EOF'
nameserver {{dns_fwd}}
EOF

# Применяем WAN и проверяем интернет
systemctl restart network && sleep 2
ping -c 2 -W 3 77.88.8.8 && echo "Интернет через ISP работает"
2.4Создать базовый интерфейс trunk (без IP)
На интерфейсе {{hqrtr_vlan_iface}} живёт trunk. Сам он без IP — IP будут у его VLAN-подынтерфейсов.
bash
mkdir -p /etc/net/ifaces/{{hqrtr_vlan_iface}}
cat > /etc/net/ifaces/{{hqrtr_vlan_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF
2.5VLAN 100 — для серверов HQ (/27, 32 адреса)
bash
mkdir -p /etc/net/ifaces/{{hqrtr_vlan_iface}}.100
cat > /etc/net/ifaces/{{hqrtr_vlan_iface}}.100/options << EOF
TYPE=vlan
HOST={{hqrtr_vlan_iface}}
VID=100
BOOTPROTO=static
DISABLED=no
ONBOOT=yes
CONFIG_IPV4=yes
EOF

echo "{{hqrtr_srv_ip}}/27" > /etc/net/ifaces/{{hqrtr_vlan_iface}}.100/ipv4address
2.6VLAN 200 — для клиентов HQ (/28, 16 адресов)
bash
mkdir -p /etc/net/ifaces/{{hqrtr_vlan_iface}}.200
cat > /etc/net/ifaces/{{hqrtr_vlan_iface}}.200/options << EOF
TYPE=vlan
HOST={{hqrtr_vlan_iface}}
VID=200
BOOTPROTO=static
DISABLED=no
ONBOOT=yes
CONFIG_IPV4=yes
EOF

echo "{{hqrtr_cli_ip}}/28" > /etc/net/ifaces/{{hqrtr_vlan_iface}}.200/ipv4address
2.7VLAN 999 — сеть управления (/29, 8 адресов)
bash
mkdir -p /etc/net/ifaces/{{hqrtr_vlan_iface}}.999
cat > /etc/net/ifaces/{{hqrtr_vlan_iface}}.999/options << EOF
TYPE=vlan
HOST={{hqrtr_vlan_iface}}
VID=999
BOOTPROTO=static
DISABLED=no
ONBOOT=yes
CONFIG_IPV4=yes
EOF

echo "{{hqrtr_mgmt_ip}}/29" > /etc/net/ifaces/{{hqrtr_vlan_iface}}.999/ipv4address

systemctl restart network && sleep 2
ip -br a  # все 3 VLAN-интерфейса должны быть UP
Что должен показать ip -br a на HQ-RTR:
{{isp_hq_iface}} UP с адресом {{hqrtr_wan_ip}}/28 — WAN к ISP
{{hqrtr_vlan_iface}} UP без IP — это trunk-«носитель» для VLAN
{{hqrtr_vlan_iface}}.100@{{hqrtr_vlan_iface}} UP с {{hqrtr_srv_ip}}/27 — VLAN 100 (SRV)
{{hqrtr_vlan_iface}}.200@{{hqrtr_vlan_iface}} UP с {{hqrtr_cli_ip}}/28 — VLAN 200 (CLI)
{{hqrtr_vlan_iface}}.999@{{hqrtr_vlan_iface}} UP с {{hqrtr_mgmt_ip}}/29 — VLAN 999 (MGMT)
Пинг шлюза ISP: ping -c 2 {{isp_hq_ip}} должен отвечать.
2.8IP forwarding
bash
grep -q "^net.ipv4.ip_forward" /etc/net/sysctl.conf && \
    sed -i 's/^net.ipv4.ip_forward.*/net.ipv4.ip_forward = 1/' /etc/net/sysctl.conf || \
    echo "net.ipv4.ip_forward = 1" >> /etc/net/sysctl.conf
sysctl -w net.ipv4.ip_forward=1
2.9Установить пакеты (iptables, frr, dhcp, sudo)
bash
apt-get update
apt-get install -y iptables frr dhcp-server sudo nano tzdata
2.10Часовой пояс
bash
timedatectl set-timezone {{timezone}}
2.11Пользователь net_admin с sudo без пароля
bash
useradd -m net_admin
echo 'net_admin:{{net_admin_pass}}' | chpasswd
usermod -a -G wheel net_admin

# Включить NOPASSWD для группы wheel
sed -i 's/^#\s*WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/' /etc/sudoers

# Проверка
su - net_admin -c 'sudo whoami'  # должно вернуть: root
3

3. BR-RTR — маршрутизатор BR

BR-RTR · ALT JeOS

BR-RTR — роутер филиала. Проще чем HQ-RTR: без VLAN, только WAN к ISP и LAN к BR-SRV.

3.1Hostname
bash
hostnamectl set-hostname br-rtr.{{domain}}
3.2WAN к ISP ({{brrtr_wan_ip}}/28)
bash
mkdir -p /etc/net/ifaces/{{isp_br_iface}}
cat > /etc/net/ifaces/{{isp_br_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{brrtr_wan_ip}}/28" > /etc/net/ifaces/{{isp_br_iface}}/ipv4address
echo "default via {{isp_br_ip}}" > /etc/net/ifaces/{{isp_br_iface}}/ipv4route
3.3LAN к BR-SRV ({{brrtr_lan_ip}}/28)
bash
mkdir -p /etc/net/ifaces/{{brrtr_lan_iface}}
cat > /etc/net/ifaces/{{brrtr_lan_iface}}/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{brrtr_lan_ip}}/28" > /etc/net/ifaces/{{brrtr_lan_iface}}/ipv4address

cat > /etc/resolv.conf << 'EOF'
nameserver {{dns_fwd}}
EOF

systemctl restart network && sleep 2
ping -c 2 -W 3 77.88.8.8
3.4IP forwarding
bash
grep -q "^net.ipv4.ip_forward" /etc/net/sysctl.conf && \
    sed -i 's/^net.ipv4.ip_forward.*/net.ipv4.ip_forward = 1/' /etc/net/sysctl.conf || \
    echo "net.ipv4.ip_forward = 1" >> /etc/net/sysctl.conf
sysctl -w net.ipv4.ip_forward=1
3.5Пакеты
bash
apt-get update
apt-get install -y iptables frr sudo nano tzdata
3.6Часовой пояс
bash
timedatectl set-timezone {{timezone}}
3.7Пользователь net_admin
bash
useradd -m net_admin
echo 'net_admin:{{net_admin_pass}}' | chpasswd
usermod -a -G wheel net_admin
sed -i 's/^#\s*WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/' /etc/sudoers
4

4. NAT на HQ-RTR

HQ-RTR

Все внутренние сети HQ (VLAN 100/200/999) должны ходить в интернет через WAN с подменой адреса.

4.1MASQUERADE для всех VLAN
bash
# Чистим
iptables -t nat -F POSTROUTING

# VLAN 100 (серверы) → WAN
iptables -t nat -A POSTROUTING -s {{hq_srv_net}} -o {{isp_hq_iface}} -j MASQUERADE

# VLAN 200 (клиенты) → WAN
iptables -t nat -A POSTROUTING -s {{hq_cli_net}} -o {{isp_hq_iface}} -j MASQUERADE

# VLAN 999 (управление) → WAN
iptables -t nat -A POSTROUTING -s {{hq_mgmt_net}} -o {{isp_hq_iface}} -j MASQUERADE

iptables -t nat -L POSTROUTING -n -v
4.2Сохранить и автозапуск
bash
mkdir -p /etc/sysconfig
iptables-save > /etc/sysconfig/iptables
systemctl enable --now iptables
5

5. NAT на BR-RTR

BR-RTR

Аналогично HQ, но только для одной сети (LAN).

5.1MASQUERADE для LAN
bash
iptables -t nat -F POSTROUTING
iptables -t nat -A POSTROUTING -s {{br_lan_net}} -o {{isp_br_iface}} -j MASQUERADE

iptables -t nat -L POSTROUTING -n -v
5.2Сохранить и автозапуск
bash
mkdir -p /etc/sysconfig
iptables-save > /etc/sysconfig/iptables
systemctl enable --now iptables
6

6. GRE-туннель и OSPF

HQ-RTR + BR-RTR

Поднимаем GRE-туннель между HQ-RTR и BR-RTR, поверх него — OSPF с парольной защитой. Оба роутера делятся маршрутами только друг с другом.

6.1HQ-RTR: создать GRE-туннель
bash
# Выполняется на HQ-RTR
mkdir -p /etc/net/ifaces/gre1
cat > /etc/net/ifaces/gre1/options << EOF
TYPE=iptun
TUNTYPE=gre
TUNLOCAL={{hqrtr_wan_ip}}
TUNREMOTE={{brrtr_wan_ip}}
TUNOPTIONS='ttl 64'
HOST={{isp_hq_iface}}
BOOTPROTO=static
DISABLED=no
CONFIG_IPV4=yes
EOF

echo "{{gre_hq_ip}}/30" > /etc/net/ifaces/gre1/ipv4address
systemctl restart network && sleep 2
ip -br a show gre1
6.2HQ-RTR: включить OSPF в FRR
bash
sed -i 's/^ospfd=no/ospfd=yes/' /etc/frr/daemons
systemctl enable --now frr
sleep 2

vtysh << 'VTYSH_EOF'
configure terminal
router ospf
  passive-interface default
  network {{gre_net}} area {{ospf_area}}
  network {{hq_srv_net}} area {{ospf_area}}
  network {{hq_cli_net}} area {{ospf_area}}
  network {{hq_mgmt_net}} area {{ospf_area}}
  area {{ospf_area}} authentication
exit
interface gre1
  no ip ospf passive
  ip ospf authentication-key {{ospf_key}}
exit
do write
end
VTYSH_EOF
6.3BR-RTR: создать GRE-туннель
bash
# Выполняется на BR-RTR
mkdir -p /etc/net/ifaces/gre1
cat > /etc/net/ifaces/gre1/options << EOF
TYPE=iptun
TUNTYPE=gre
TUNLOCAL={{brrtr_wan_ip}}
TUNREMOTE={{hqrtr_wan_ip}}
TUNOPTIONS='ttl 64'
HOST={{isp_br_iface}}
BOOTPROTO=static
DISABLED=no
CONFIG_IPV4=yes
EOF

echo "{{gre_br_ip}}/30" > /etc/net/ifaces/gre1/ipv4address
systemctl restart network && sleep 2
6.4BR-RTR: OSPF
bash
sed -i 's/^ospfd=no/ospfd=yes/' /etc/frr/daemons
systemctl enable --now frr
sleep 2

vtysh << 'VTYSH_EOF'
configure terminal
router ospf
  passive-interface default
  network {{gre_net}} area {{ospf_area}}
  network {{br_lan_net}} area {{ospf_area}}
  area {{ospf_area}} authentication
exit
interface gre1
  no ip ospf passive
  ip ospf authentication-key {{ospf_key}}
exit
do write
end
VTYSH_EOF
6.5Проверить соседство OSPF
bash
# На любом из роутеров
vtysh -c "show ip ospf neighbor"

# Должен появиться сосед со статусом Full:
#   Neighbor ID  Pri  State     Dead Time  Address     Interface
#   10.10.10.X   1    Full/DR   00:00:39   10.10.10.Y  gre1:10.10.10.Z
Теперь с HQ-SRV должен пинговаться BR-SRV (через туннель) — после настройки самих серверов.
7

7. DHCP на HQ-RTR

HQ-RTR

HQ-CLI получает адрес по DHCP из сети VLAN 200. Адрес самого HQ-RTR ({{hqrtr_cli_ip}}) исключаем из пула. В DHCP-опциях передаём шлюз, DNS (HQ-SRV) и domain-suffix.

7.1Конфиг dhcpd.conf
bash
# На HQ-RTR
cat > /etc/dhcp/dhcpd.conf << EOF
ddns-update-style none;

subnet 192.168.200.0 netmask 255.255.255.240 {
    option routers                  {{hqrtr_cli_ip}};
    option subnet-mask              255.255.255.240;
    option domain-name-servers      {{hqsrv_ip}};
    option domain-name              "{{domain}}";
    range                           {{dhcp_start}} {{dhcp_end}};
    default-lease-time              21600;
    max-lease-time                  43200;
}
EOF
7.2Привязать DHCP к VLAN-интерфейсу
DHCP должен слушать только на {{hqrtr_vlan_iface}}.200, а не на всех интерфейсах.
bash
echo 'DHCPDARGS={{hqrtr_vlan_iface}}.200' > /etc/sysconfig/dhcpd
7.3Автозапуск и старт
bash
systemctl enable --now dhcpd
systemctl status dhcpd | head -10

# Проверка конфига
dhcpd -t -cf /etc/dhcp/dhcpd.conf
8

8. HQ-SRV — DNS-сервер HQ

HQ-SRV · ALT Server

HQ-SRV — сервер в VLAN 100. Выполняет роль DNS (BIND) для домена {{domain}}, разрешает прямые и обратные имена.

8.0⚠ Перед запуском VM — проверить VLAN Tag = 100 в Proxmox
Проверь что на сетевом адаптере HQ-SRV в Proxmox установлен VLAN Tag = 100. Если VM уже запущена без тега — выключи её, поставь тег, запусти снова. Внутри гостя НЕ создаём ens19.100 — тег ставит гипервизор.
8.1Hostname
bash
hostnamectl set-hostname hq-srv.{{domain}}
8.2Сеть — простой ens19 со статикой (тег ставит Proxmox)
bash
mkdir -p /etc/net/ifaces/ens19
cat > /etc/net/ifaces/ens19/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{hqsrv_ip}}/27" > /etc/net/ifaces/ens19/ipv4address
echo "default via {{hqrtr_srv_ip}}" > /etc/net/ifaces/ens19/ipv4route

# Пока DNS смотрит на yandex (чтобы скачать пакеты)
cat > /etc/resolv.conf << 'EOF'
nameserver {{dns_fwd}}
EOF

systemctl restart network && sleep 2
ping -c 2 {{hqrtr_srv_ip}}  # шлюз должен отвечать
ping -c 2 77.88.8.8         # интернет тоже
8.3Часовой пояс
bash
timedatectl set-timezone {{timezone}}
8.4Установить BIND
bash
apt-get update
apt-get install -y bind bind-utils sudo nano tzdata
8.5Настроить BIND options (forwarders)
bash
cat > /var/lib/bind/etc/options.conf << 'EOF'
options {
    version "unknown";
    directory "/etc/bind/zone";
    listen-on { any; };
    listen-on-v6 { none; };
    recursion yes;
    allow-recursion { any; };
    forwarders { {{dns_fwd}}; };
    allow-query { any; };
};
EOF
8.6Объявить зоны (прямая + две обратные)
bash
cat >> /var/lib/bind/etc/rfc1912.conf << 'EOF'
zone "{{domain}}" { type master; file "{{domain}}"; };
zone "100.168.192.in-addr.arpa" { type master; file "100.168.192.in-addr.arpa"; };
zone "200.168.192.in-addr.arpa" { type master; file "200.168.192.in-addr.arpa"; };
EOF

# Копируем пустой шаблон как основу
cp /var/lib/bind/etc/zone/empty /var/lib/bind/etc/zone/{{domain}}
cp /var/lib/bind/etc/zone/empty /var/lib/bind/etc/zone/100.168.192.in-addr.arpa
cp /var/lib/bind/etc/zone/empty /var/lib/bind/etc/zone/200.168.192.in-addr.arpa
8.7Прямая зона {{domain}} (A-записи)
bash
cat > /var/lib/bind/etc/zone/{{domain}} << 'EOF'
$TTL 1D
@ IN SOA  {{domain}}. root.{{domain}}. ( 2026042100 12H 1H 1W 1H )
  IN NS   {{domain}}.
  IN A    {{hqsrv_ip}}

hq-srv   IN A  {{hqsrv_ip}}
hq-cli   IN A  {{dhcp_start}}
hq-rtr   IN A  {{hqrtr_srv_ip}}
hq-rtr   IN A  {{hqrtr_cli_ip}}
hq-rtr   IN A  {{hqrtr_mgmt_ip}}
br-rtr   IN A  {{brrtr_lan_ip}}
br-srv   IN A  {{brsrv_ip}}
isp      IN A  {{isp_hq_ip}}
EOF
8.8Обратные зоны (PTR)
bash
cat > /var/lib/bind/etc/zone/100.168.192.in-addr.arpa << 'EOF'
$TTL 1D
@ IN SOA {{domain}}. root.{{domain}}. ( 2026042100 12H 1H 1W 1H )
  IN NS  {{domain}}.
1 IN PTR hq-rtr.{{domain}}.
2 IN PTR hq-srv.{{domain}}.
EOF

cat > /var/lib/bind/etc/zone/200.168.192.in-addr.arpa << 'EOF'
$TTL 1D
@ IN SOA {{domain}}. root.{{domain}}. ( 2026042100 12H 1H 1W 1H )
  IN NS  {{domain}}.
1 IN PTR hq-rtr.{{domain}}.
2 IN PTR hq-cli.{{domain}}.
EOF
8.9Права, проверка, запуск
bash
rndc-confgen > /var/lib/bind/etc/rndc.key && sed -i '6,$d' /var/lib/bind/etc/rndc.key
chown -R root:named /var/lib/bind/etc/zone/*

named-checkconf && named-checkconf -z   # синтаксис + зоны
systemctl enable --now bind

# Переключаем resolv.conf на себя
cat > /etc/resolv.conf << EOF
search {{domain}}
nameserver {{hqsrv_ip}}
EOF

# Проверка
host hq-rtr.{{domain}}
host {{hqsrv_ip}}
8.10Создать sshuser (UID {{sshuser_uid}}, sudo без пароля)
bash
useradd -m -u {{sshuser_uid}} {{sshuser_name}}
echo '{{sshuser_name}}:{{sshuser_pass}}' | chpasswd
usermod -a -G wheel {{sshuser_name}}

sed -i 's/^#\s*WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/' /etc/sudoers

id {{sshuser_name}}  # UID должен быть {{sshuser_uid}}
8.11SSH — порт {{ssh_port}}, AllowUsers, баннер, MaxAuthTries
bash
# Порт
sed -i 's/^#\?Port .*/Port {{ssh_port}}/' /etc/openssh/sshd_config
grep -q "^Port {{ssh_port}}" /etc/openssh/sshd_config || echo "Port {{ssh_port}}" >> /etc/openssh/sshd_config

# Только sshuser
grep -q "^AllowUsers" /etc/openssh/sshd_config || echo "AllowUsers {{sshuser_name}}" >> /etc/openssh/sshd_config

# Максимум 2 попытки
grep -q "^MaxAuthTries" /etc/openssh/sshd_config || echo "MaxAuthTries {{ssh_max_tries}}" >> /etc/openssh/sshd_config

# Баннер
echo "{{ssh_banner}}" > /etc/openssh/banner
grep -q "^Banner" /etc/openssh/sshd_config || echo "Banner /etc/openssh/banner" >> /etc/openssh/sshd_config

systemctl restart sshd
ss -tlnp | grep {{ssh_port}}  # sshd должен слушать на {{ssh_port}}
9

9. BR-SRV — сервер BR

BR-SRV · ALT Server

BR-SRV — сервер филиала. Без VLAN (подключён напрямую в LAN BR), DNS берёт с HQ-SRV через GRE-туннель.

9.1Hostname
bash
hostnamectl set-hostname br-srv.{{domain}}
9.2Сеть — прямой LAN BR
bash
mkdir -p /etc/net/ifaces/ens19
cat > /etc/net/ifaces/ens19/options << 'EOF'
TYPE=eth
BOOTPROTO=static
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

echo "{{brsrv_ip}}/28" > /etc/net/ifaces/ens19/ipv4address
echo "default via {{brrtr_lan_ip}}" > /etc/net/ifaces/ens19/ipv4route

cat > /etc/resolv.conf << EOF
search {{domain}}
nameserver {{hqsrv_ip}}
EOF

systemctl restart network && sleep 2
ping -c 2 {{brrtr_lan_ip}}
9.3Часовой пояс + пакеты
bash
timedatectl set-timezone {{timezone}}
apt-get update
apt-get install -y sudo nano tzdata
9.4sshuser + sudo + SSH-конфиг
bash
useradd -m -u {{sshuser_uid}} {{sshuser_name}}
echo '{{sshuser_name}}:{{sshuser_pass}}' | chpasswd
usermod -a -G wheel {{sshuser_name}}
sed -i 's/^#\s*WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/WHEEL_USERS ALL=(ALL:ALL) NOPASSWD: ALL/' /etc/sudoers

# SSH
sed -i 's/^#\?Port .*/Port {{ssh_port}}/' /etc/openssh/sshd_config
grep -q "^Port {{ssh_port}}" /etc/openssh/sshd_config || echo "Port {{ssh_port}}" >> /etc/openssh/sshd_config
grep -q "^AllowUsers" /etc/openssh/sshd_config || echo "AllowUsers {{sshuser_name}}" >> /etc/openssh/sshd_config
grep -q "^MaxAuthTries" /etc/openssh/sshd_config || echo "MaxAuthTries {{ssh_max_tries}}" >> /etc/openssh/sshd_config
echo "{{ssh_banner}}" > /etc/openssh/banner
grep -q "^Banner" /etc/openssh/sshd_config || echo "Banner /etc/openssh/banner" >> /etc/openssh/sshd_config

systemctl restart sshd
10

10. HQ-CLI — клиент HQ

HQ-CLI · ALT Workstation

HQ-CLI — рабочая станция в VLAN 200. Получает адрес по DHCP от HQ-RTR. VLAN-тег ставит Proxmox — внутри гостя просто DHCP на ens19.

10.0⚠ Перед запуском VM — VLAN Tag = 200 в Proxmox
Проверь что на сетевом адаптере HQ-CLI в Proxmox стоит VLAN Tag = 200. Без этого DHCP не придёт.
10.1Hostname
bash
hostnamectl set-hostname hq-cli.{{domain}}
10.2Сеть — DHCP (тег 200 ставит Proxmox)
bash
mkdir -p /etc/net/ifaces/ens19
cat > /etc/net/ifaces/ens19/options << 'EOF'
TYPE=eth
BOOTPROTO=dhcp
CONFIG_IPV4=yes
DISABLED=no
NM_CONTROLLED=no
SYSTEMD_CONTROLLED=no
EOF

systemctl restart network && sleep 4
ip -br a show ens19        # должен получить адрес из {{hq_cli_net}}
cat /etc/resolv.conf       # DHCP должен прописать DNS {{hqsrv_ip}}
10.3Часовой пояс
bash
timedatectl set-timezone {{timezone}}
10.4Проверка разрешения имён
bash
host hq-srv.{{domain}}     # должен резолвить в {{hqsrv_ip}}
ping -c 2 hq-srv.{{domain}}
ping -c 2 br-srv.{{domain}}  # через GRE-туннель
Если имена резолвятся и ping идёт — DHCP+DNS+OSPF+GRE работают полным циклом.
11

11. Проверка всей инфраструктуры

ВСЯ СЕТЬ

Финальные проверки — что вся инфраструктура работает согласованно.

11.1OSPF — соседство установлено
bash
# На HQ-RTR или BR-RTR
vtysh -c "show ip ospf neighbor"
# Должно быть состояние Full

vtysh -c "show ip ospf route"
# Должны быть маршруты в сети противоположного офиса
11.2GRE-туннель поднят и пингуется
bash
# HQ-RTR → BR-RTR через туннель
ping -c 3 {{gre_br_ip}}

# BR-RTR → HQ-RTR
ping -c 3 {{gre_hq_ip}}
11.3DHCP работает
bash
# На HQ-RTR
systemctl status dhcpd
cat /var/lib/dhcp/dhcpd/state/dhcpd.leases | tail -30

# На HQ-CLI
ip -br a show ens19  # должен быть адрес {{dhcp_start}} или близкий
11.4DNS — прямое и обратное разрешение
bash
# Откуда угодно в сети
host hq-srv.{{domain}}        # → {{hqsrv_ip}}
host {{hqsrv_ip}}             # → hq-srv.{{domain}}
host br-srv.{{domain}}        # → {{brsrv_ip}}
host hq-cli.{{domain}}        # → {{dhcp_start}}
11.5SSH — только sshuser на порту {{ssh_port}}
bash
# С любой машины в сети → HQ-SRV
ssh -p {{ssh_port}} {{sshuser_name}}@{{hqsrv_ip}}

# Должен показаться баннер "{{ssh_banner}}"
# и запрос пароля
11.6Интернет из офисов
bash
# С HQ-SRV, HQ-CLI, BR-SRV
ping -c 2 77.88.8.8

# Все запросы должны уходить через NAT (ISP) и возвращаться
🎉Модуль 1 готов. Теперь можно переходить к Модулю 2 (Samba DC, RAID, NFS, Chrony, Ansible, Docker, Web).