Модуль 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
Настройте доступ к сети Интернет на маршрутизаторе ISP
- Настройте адресацию на интерфейсах:
- Интерфейс, подключенный к магистральному провайдеру, получает адрес по DHCP
- Настройте маршрут по умолчанию, если это необходимо
- Настройте интерфейс в сторону HQ-RTR, интерфейс подключен к сети 172.16.1.0/28
- Настройте интерфейс в сторону BR-RTR, интерфейс подключен к сети 172.16.2.0/28
- На ISP настройте динамическую сетевую трансляцию портов для доступа к сети Интернет HQ-RTR и BR-RTR
- 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
Настройте коммутацию в сегменте HQ
- Трафик HQ-SRV должен принадлежать VLAN 100
- Трафик HQ-CLI должен принадлежать VLAN 200
- Предусмотреть возможность передачи трафика управления в VLAN 999
- Реализовать на HQ-RTR маршрутизацию трафика всех указанных VLAN с использованием одного сетевого адаптера ВМ/физического порта
- Сведения о настройке коммутации внесите в отчёт
- 5
Настройте безопасный удалённый доступ на серверах HQ-SRV и BR-SRV
- Для подключения используйте порт 2026
- Разрешите подключения исключительно пользователю sshuser
- Ограничьте количество попыток входа до двух
- Настройте баннер «Authorized access only»
- 6
IP-туннель между офисами HQ и BR
- На маршрутизаторах HQ-RTR и BR-RTR необходимо сконфигурировать ip-туннель
- На выбор технологии: GRE или IP in IP
- Сведения о туннеле занесите в отчёт
- 7
Обеспечьте динамическую маршрутизацию
- На маршрутизаторах HQ-RTR и BR-RTR: сети одного офиса должны быть доступны из другого
- Используйте link state протокол на усмотрение участника (OSPF / IS-IS)
- Разрешите выбранный протокол только на интерфейсах ip-туннеля
- Маршрутизаторы должны делиться маршрутами только друг с другом
- Обеспечьте защиту выбранного протокола посредством парольной защиты
- Сведения о настройке и защите протокола занесите в отчёт
- 8
Динамическая трансляция адресов
- Настройте динамическую трансляцию адресов на HQ-RTR и BR-RTR для обоих офисов в сторону ISP
- Все устройства в офисах должны иметь доступ к сети Интернет
- 9
DHCP для сети в сторону HQ-CLI
- Настройте нужную подсеть
- В качестве сервера DHCP выступает маршрутизатор HQ-RTR
- Клиентом является машина HQ-CLI
- Исключите из выдачи адрес маршрутизатора
- Адрес шлюза по умолчанию — адрес маршрутизатора HQ-RTR
- Адрес DNS-сервера для машины HQ-CLI — адрес сервера HQ-SRV
- DNS-суффикс — au-team.irpo
- Сведения о настройке протокола занесите в отчёт
- 10
Инфраструктура разрешения доменных имён
- Основной DNS-сервер реализован на HQ-SRV
- Сервер должен обеспечивать разрешение имён в сетевые адреса устройств и обратно в соответствии с таблицей 3
- В качестве DNS сервера пересылки используйте любой общедоступный DNS сервер (77.88.8.7, 77.88.8.3 или другие)
- 11
Настройте часовой пояс
- На всех устройствах (за исключением виртуального коммутатора, в случае его использования) согласно месту проведения экзамена
Схема — Рисунок 1
au-team.irpo.Автоматизация настройки
setup.sh. Генераторы встроены в эту страницу — работают даже без доступа к серверу.Развёртывание и проверка
deployer_m3.py
#!/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
--all, --debug.#!/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()
Критерии оценки · Модуль 1
Модуль 1. Выполнение работ по проектированию сетевой инфраструктуры
26.00балл.- Участие в приёмо-сдаточных испытаниях компьютерных сетей и сетевого оборудования различного уровня и в оценке качества и экономической эффективности сетевой топологии1.00
- Осуществление выбора технологии, инструментальных средств и средств вычислительной техники при организации процесса разработки и исследования объектов профессиональной деятельности14.00
- Выполнение проектирования кабельной структуры компьютерной сети7.00
- Обеспечение защиты информации в сети с использованием программно-аппаратных средств2.00
- Осуществление поиска, анализа и интерпретации информации, необходимой для выполнения задач профессиональной деятельности2.00
Инструкция · Модуль 1
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
📍 Содержание — 11 шагов
Подготовка гипервизора
PROXMOXПеред установкой виртуальных машин подготовим Proxmox: включим поддержку VLAN на бридже и повесим VLAN-теги на сетевые адаптеры VM, где это нужно по заданию. Это нужно сделать до первого запуска VM.
Datacenter → Node → Network → vmbr0 (или тот бридж, который используют VM). Нажмите Edit, установите галочку «VLAN aware», примените (Apply Configuration). Без неё VLAN-теги внутри VM не будут работать.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# На хосте Proxmox — проверяем загружен ли модуль 8021q lsmod | grep 8021q # Если пусто — загружаем: modprobe 8021q echo "8021q" >> /etc/modules # Проверяем что бридж действительно VLAN-aware: cat /sys/class/net/vmbr0/bridge/vlan_filtering # Должно быть: 1
1. ISP — маршрутизатор провайдера
ISP · ALT JeOSISP — маршрутизатор провайдера. Раздаёт интернет в офисы HQ и BR через NAT. 3 интерфейса: WAN (DHCP от аплинка), канал к HQ-RTR, канал к BR-RTR.
hostnamectl set-hostname isp.{{domain}}{{isp_wan_iface}} получает адрес автоматически. Настраиваем через etcnet (стандарт ALT):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 "Интернет работает"timedatectl set-timezone {{timezone}}
timedatectl # проверить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}}/ipv4addressmkdir -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# Включаем 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=1apt-get update apt-get install -y iptables
# Чистим на всякий случай
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 -vmkdir -p /etc/sysconfig iptables-save > /etc/sysconfig/iptables systemctl enable --now iptables
2. HQ-RTR — маршрутизатор HQ
HQ-RTR · ALT JeOSHQ-RTR — роутер головного офиса. WAN к ISP, trunk-порт к виртуальному свитчу с тремя VLAN: 100 (серверы), 200 (клиенты), 999 (управление). VLAN настраиваем внутри ALT через sub-interfaces (ens19.100, ens19.200, ens19.999).
hostnamectl set-hostname hq-rtr.{{domain}}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}}/ipv4routecat > /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 работает"{{hqrtr_vlan_iface}} живёт trunk. Сам он без IP — IP будут у его VLAN-подынтерфейсов.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
EOFmkdir -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/ipv4addressmkdir -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/ipv4addressmkdir -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-интерфейса должны быть UPip -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}} должен отвечать.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=1apt-get update apt-get install -y iptables frr dhcp-server sudo nano tzdata
timedatectl set-timezone {{timezone}}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' # должно вернуть: root3. BR-RTR — маршрутизатор BR
BR-RTR · ALT JeOSBR-RTR — роутер филиала. Проще чем HQ-RTR: без VLAN, только WAN к ISP и LAN к BR-SRV.
hostnamectl set-hostname br-rtr.{{domain}}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}}/ipv4routemkdir -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.8grep -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=1apt-get update apt-get install -y iptables frr sudo nano tzdata
timedatectl set-timezone {{timezone}}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/sudoers4. NAT на HQ-RTR
HQ-RTRВсе внутренние сети HQ (VLAN 100/200/999) должны ходить в интернет через WAN с подменой адреса.
# Чистим
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 -vmkdir -p /etc/sysconfig iptables-save > /etc/sysconfig/iptables systemctl enable --now iptables
5. NAT на BR-RTR
BR-RTRАналогично HQ, но только для одной сети (LAN).
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 -vmkdir -p /etc/sysconfig iptables-save > /etc/sysconfig/iptables systemctl enable --now iptables
6. GRE-туннель и OSPF
HQ-RTR + BR-RTRПоднимаем GRE-туннель между HQ-RTR и BR-RTR, поверх него — OSPF с парольной защитой. Оба роутера делятся маршрутами только друг с другом.
# Выполняется на 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 gre1sed -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# Выполняется на 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 2sed -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# На любом из роутеров 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
7. DHCP на HQ-RTR
HQ-RTRHQ-CLI получает адрес по DHCP из сети VLAN 200. Адрес самого HQ-RTR ({{hqrtr_cli_ip}}) исключаем из пула. В DHCP-опциях передаём шлюз, DNS (HQ-SRV) и domain-suffix.
# На 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{{hqrtr_vlan_iface}}.200, а не на всех интерфейсах.echo 'DHCPDARGS={{hqrtr_vlan_iface}}.200' > /etc/sysconfig/dhcpdsystemctl enable --now dhcpd systemctl status dhcpd | head -10 # Проверка конфига dhcpd -t -cf /etc/dhcp/dhcpd.conf
8. HQ-SRV — DNS-сервер HQ
HQ-SRV · ALT ServerHQ-SRV — сервер в VLAN 100. Выполняет роль DNS (BIND) для домена {{domain}}, разрешает прямые и обратные имена.
ens19.100 — тег ставит гипервизор.hostnamectl set-hostname hq-srv.{{domain}}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 # интернет тожеtimedatectl set-timezone {{timezone}}apt-get update apt-get install -y bind bind-utils sudo nano tzdata
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; };
};
EOFcat >> /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.arpacat > /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}}
EOFcat > /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}}.
EOFrndc-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}}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}}# Порт
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. BR-SRV — сервер BR
BR-SRV · ALT ServerBR-SRV — сервер филиала. Без VLAN (подключён напрямую в LAN BR), DNS берёт с HQ-SRV через GRE-туннель.
hostnamectl set-hostname br-srv.{{domain}}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}}timedatectl set-timezone {{timezone}}
apt-get update
apt-get install -y sudo nano tzdatauseradd -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 sshd10. HQ-CLI — клиент HQ
HQ-CLI · ALT WorkstationHQ-CLI — рабочая станция в VLAN 200. Получает адрес по DHCP от HQ-RTR. VLAN-тег ставит Proxmox — внутри гостя просто DHCP на ens19.
hostnamectl set-hostname hq-cli.{{domain}}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}}timedatectl set-timezone {{timezone}}host hq-srv.{{domain}} # должен резолвить в {{hqsrv_ip}}
ping -c 2 hq-srv.{{domain}}
ping -c 2 br-srv.{{domain}} # через GRE-туннель11. Проверка всей инфраструктуры
ВСЯ СЕТЬФинальные проверки — что вся инфраструктура работает согласованно.
# На HQ-RTR или BR-RTR vtysh -c "show ip ospf neighbor" # Должно быть состояние Full vtysh -c "show ip ospf route" # Должны быть маршруты в сети противоположного офиса
# HQ-RTR → BR-RTR через туннель
ping -c 3 {{gre_br_ip}}
# BR-RTR → HQ-RTR
ping -c 3 {{gre_hq_ip}}# На HQ-RTR
systemctl status dhcpd
cat /var/lib/dhcp/dhcpd/state/dhcpd.leases | tail -30
# На HQ-CLI
ip -br a show ens19 # должен быть адрес {{dhcp_start}} или близкий# Откуда угодно в сети
host hq-srv.{{domain}} # → {{hqsrv_ip}}
host {{hqsrv_ip}} # → hq-srv.{{domain}}
host br-srv.{{domain}} # → {{brsrv_ip}}
host hq-cli.{{domain}} # → {{dhcp_start}}# С любой машины в сети → HQ-SRV
ssh -p {{ssh_port}} {{sshuser_name}}@{{hqsrv_ip}}
# Должен показаться баннер "{{ssh_banner}}"
# и запрос пароля# С HQ-SRV, HQ-CLI, BR-SRV ping -c 2 77.88.8.8 # Все запросы должны уходить через NAT (ISP) и возвращаться