issayev.kz
RU EN

Скрипты и ПО

bash4
bashфайлов: 14
Proxmox VE Cluster Autoconfig
Комплект идемпотентных bash-скриптов для автоматической сборки production-кластера Proxmox VE 8/9 с выбором модели хранилища (Ceph / FC-hybrid) и программно-определяемой сетью (SDN), построенной по образцу VMware vSphere Distributed Switch (vDS).
Показать код
#!/usr/bin/env bash
# deploy-all.sh — оркестратор полного развёртывания. Выполняет этапы в
# правильном порядке с учётом роли узла и DEPLOY_MODE. Идемпотентен —
# безопасно перезапускать. Поддерживает выборочный запуск (--only) и
# продолжение с этапа (--from).
#
# Порядок фаз:
#   phase-local  (на КАЖДОМ узле): 00-preflight, 10-network, 40-fc(multipath)
#   phase-init   (на ИНИЦИАТОРЕ):  20-cluster
#   [join]       (на остальных):   20-cluster
#   phase-cluster(на ИНИЦИАТОРЕ):  30-ceph, 40-fc(vg/storage), 50-sdn, 60-ha
#
# Рекомендуемый сценарий:
#   1) на каждом узле:      ./deploy-all.sh --phase local
#   2) на инициаторе:       ./deploy-all.sh --phase init
#   3) на остальных узлах:  ./deploy-all.sh --phase join
#   4) на инициаторе:       ./deploy-all.sh --phase cluster
# Либо всё сразу на инициаторе с настроенным SSH: ./deploy-all.sh --phase all

set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/common.sh"

CONFIG_FILE="${CONFIG_FILE:-${SCRIPT_DIR}/../config/cluster.conf}"
PHASE="all"

usage() {
    cat <<EOF
Использование: $0 [--config FILE] [--phase local|init|join|cluster|all]
                  [--dry-run] [--yes]

  --config FILE   путь к cluster.conf (по умолчанию config/cluster.conf)
  --phase         какую фазу выполнить (см. заголовок скрипта)
  --dry-run       только печатать команды (экспорт DRY_RUN=1)
  --yes           не запрашивать подтверждений (экспорт ASSUME_YES=1)
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --config) CONFIG_FILE="$2"; shift 2 ;;
        --phase)  PHASE="$2"; shift 2 ;;
        --dry-run) export DRY_RUN=1; shift ;;
        --yes)     export ASSUME_YES=1; shift ;;
        -h|--help) usage; exit 0 ;;
        *) die "Неизвестный аргумент: $1" ;;
    esac
done

require_root
load_config "$CONFIG_FILE"
export CONFIG_FILE

run_step() {
    local script="$1"
    log "═══ Этап: ${script} ═══"
    bash "${SCRIPT_DIR}/${script}" "$CONFIG_FILE"
    ok "═══ Завершён: ${script} ═══"
}

phase_local() {
    run_step 00-preflight.sh
    run_step 10-network.sh
    # multipath-часть 40 запускается на каждом узле (VG/storage внутри скрипта
    # выполняется только на инициаторе — он сам это определяет).
    if [[ "$DEPLOY_MODE" == "fc" || "$DEPLOY_MODE" == "hybrid" ]]; then
        run_step 40-fc-storage.sh
    fi
}

phase_init()    { run_step 20-cluster.sh; }
phase_join()    { run_step 20-cluster.sh; }

phase_cluster() {
    [[ "$DEPLOY_MODE" == "ceph" || "$DEPLOY_MODE" == "hybrid" ]] && run_step 30-ceph.sh
    [[ "$DEPLOY_MODE" == "fc"   || "$DEPLOY_MODE" == "hybrid" ]] && run_step 40-fc-storage.sh
    run_step 50-sdn.sh
    run_step 60-ha.sh
}

case "$PHASE" in
    local)   phase_local ;;
    init)    phase_init ;;
    join)    phase_join ;;
    cluster) phase_cluster ;;
    all)
        warn "Фаза 'all' предполагает настроенный SSH и запуск с инициатора."
        phase_local
        phase_init
        phase_cluster
        warn "НЕ забудьте выполнить фазы 'local' и 'join' на остальных узлах!"
        ;;
    *) die "Неизвестная фаза: $PHASE" ;;
esac

ok "Оркестрация фазы '${PHASE}' завершена."
Файлы комплектаcommon.sh bash00-preflight.sh bash10-network.sh bash20-cluster.sh bash30-ceph.sh bash40-fc-storage.sh bash50-sdn.sh bash60-ha.sh bashadd-ha-vm.sh bashteardown.sh bashvalidate.sh bashsdn-model.reference.yaml yamlcluster.conf.example
Proxmox, bash
bashфайлов: 16
Пошаговое развёртывание: Samba AD + Postfix/Dovecot/LDAP + PMG + EAS на двух площадках
Домен AD: ad.example.com (NetBIOS EXAMPLE) · Почта: example.com ОС всех узлов: Debian 12 · Топология: Active-Active, по узлу каждой роли на площадку Периметр: PMG1/PMG2 (релей + DKIM/DMARC) · EAS: grommunio-sync · Бэкенд почты: Postfix+Dovecot, LDAP-аутентификация в AD GUI (всё FOSS): LAM Community (каталог) · Roundcube (веб-почта) · PMG web UI (антиспам/очереди)
Показать код
#!/usr/bin/env bash
# 00-base.sh — базовая подготовка узла (все 6 узлов: dc1,dc2,mx1,mx2,pmg1,pmg2)
# Идемпотентен. Запуск: sudo FQDN=dc1.ad.example.com IP=10.10.10.10 ./00-base.sh
#   опц.: TZ_NAME=UTC

source "$(dirname "$0")/lib.sh"
require_root
require_env FQDN
require_env IP
TZ_NAME="${TZ_NAME:-UTC}"

valid_fqdn "$FQDN" || die "FQDN некорректен: $FQDN"
valid_ipv4 "$IP"   || die "IP некорректен: $IP"

info "Узел: ${FQDN} (${IP}), TZ=${TZ_NAME}"

# ── время ──
timedatectl set-timezone "$TZ_NAME"
hostnamectl set-hostname "$FQDN"

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get -y -qq dist-upgrade
apt-get -y -qq install chrony curl gnupg ca-certificates dnsutils \
    rsync vim less acl attr ufw net-tools

systemctl enable --now chrony
chronyc -a makestep >/dev/null 2>&1 || warn "chronyc makestep отложен (нет источника?)"

# ── /etc/hosts: FQDN → статический IP, без 127.0.1.1 ──
SHORT="${FQDN%%.*}"
backup_once /etc/hosts
sed -i '/127\.0\.1\.1/d' /etc/hosts
ensure_line "${IP}  ${FQDN} ${SHORT}" /etc/hosts

ok "Базовая подготовка ${FQDN} завершена"
Файлы комплекта10-dc1-provision.sh bash20-dc2-join.sh bash30-directory-objects.sh bash40-mail-install.sh bash50-eas-install.sh bash60-pmg-relay.sh bash70-firewall-mx.sh bash80-lam-install.sh bash85-sysvol-sync.sh bash90-roundcube-install.sh bash95-domain-rebuild.sh bashlib.sh bashautodiscover.php phpexample.com.zoneconfig-v1.1.xml
bash
Angie Reverse Proxy для 1С (IIS)
Автоматизированное развёртывание Angie в роли защищённого reverse proxy для публикации 1С на IIS наружу через HTTPS, с веб-мониторингом (Angie Console Light) и защитой от типовых веб-атак и L7-DDoS.
Показать код
#!/usr/bin/env bash
#===============================================================================
# deploy-angie-rp.sh  (v2 — публикация 1С/IIS + Console Light мониторинг)
#
#   Внешний вход:  TCP/443 (TLS 1.2/1.3), server_name = FQDN
#   Проксирование: на upstream IP:PORT публикации 1С в IIS (по умолчанию 5743)
#   Мониторинг:    Angie Console Light на ОТДЕЛЬНОМ внутреннем server-блоке
#                  (порт по умолчанию 8443, доступ только из доверенных сетей)
#   Защита:        TLS hardening, security headers, L7 rate/conn-limit,
#                  anti-slowloris, ACL, опционально ModSecurity+CRS.
#
# Специфика 1С/IIS (аутентификация анонимная/внутри 1С):
#   - WebSocket-апгрейд для тонкого клиента (map Connection)
#   - длинные таймауты (отчёты/проведение/обмен) -> 1800s
#   - большие тела (внешние обработки/обмен) -> client_max_body_size 512m
#   - раздельные rate-зоны: щедрая для веб-клиента, отдельная для веб-сервисов
#   - сохранение пути публикации (/base) и корректный proxy_redirect
#
# Поддерживаемые ОС: Debian 11/12, Ubuntu 22.04/24.04, RHEL/Rocky/Alma 8/9.
# Идемпотентность: повторный запуск безопасен (бэкап конфигов, перегенерация).
#===============================================================================

set -Eeuo pipefail

#------------------------------- Константы -------------------------------------
readonly SCRIPT_NAME="${0##*/}"
readonly LOG_FILE="/var/log/deploy-angie-rp.log"
readonly ANGIE_CONF_DIR="/etc/angie"
readonly SITE_CONF="${ANGIE_CONF_DIR}/http.d/reverse-proxy.conf"
readonly SEC_CONF="${ANGIE_CONF_DIR}/http.d/00-security.conf"
readonly CONSOLE_CONF="${ANGIE_CONF_DIR}/http.d/10-console.conf"
readonly TLS_DIR="${ANGIE_CONF_DIR}/tls"
readonly CONSOLE_ROOT="/usr/share/angie-console-light/html"
readonly BACKUP_DIR="${ANGIE_CONF_DIR}/backup-$(date +%Y%m%d-%H%M%S)"

ENABLE_MODSEC="${ENABLE_MODSEC:-ask}"
ENABLE_CONSOLE="${ENABLE_CONSOLE:-ask}"
UPDATE_SYSTEM="${UPDATE_SYSTEM:-ask}"     # yes|no|ask — обновить пакеты ОС перед установкой
MANAGE_BASE_REPOS="${MANAGE_BASE_REPOS:-yes}"  # yes|no — добавлять недостающие базовые репозитории ОС
RATE_RPS="${RATE_RPS:-50}"
RATE_BURST="${RATE_BURST:-100}"
CONN_PER_IP="${CONN_PER_IP:-50}"
CONSOLE_PORT="${CONSOLE_PORT:-8443}"
NONINTERACTIVE="${NONINTERACTIVE:-0}"

FQDN=""
UPSTREAM_IP=""
UPSTREAM_PORT=""
UPSTREAM_SCHEME="http"
ONEC_BASE=""
WS_PATHS="ws hs"                          # пути веб-сервисов 1С (через пробел): ws hs [api ...]
TRUSTED_NETS=""
CONSOLE_USER=""
CONSOLE_PASS=""

#------------------------------- Утилиты ---------------------------------------
log()  { echo -e "[$(date +'%F %T')] $*" | tee -a "$LOG_FILE" >&2; }
info() { log "\033[0;34m[INFO]\033[0m  $*"; }
ok()   { log "\033[0;32m[ OK ]\033[0m  $*"; }
warn() { log "\033[0;33m[WARN]\033[0m  $*"; }
err()  { log "\033[0;31m[FAIL]\033[0m  $*"; }
die() { err "$*"; exit 1; }

on_err() { local ec=$?; err "Ошибка на строке ${BASH_LINENO[0]} (код $ec). Смотрите $LOG_FILE"; exit "$ec"; }
trap on_err ERR

require_root() { [[ ${EUID} -eq 0 ]] || die "Запускать от root (sudo $SCRIPT_NAME)."; }

#------------------------------- Валидаторы ------------------------------------
is_valid_fqdn() {
  local f="$1"
  [[ ${#f} -le 253 ]] || return 1
  [[ "$f" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z]{2,63}|xn--[a-zA-Z0-9-]{2,59}[a-zA-Z0-9])$ ]]
}
is_valid_ipv4() {
  local ip="$1" o
  [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
  IFS='.' read -r -a oct <<< "$ip"
  for o in "${oct[@]}"; do
    (( o >= 0 && o <= 255 )) || return 1
    [[ "$o" == "0" || "$o" != 0* ]] || return 1
  done
  return 0
}
is_valid_port() { local p="$1"; [[ "$p" =~ ^[0-9]+$ ]] || return 1; (( p >= 1 && p <= 65535 )); }
is_valid_cidr() {
  local c="$1" ip mask
  if [[ "$c" == */* ]]; then
    ip="${c%/*}"; mask="${c#*/}"
    [[ "$mask" =~ ^[0-9]+$ ]] || return 1
    (( mask >= 0 && mask <= 32 )) || return 1
  else ip="$c"; fi
  is_valid_ipv4 "$ip"
}
is_valid_onec_base() {
  local b="$1"; [[ -z "$b" ]] && return 0
  [[ "$b" =~ ^(/[A-Za-z0-9._-]+)+$ ]]
}
# Список путей веб-сервисов через пробел: каждый сегмент из [A-Za-z0-9._-], хотя бы один.
is_valid_ws_paths() {
  local s="$1" p
  [[ -z "${s// /}" ]] && return 1
  for p in $s; do
    [[ "$p" =~ ^[A-Za-z0-9._-]+$ ]] || return 1
    [[ "$p" == -* ]] && return 1     # сегмент не должен начинаться с дефиса
  done
  return 0
}
# Собирает альтернацию для регекспа: "ws hs api" -> "ws|hs|api"
ws_paths_regex() {
  local s="$1" out="" p
  for p in $s; do out+="${out:+|}${p}"; done
  printf '%s' "$out"
}

#------------------------------- Ввод данных -----------------------------------
prompt_inputs() {
  if [[ "$NONINTERACTIVE" == "1" ]]; then
    FQDN="${FQDN:-${IN_FQDN:-}}"
    UPSTREAM_IP="${UPSTREAM_IP:-${IN_UPSTREAM_IP:-}}"
    UPSTREAM_PORT="${UPSTREAM_PORT:-${IN_UPSTREAM_PORT:-5743}}"
    UPSTREAM_SCHEME="${IN_UPSTREAM_SCHEME:-http}"
    ONEC_BASE="${IN_ONEC_BASE:-}"
    WS_PATHS="${IN_WS_PATHS:-ws hs}"
    TRUSTED_NETS="${IN_TRUSTED_NETS:-127.0.0.1}"
    [[ "$ENABLE_CONSOLE" == "ask" ]] && ENABLE_CONSOLE="${IN_ENABLE_CONSOLE:-yes}"
    [[ "$ENABLE_MODSEC" == "ask" ]] && ENABLE_MODSEC="${IN_ENABLE_MODSEC:-no}"
    is_valid_fqdn "$FQDN"          || die "NONINTERACTIVE: некорректный IN_FQDN='$FQDN'"
    is_valid_ipv4 "$UPSTREAM_IP"   || die "NONINTERACTIVE: некорректный IN_UPSTREAM_IP='$UPSTREAM_IP'"
    is_valid_port "$UPSTREAM_PORT" || die "NONINTERACTIVE: некорректный IN_UPSTREAM_PORT='$UPSTREAM_PORT'"
    is_valid_onec_base "$ONEC_BASE"|| die "NONINTERACTIVE: некорректный IN_ONEC_BASE='$ONEC_BASE'"
    is_valid_ws_paths "$WS_PATHS"  || die "NONINTERACTIVE: некорректный IN_WS_PATHS='$WS_PATHS'"
    return 0
  fi

  while :; do
    read -r -p "FQDN внешнего ресурса (напр. 1c.example.kz): " FQDN
    FQDN="${FQDN,,}"; FQDN="${FQDN#"${FQDN%%[![:space:]]*}"}"; FQDN="${FQDN%"${FQDN##*[![:space:]]}"}"
    is_valid_fqdn "$FQDN" && break
    warn "Некорректный FQDN. Пример: 1c.example.kz"
  done

  local sc
  read -r -p "Протокол до IIS-бэкенда [http/https] (по умолчанию http): " sc
  sc="${sc,,}"
  case "$sc" in
    https) UPSTREAM_SCHEME="https" ;;
    http|"") UPSTREAM_SCHEME="http" ;;
    *) warn "Неизвестно '$sc', использую http"; UPSTREAM_SCHEME="http" ;;
  esac

  while :; do
    read -r -p "IP-адрес сервера IIS с публикацией 1С: " UPSTREAM_IP
    UPSTREAM_IP="${UPSTREAM_IP//[[:space:]]/}"
    is_valid_ipv4 "$UPSTREAM_IP" && break
    warn "Некорректный IPv4. Пример: 10.20.30.40"
  done

  while :; do
    read -r -p "Порт публикации 1С на IIS (по умолчанию 5743): " UPSTREAM_PORT
    UPSTREAM_PORT="${UPSTREAM_PORT//[[:space:]]/}"
    [[ -z "$UPSTREAM_PORT" ]] && UPSTREAM_PORT="5743"
    is_valid_port "$UPSTREAM_PORT" && break
    warn "Некорректный порт (1-65535)."
  done

  while :; do
    read -r -p "Имя публикации 1С в IIS, напр. /accounting (Enter = корень /): " ONEC_BASE
    ONEC_BASE="${ONEC_BASE//[[:space:]]/}"
    [[ "$ONEC_BASE" == "/" ]] && ONEC_BASE=""
    ONEC_BASE="${ONEC_BASE%/}"
    is_valid_onec_base "$ONEC_BASE" && break
    warn "Некорректный путь. Пример: /accounting"
  done

  # Пути веб-сервисов 1С (SOAP/HTTP) — отдельная зона лимитов и стриминг.
  while :; do
    read -r -p "Пути веб-сервисов 1С через пробел (Enter = 'ws hs'): " WS_PATHS
    WS_PATHS="${WS_PATHS# }"; WS_PATHS="${WS_PATHS% }"
    [[ -z "$WS_PATHS" ]] && WS_PATHS="ws hs"
    is_valid_ws_paths "$WS_PATHS" && break
    warn "Некорректно. Пример: ws hs  или  ws hs api  (латиница/цифры/_/-, через пробел)"
  done

  if [[ "$ENABLE_CONSOLE" == "ask" ]]; then
    local a
    read -r -p "Установить веб-мониторинг Angie Console Light? [Y/n]: " a
    [[ "${a,,}" == "n" || "${a,,}" == "no" ]] && ENABLE_CONSOLE="no" || ENABLE_CONSOLE="yes"
  fi

  if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
    while :; do
      read -r -p "Доверенные сети для консоли (через пробел, напр. '10.30.0.0/16 192.168.1.5'): " TRUSTED_NETS
      TRUSTED_NETS="${TRUSTED_NETS# }"; TRUSTED_NETS="${TRUSTED_NETS% }"
      [[ -z "$TRUSTED_NETS" ]] && { warn "Пусто нельзя — консоль не должна быть открыта всем."; continue; }
      local bad=0 n
      for n in $TRUSTED_NETS; do is_valid_cidr "$n" || { warn "Некорректная сеть/IP: $n"; bad=1; }; done
      (( bad == 0 )) && break
    done
    while :; do
      read -r -p "Порт внутреннего server-блока консоли (по умолчанию 8443): " CONSOLE_PORT
      CONSOLE_PORT="${CONSOLE_PORT//[[:space:]]/}"
      [[ -z "$CONSOLE_PORT" ]] && CONSOLE_PORT="8443"
      is_valid_port "$CONSOLE_PORT" && break
      warn "Некорректный порт."
    done
  fi

  if [[ "$ENABLE_MODSEC" == "ask" ]]; then
    local m
    read -r -p "Установить ModSecurity + OWASP CRS (WAF)? [y/N]: " m
    [[ "${m,,}" == "y" || "${m,,}" == "yes" ]] && ENABLE_MODSEC="yes" || ENABLE_MODSEC="no"
  fi

  echo
  info "Подтвердите параметры:"
  info "  FQDN:            $FQDN"
  info "  1С/IIS backend:  ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}${ONEC_BASE:-/}"
  info "  Веб-сервисы:     $(ws_paths_regex "$WS_PATHS") (пути: ${WS_PATHS})"
  info "  Rate limit:      ${RATE_RPS} r/s (burst ${RATE_BURST}), conn/IP ${CONN_PER_IP}"
  info "  Console Light:   ${ENABLE_CONSOLE}$( [[ "$ENABLE_CONSOLE" == yes ]] && echo " (порт ${CONSOLE_PORT}, сети: ${TRUSTED_NETS})" )"
  info "  ModSecurity:     ${ENABLE_MODSEC}"
  local c
  read -r -p "Продолжить? [Y/n]: " c
  [[ "${c,,}" == "n" || "${c,,}" == "no" ]] && die "Отменено пользователем."
}

#------------------------------- Детект ОС -------------------------------------
detect_os() {
  [[ -r /etc/os-release ]] || die "Не найден /etc/os-release."
  # shellcheck disable=SC1091
  source /etc/os-release
  OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
  OS_VERSION_ID="${VERSION_ID:-}"
  # Кодовое имя релиза (bookworm/jammy/noble...): из os-release либо lsb_release
  OS_CODENAME="${VERSION_CODENAME:-}"
  [[ -z "$OS_CODENAME" ]] && OS_CODENAME="$(lsb_release -cs 2>/dev/null || true)"
  # Мажор EL для RHEL-семейства (8/9...)
  EL_MAJOR="${OS_VERSION_ID%%.*}"
  case "$OS_ID" in
    debian|ubuntu) PKG="deb" ;;
    rhel|centos|rocky|almalinux|fedora) PKG="rpm" ;;
    *)
      if [[ "$OS_LIKE" == *debian* ]]; then PKG="deb"
      elif [[ "$OS_LIKE" == *rhel* || "$OS_LIKE" == *fedora* ]]; then PKG="rpm"
      else die "Неподдерживаемая ОС: $OS_ID"; fi ;;
  esac
  ok "ОС: ${PRETTY_NAME:-$OS_ID} (пакеты: $PKG, релиз: ${OS_CODENAME:-$OS_VERSION_ID})"
}

#--------------------- Базовые репозитории дистрибутива -------------------------
# Проверяет наличие штатных репо ОС и добавляет недостающие:
#   Debian:  main + security + updates
#   Ubuntu:  main, universe, updates, security
#   RHEL/*:  baseos, appstream, CRB/PowerTools, EPEL
ensure_base_repos() {
  [[ "$MANAGE_BASE_REPOS" == "yes" ]] || { info "Управление базовыми репо отключено (MANAGE_BASE_REPOS=no)."; return 0; }
  info "Проверяю базовые репозитории дистрибутива..."
  if [[ "$PKG" == "deb" ]]; then ensure_base_repos_deb; else ensure_base_repos_rpm; fi
}

ensure_base_repos_deb() {
  local ts backup added=0 list sources cn
  ts="$(date +%Y%m%d-%H%M%S)"
  backup="/etc/apt/sources.backup-${ts}"
  mkdir -p "$backup"
  [[ -f /etc/apt/sources.list ]] && cp -a /etc/apt/sources.list "$backup/" 2>/dev/null || true
  [[ -d /etc/apt/sources.list.d ]] && cp -a /etc/apt/sources.list.d "$backup/" 2>/dev/null || true

  sources="$(grep -rhE '^\s*(deb|Types:|URIs:|Suites:|Components:)' \
              /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null || true)"
  cn="${OS_CODENAME:-stable}"
  list="/etc/apt/sources.list.d/90-base-added.list"

  add_deb_line() {  # $1 маркер, $2 строка, $3 описание
    if ! echo "$sources" | grep -q -- "$1"; then
      echo "$2" >> "$list"
      info "Добавлен репозиторий: $3"
      added=1
      sources="${sources}"$'\n'"$2"
    fi
  }

  if [[ "$OS_ID" == "ubuntu" ]]; then
    local url="http://archive.ubuntu.com/ubuntu" surl="http://security.ubuntu.com/ubuntu"
    add_deb_line " ${cn} .*universe" "deb ${url} ${cn} main restricted universe multiverse" "${cn} main/universe"
    add_deb_line " ${cn}-updates"    "deb ${url} ${cn}-updates main restricted universe multiverse" "${cn}-updates"
    add_deb_line " ${cn}-security"   "deb ${surl} ${cn}-security main restricted universe multiverse" "${cn}-security"
  elif [[ "$OS_ID" == "debian" || "$OS_LIKE" == *debian* ]]; then
    local url="http://deb.debian.org/debian" surl="http://security.debian.org/debian-security"
    add_deb_line " ${cn} .*main"  "deb ${url} ${cn} main contrib non-free non-free-firmware" "${cn} main"
    add_deb_line " ${cn}-updates" "deb ${url} ${cn}-updates main contrib non-free non-free-firmware" "${cn}-updates"
    add_deb_line "${cn}-security" "deb ${surl} ${cn}-security main contrib non-free non-free-firmware" "${cn}-security"
  fi

  if (( added )); then
    ok "Базовые репозитории дополнены (бэкап: $backup). Обновляю индексы..."
    apt-get update -y || warn "apt-get update с ошибкой — проверьте $list"
  else
    ok "Все необходимые базовые репозитории уже подключены."
  fi
}

ensure_base_repos_rpm() {
  local mgr; mgr="$(command -v dnf >/dev/null && echo dnf || echo yum)"
  $mgr -y install "dnf-command(config-manager)" >/dev/null 2>&1 \
    || $mgr -y install yum-utils >/dev/null 2>&1 || true

  case "$EL_MAJOR" in
    9) $mgr config-manager --set-enabled crb >/dev/null 2>&1 \
         && ok "Включён репозиторий CRB." || warn "CRB включить не удалось (возможно уже включён)." ;;
    8) ($mgr config-manager --set-enabled powertools >/dev/null 2>&1 \
         || $mgr config-manager --set-enabled PowerTools >/dev/null 2>&1) \
         && ok "Включён репозиторий PowerTools." || warn "PowerTools включить не удалось." ;;
    *) info "EL${EL_MAJOR}: CRB/PowerTools не требуется/не определён." ;;
  esac

  if ! $mgr repolist enabled 2>/dev/null | grep -qi '^epel'; then
    if $mgr -y install epel-release >/dev/null 2>&1; then
      ok "Установлен EPEL."
    elif [[ -n "$EL_MAJOR" ]]; then
      $mgr -y install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${EL_MAJOR}.noarch.rpm" >/dev/null 2>&1 \
        && ok "Установлен EPEL (прямой RPM)." || warn "EPEL установить не удалось."
    fi
  else
    ok "EPEL уже подключён."
  fi
  $mgr -y makecache >/dev/null 2>&1 || true
}

#--------------------- Обновление системы --------------------------------------
update_system() {
  case "$UPDATE_SYSTEM" in
    no)  info "Обновление системы пропущено (UPDATE_SYSTEM=no)."; return 0 ;;
    yes) : ;;
    ask)
      if [[ "$NONINTERACTIVE" == "1" ]]; then info "Обновление системы пропущено (не задано)."; return 0; fi
      local a
      read -r -p "Обновить пакеты ОС перед установкой (может занять время)? [y/N]: " a
      [[ "${a,,}" == "y" || "${a,,}" == "yes" ]] || { info "Обновление системы пропущено."; return 0; }
      ;;
  esac
  info "Обновляю пакеты системы..."
  if [[ "$PKG" == "deb" ]]; then
    export DEBIAN_FRONTEND=noninteractive
    apt-get update -y
    apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade \
      && ok "Система обновлена." || warn "apt upgrade завершился с предупреждениями."
  else
    local mgr; mgr="$(command -v dnf >/dev/null && echo dnf || echo yum)"
    $mgr -y update && ok "Система обновлена." || warn "$mgr update завершился с предупреждениями."
  fi
}

#--------------------- Репозиторий Angie ---------------------------------------
setup_angie_repo() {
  info "Настраиваю репозиторий Angie ($OS_ID $OS_VERSION_ID)..."
  if [[ "$PKG" == "deb" ]]; then
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y curl gnupg2 ca-certificates lsb-release >/dev/null 2>&1 \
      || apt-get install -y curl gnupg2 ca-certificates lsb-release
    curl -fsSL https://angie.software/keys/angie-signing.gpg \
      -o /usr/share/keyrings/angie-signing.gpg || die "Не удалось скачать ключ подписи Angie."
    local rel="${OS_VERSION_ID}" cn="${OS_CODENAME}"
    [[ -z "$rel" ]] && rel="$(lsb_release -rs 2>/dev/null || echo "")"
    echo "deb [signed-by=/usr/share/keyrings/angie-signing.gpg] https://download.angie.software/angie/${OS_ID}/${rel}/ ${cn} main" \
      > /etc/apt/sources.list.d/angie.list
    apt-get update -y || die "apt-get update для репо Angie не удался (${OS_ID}/${rel}/${cn})."
    ok "Репозиторий Angie подключён (${OS_ID}/${rel}/${cn})."
  else
    local rid="${OS_ID/centos/rhel}"
    curl -fsSL -o /etc/yum.repos.d/angie.repo \
      "https://download.angie.software/angie/${rid}/${EL_MAJOR}/x86_64/angie.repo" \
      || die "Не удалось скачать angie.repo для ${rid}/${EL_MAJOR}."
    ok "Репозиторий Angie подключён (${rid}/${EL_MAJOR})."
  fi
}

#------------------------------- Установка -------------------------------------
install_angie() {
  if command -v angie >/dev/null 2>&1; then
    ok "Angie уже установлен ($(angie -v 2>&1 | head -1))"; return 0
  fi
  info "Устанавливаю Angie..."
  if [[ "$PKG" == "deb" ]]; then
    apt-get install -y angie
  else
    (command -v dnf >/dev/null && dnf install -y angie) || yum install -y angie
  fi
  command -v angie >/dev/null 2>&1 || die "Установка Angie не удалась."
  ok "Angie установлен: $(angie -v 2>&1 | head -1)"
}

install_console() {
  [[ "$ENABLE_CONSOLE" == "yes" ]] || { info "Console Light пропущен."; return 0; }
  if [[ -d "$CONSOLE_ROOT" ]] || [[ -d /usr/share/angie-console-light ]]; then
    ok "Console Light уже установлен."; return 0
  fi
  info "Устанавливаю angie-console-light..."
  if [[ "$PKG" == "deb" ]]; then
    apt-get install -y angie-console-light || { warn "Пакет недоступен — мониторинг отключён."; ENABLE_CONSOLE="no"; return 0; }
  else
    (command -v dnf >/dev/null && dnf install -y angie-console-light) || yum install -y angie-console-light || { warn "Пакет недоступен — мониторинг отключён."; ENABLE_CONSOLE="no"; return 0; }
  fi
  ok "Console Light установлен."
}

install_modsecurity() {
  [[ "$ENABLE_MODSEC" == "yes" ]] || { info "ModSecurity пропущен."; return 0; }
  info "Устанавливаю ModSecurity + OWASP CRS..."
  if [[ "$PKG" == "deb" ]]; then
    apt-get install -y angie-module-modsecurity modsecurity-crs || { warn "Модуль недоступен — WAF отключён."; ENABLE_MODSEC="no"; return 0; }
  else
    (command -v dnf >/dev/null && dnf install -y angie-module-modsecurity) || yum install -y angie-module-modsecurity || { warn "Модуль недоступен — WAF отключён."; ENABLE_MODSEC="no"; return 0; }
  fi
  ok "ModSecurity установлен."
}

#------------------------------- TLS -------------------------------------------
generate_tls() {
  mkdir -p "$TLS_DIR"; chmod 750 "$TLS_DIR"
  local crt="${TLS_DIR}/${FQDN}.crt" key="${TLS_DIR}/${FQDN}.key" dh="${TLS_DIR}/dhparam.pem"
  if [[ ! -s "$crt" || ! -s "$key" ]]; then
    info "Генерирую self-signed сертификат-заглушку для $FQDN..."
    openssl req -x509 -nodes -newkey rsa:2048 -days 397 -keyout "$key" -out "$crt" -subj "/CN=${FQDN}/O=ReverseProxy" -addext "subjectAltName=DNS:${FQDN}" >/dev/null 2>&1
    chmod 600 "$key"; chmod 644 "$crt"
    ok "Сертификат-заглушка создан."
  else
    ok "Сертификат уже существует."
  fi
  if [[ ! -s "$dh" ]]; then
    info "Генерирую DH-параметры (2048 бит)..."
    openssl dhparam -out "$dh" 2048 >/dev/null 2>&1
    chmod 644 "$dh"
  fi
}

#------------------------------- Конфигурация ----------------------------------
backup_configs() {
  mkdir -p "$BACKUP_DIR"
  local f
  for f in "$SITE_CONF" "$SEC_CONF" "$CONSOLE_CONF" "${ANGIE_CONF_DIR}/angie.conf"; do
    [[ -f "$f" ]] && cp -a "$f" "$BACKUP_DIR/"
  done
  ok "Бэкап конфигов: $BACKUP_DIR"
}

write_security_conf() {
  mkdir -p "${ANGIE_CONF_DIR}/http.d"
  cat > "$SEC_CONF" <<EOF
#=== 00-security.conf — глобальные http-level настройки =========================
server_tokens off;

client_max_body_size        512m;
client_body_buffer_size     128k;
client_header_buffer_size   1k;
large_client_header_buffers 4 16k;

client_body_timeout   30s;
client_header_timeout 15s;
send_timeout          30s;
keepalive_timeout     65s;
reset_timedout_connection on;

limit_req_zone  \$binary_remote_addr zone=req_web:10m rate=${RATE_RPS}r/s;
limit_req_zone  \$binary_remote_addr zone=req_ws:10m  rate=30r/s;
limit_conn_zone \$binary_remote_addr zone=conn_main:10m;
limit_req_status  429;
limit_conn_status 429;

set_real_ip_from 127.0.0.1;
# set_real_ip_from <IP_фронтового_балансировщика>;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

map \$http_upgrade \$connection_upgrade {
    default upgrade;
    ""      close;
}

map \$request_method \$bad_method {
    default 1;
    GET 0; HEAD 0; POST 0; PUT 0; PATCH 0; DELETE 0; OPTIONS 0;
}

map \$http_user_agent \$bad_ua {
    default 0;
    ""                     1;
    "~*(nikto|sqlmap|nmap|masscan|acunetix|fimap|nessus)" 1;
}
EOF
  ok "Записан $SEC_CONF"
}

write_site_conf() {
  local modsec_on="" base loc_prefix ws_re
  if [[ "$ENABLE_MODSEC" == "yes" ]]; then
    modsec_on=$'    modsecurity on;\n    modsecurity_rules_file /etc/modsecurity.d/include.conf;'
  fi
  base="${ONEC_BASE:-}"
  loc_prefix="${base:-/}"
  ws_re="$(ws_paths_regex "$WS_PATHS")"

  cat > "$SITE_CONF" <<EOF
#=== reverse-proxy.conf — публикация 1С (IIS) ==================================
upstream onec_iis {
    zone onec_iis 64k;
    server ${UPSTREAM_IP}:${UPSTREAM_PORT} max_fails=3 fail_timeout=15s;
    keepalive 32;
}

server {
    listen 80;
    listen [::]:80;
    server_name ${FQDN};
    location /.well-known/acme-challenge/ { root /var/www/acme; }
    location / { return 301 https://\$host\$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name ${FQDN};
    status_zone onec_site;

    ssl_certificate     ${TLS_DIR}/${FQDN}.crt;
    ssl_certificate_key ${TLS_DIR}/${FQDN}.key;
    ssl_dhparam         ${TLS_DIR}/dhparam.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_stapling on; ssl_stapling_verify on;
    resolver 1.1.1.1 9.9.9.9 valid=300s ipv6=off;
    resolver_timeout 5s;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options           "SAMEORIGIN" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: https:; frame-ancestors 'self'" always;

    limit_conn conn_main ${CONN_PER_IP};
    if (\$bad_method) { return 405; }
    if (\$bad_ua)     { return 444; }

    location ~ /\\.(?!well-known) { deny all; }

${modsec_on}

    location ~* ^${base}/(${ws_re})/ {
        limit_req zone=req_ws burst=60 nodelay;
        proxy_pass ${UPSTREAM_SCHEME}://onec_iis;
        proxy_http_version 1.1;
        proxy_set_header Host              \$host;
        proxy_set_header X-Real-IP         \$remote_addr;
        proxy_set_header X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_set_header Connection        "";
        proxy_connect_timeout 10s;
        proxy_send_timeout    1800s;
        proxy_read_timeout    1800s;
        proxy_buffering off;
    }

    location ${loc_prefix} {
        limit_req zone=req_web burst=${RATE_BURST} nodelay;

        proxy_pass ${UPSTREAM_SCHEME}://onec_iis;
        proxy_http_version 1.1;

        proxy_set_header Upgrade    \$http_upgrade;
        proxy_set_header Connection \$connection_upgrade;

        proxy_set_header Host              \$host;
        proxy_set_header X-Real-IP         \$remote_addr;
        proxy_set_header X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_set_header X-Forwarded-Host  \$host;
        proxy_set_header X-Forwarded-Port  \$server_port;

        proxy_connect_timeout 10s;
        proxy_send_timeout    1800s;
        proxy_read_timeout    1800s;

        proxy_buffering on;
        proxy_buffers 16 32k;
        proxy_buffer_size 32k;
        proxy_busy_buffers_size 64k;

        proxy_redirect ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}/ https://${FQDN}/;
        proxy_redirect ${UPSTREAM_SCHEME}://${FQDN}/ https://${FQDN}/;

        proxy_hide_header X-Powered-By;
        proxy_hide_header X-AspNet-Version;
        proxy_hide_header Server;
    }

    location = /healthz { access_log off; return 200 "ok\\n"; }
}
EOF
  ok "Записан $SITE_CONF"
}

write_console_conf() {
  if [[ "$ENABLE_CONSOLE" != "yes" ]]; then
    rm -f "$CONSOLE_CONF" 2>/dev/null || true
    info "Console-конфиг не создаётся."
    return 0
  fi
  local allows="" n
  for n in $TRUSTED_NETS; do allows+="        allow ${n};"$'\n'; done

  cat > "$CONSOLE_CONF" <<EOF
#=== 10-console.conf — Angie Console Light (ВНУТРЕННИЙ доступ) ==================
server {
    listen ${CONSOLE_PORT} ssl;
    server_name ${FQDN};
    status_zone console;

    ssl_certificate     ${TLS_DIR}/${FQDN}.crt;
    ssl_certificate_key ${TLS_DIR}/${FQDN}.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    location / {
${allows}        deny all;
        auth_basic "Angie Console";
        auth_basic_user_file ${ANGIE_CONF_DIR}/console.htpasswd;
        alias ${CONSOLE_ROOT}/;
        index index.html;
    }

    location /status/ {
        api /status/;
${allows}        deny all;
        auth_basic "Angie Console";
        auth_basic_user_file ${ANGIE_CONF_DIR}/console.htpasswd;
    }
}
EOF
  ok "Записан $CONSOLE_CONF"

  local htp="${ANGIE_CONF_DIR}/console.htpasswd"
  if [[ ! -s "$htp" ]]; then
    local pw hash
    pw="$(openssl rand -base64 12 2>/dev/null || echo "ChangeMe$RANDOM")"
    hash="$(openssl passwd -apr1 "$pw" 2>/dev/null)"
    if [[ -n "$hash" ]]; then
      echo "admin:${hash}" > "$htp"; chmod 640 "$htp"
      CONSOLE_USER="admin"; CONSOLE_PASS="$pw"
      ok "Создан доступ к консоли (логин admin)."
    else
      warn "Не удалось сгенерировать htpasswd — создайте вручную."
    fi
  else
    ok "console.htpasswd уже существует."
  fi
}

ensure_include() {
  local main="${ANGIE_CONF_DIR}/angie.conf"
  [[ -f "$main" ]] || die "Не найден $main"
  if ! grep -qE 'include\s+.*http\.d/\*\.conf' "$main"; then
    warn "http.d/*.conf не подключён — добавляю в http{}"
    if grep -q 'include .*conf.d/\*.conf' "$main"; then
      sed -i 's#\(include .*conf.d/\*.conf;\)#\1\n    include /etc/angie/http.d/*.conf;#' "$main"
    else
      warn "Авто-подключение не удалось. Добавьте вручную: include /etc/angie/http.d/*.conf; в http{}"
    fi
  fi
  mkdir -p /var/www/acme
}

configure_firewall() {
  local console_rule=""
  [[ "$ENABLE_CONSOLE" == "yes" ]] && console_rule="$CONSOLE_PORT"
  if command -v ufw >/dev/null 2>&1; then
    ufw allow 80/tcp  >/dev/null 2>&1 || true
    ufw allow 443/tcp >/dev/null 2>&1 || true
    if [[ -n "$console_rule" ]]; then
      local n
      for n in $TRUSTED_NETS; do ufw allow from "$n" to any port "$console_rule" proto tcp >/dev/null 2>&1 || true; done
    fi
    ok "UFW: 80,443$( [[ -n "$console_rule" ]] && echo ", $console_rule (только доверенным)" )"
  elif command -v firewall-cmd >/dev/null 2>&1; then
    firewall-cmd --permanent --add-service=http  >/dev/null 2>&1 || true
    firewall-cmd --permanent --add-service=https >/dev/null 2>&1 || true
    if [[ -n "$console_rule" ]]; then
      local n
      for n in $TRUSTED_NETS; do
        firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=$n port port=$console_rule protocol=tcp accept" >/dev/null 2>&1 || true
      done
    fi
    firewall-cmd --reload >/dev/null 2>&1 || true
    ok "firewalld настроен."
  else
    warn "Не найден ufw/firewalld — откройте порты вручную."
  fi
}

validate_and_start() {
  info "Проверяю конфигурацию (angie -t)..."
  if ! angie -t 2>>"$LOG_FILE"; then
    err "Конфигурация невалидна. Восстанавливаю из бэкапа $BACKUP_DIR"
    local f
    for f in reverse-proxy.conf 00-security.conf 10-console.conf; do
      [[ -f "$BACKUP_DIR/$f" ]] && cp -a "$BACKUP_DIR/$f" "${ANGIE_CONF_DIR}/http.d/$f"
    done
    die "Откат выполнен. Смотрите $LOG_FILE"
  fi
  ok "Конфигурация валидна."
  systemctl enable angie >/dev/null 2>&1 || true
  if systemctl is-active --quiet angie; then
    systemctl reload angie && ok "Angie перезагружен (reload)."
  else
    systemctl restart angie && ok "Angie запущен (restart)."
  fi
}

smoke_test() {
  info "Smoke-тест..."
  sleep 1
  ss -tlnp 2>/dev/null | grep -q ':443' && ok "Порт 443 слушается." || warn "443 не найден — systemctl status angie"
  if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
    ss -tlnp 2>/dev/null | grep -q ":${CONSOLE_PORT}" && ok "Порт консоли ${CONSOLE_PORT} слушается." || warn "Порт ${CONSOLE_PORT} не найден."
  fi
  if command -v curl >/dev/null 2>&1; then
    local code
    code="$(curl -kso /dev/null -w '%{http_code}' "https://127.0.0.1/healthz" -H "Host: ${FQDN}" || echo 000)"
    [[ "$code" == "200" ]] && ok "healthz -> HTTP $code" || warn "healthz -> HTTP $code"
  fi
}

print_summary() {
  local base="${ONEC_BASE:-}" ws_list="" p
  for p in $WS_PATHS; do ws_list+="${ws_list:+ , }${base}/${p}/"; done
  cat <<EOF | tee -a "$LOG_FILE"

================= РАЗВЁРТЫВАНИЕ ЗАВЕРШЕНО =================
 FQDN:              ${FQDN}
 Вход (1С):         https://${FQDN}${ONEC_BASE:-/}   (TCP/443, TLS1.2/1.3)
 Бэкенд IIS:        ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}${ONEC_BASE:-/}
 Веб-сервисы 1С:    ${ws_list}  (отдельная зона лимитов)
 Rate limit:        web ${RATE_RPS} r/s (burst ${RATE_BURST}); ws 30 r/s; conn/IP ${CONN_PER_IP}
 ModSecurity/CRS:   ${ENABLE_MODSEC}
EOF
  if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
    cat <<EOF | tee -a "$LOG_FILE"
 Console Light:     https://<внутр.IP>:${CONSOLE_PORT}/   (только сети: ${TRUSTED_NETS})
   Логин/пароль:    ${CONSOLE_USER:-admin} / ${CONSOLE_PASS:-<см. console.htpasswd>}
   ВНИМАНИЕ:        консоль НЕ опубликована на 443 и не должна быть доступна извне.
EOF
  fi
  cat <<EOF | tee -a "$LOG_FILE"
 Конфиги:           ${SITE_CONF}
                    ${SEC_CONF}
 TLS:               ${TLS_DIR}/${FQDN}.{crt,key}  (ЗАГЛУШКА — замените!)
 Бэкап:             ${BACKUP_DIR}
 Лог:               ${LOG_FILE}

 СЛЕДУЮЩИЕ ШАГИ:
  1) Замените self-signed сертификат на боевой (ACME/корп. CA).
  2) Укажите set_real_ip_from для фронтового LB/FortiGate в 00-security.conf.
  3) Проверьте вход: тонкий клиент 1С + один веб-сервис (/ws/).
  4) Если UI 1С "поехал" — ослабьте CSP под конфигурацию.
  5) Настройте fail2ban на 429/444.
=========================================================
EOF
}

main() {
  require_root
  : > "$LOG_FILE" 2>/dev/null || true
  info "Старт $SCRIPT_NAME"
  detect_os
  prompt_inputs
  ensure_base_repos
  update_system
  setup_angie_repo
  install_angie
  install_console
  install_modsecurity
  generate_tls
  backup_configs
  write_security_conf
  write_site_conf
  write_console_conf
  ensure_include
  configure_firewall
  validate_and_start
  smoke_test
  print_summary
}

main "$@"
Angie,bash,universal
bash
Проверка состояния кластера Proxmox
Быстрый health-check узлов и кворума.
Показать код
#!/usr/bin/env bash
set -euo pipefail
pvecm status
pvesh get /cluster/resources --type node
proxmox,bash,healthcheck
powershell1
powershell
Сканирование инфраструктуры AD на наличие Mimikatz
Показать код
<#
.SYNOPSIS
    Полное сканирование инфраструктуры AD на следы credential theft tools.
.DESCRIPTION
    Blue Team скрипт. Сигнатурные строки закодированы в Base64 для
    предотвращения ложного срабатывания AV. Режимы: Detect | Remediate | Report
.NOTES

Copyright Sergey Issayev 

    PowerShell 5.1+, RSAT AD, WinRM | DA/EA rights | v1.1
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [string[]]$Domains,
    [ValidateSet('Detect','Remediate','Report')][string]$Mode = 'Detect',
    [string]$TargetOU,
    [string[]]$ExcludeComputers = @(),
    [ValidateRange(1,50)][int]$MaxConcurrent = 10,
    [ValidateRange(1,8760)][int]$HoursBack = 168,
    [System.Management.Automation.PSCredential]$Credential,
    [string]$OutputPath = "$env:USERPROFILE\Desktop\InfraAudit_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
)

#region ═══ AV-SAFE: SIGNATURE DECODE ═══
function _d([string]$b){[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b))}
$S = @{
    T1=_d 'bWltaWthdHo=';     TLib=_d 'bWltaWxpYg==';    TDrv=_d 'bWltaWRydg=='
    TLsa=_d 'bWltaWxzYQ==';   Sek=_d 'c2VrdXJsc2E=';     Rub=_d 'cnViZXVz'
    Laz=_d 'bGF6YWduZQ==';    PrD=_d 'cHJvY2R1bXA=';     NaD=_d 'bmFub2R1bXA='
    SfK=_d 'c2FmZXR5a2F0eg=='; ShD=_d 'c2hhcnBkdW1w';    HaK=_d 'aGFuZGxla2F0eg=='
    PhM=_d 'cGh5c21lbTJwcm9maXQ='; Dmp=_d 'ZHVtcGVydA=='; PLB=_d 'UFBMQmxhZGU='
    PLD=_d 'UFBMZHVtcA==';    Lsy=_d 'bHNhc3N5';         PyK=_d 'cHlweWthdHo='
    Kwi=_d 'a2l3aQ==';        CG=_d 'a2VyYmVyb3M6OmdvbGRlbg=='
    CPT=_d 'a2VyYmVyb3M6OnB0dA==';  CDb=_d 'cHJpdmlsZWdlOjpkZWJ1Zw=='
    CEl=_d 'dG9rZW46OmVsZXZhdGU=';  CCa=_d 'Y3J5cHRvOjpjYXBp'
    CDp=_d 'ZHBhcGk6Og==';    CVa=_d 'dmF1bHQ6Og==';     CLd=_d 'bHNhZHVtcA=='
    CDs=_d 'bHNhZHVtcDo6ZGNzeW5j'; CSk=_d 'bWlzYzo6c2tlbGV0b24='
    IT1=_d 'aW52b2tlLW1pbWlrYXR6'; IKb=_d 'aW52b2tlLWtlcmJlcm9hc3Q='
    IRb=_d 'aW52b2tlLXJ1YmV1cw=='; GNt=_d 'Z2V0LW50bG1jaGFsbGVuZ2U='
    Skl=_d 'c2tlbGV0b24=';    DCS=_d 'ZGNzeW5j';         KbR=_d 'a2VyYmVyb2FzdA=='
}
$_e={param([string[]]$a)($a|%{[regex]::Escape($_)})-join'|'}
$script:SR=@{
    Proc='(?i)'+(&$_e @($S.T1,'mimi',$S.Sek,$S.Kwi,$S.Laz,$S.PrD,$S.NaD,$S.PyK,$S.SfK,$S.ShD,$S.HaK,$S.PhM,$S.Dmp,$S.PLB,$S.PLD,$S.Lsy))
    Files=@("$($S.T1)*","$($S.TLib)*","$($S.TDrv)*","$($S.TLsa)*","$($S.Sek)*","$($S.Kwi)*","$($S.Laz)*","$($S.PrD)*","$($S.NaD)*","$($S.SfK)*","$($S.ShD)*","$($S.Rub)*","$($S.IT1)*","$($S.PyK)*","$($S.Lsy)*","$($S.HaK)*","$($S.PLB)*","$($S.PLD)*","$($S.Dmp)*")
    Cmd='(?i)'+(&$_e @($S.T1,$S.Sek,$S.CLd,$S.CG,$S.CPT,$S.CDb,$S.CEl,$S.CCa,$S.CDp,$S.CVa,$S.IT1,$S.Rub,$S.ShD))
    PS='(?i)'+(&$_e @($S.IT1,$S.Sek,$S.CG,$S.CDs,$S.CEl,$S.CDb,$S.IKb,$S.GNt,$S.IRb))
    Task='(?i)'+(&$_e @($S.T1,$S.Sek,$S.Kwi,$S.Laz,$S.Rub,$S.IT1,$S.ShD))
    Svc='(?i)'+(&$_e @($S.T1,$S.TLib,$S.TDrv,$S.Kwi,$S.Sek,$S.Skl))
    GPO='(?i)'+(&$_e @($S.T1,$S.Sek,$S.IT1,$S.Rub,$S.KbR,$S.DCS,$S.ShD))
    Sm1='(?i)'+(&$_e @($S.T1,$S.Sek,$S.CLd,$S.CG,$S.CDb,$S.IT1,$S.Rub,$S.SfK,$S.ShD))
    LsaOK=@('','kerberos','msv1_0','schannel','wdigest','tspkg','pku2u','cloudap','negoextender','wsauth')
    AuthOK=@('msv1_0','')
    NotifOK=@('scecli','rassfm','')
}
#endregion

#region ═══ GLOBALS ═══
$ErrorActionPreference='Continue'
$script:T0=[DateTime]::Now
$script:AF=[System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
$script:RL=[System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
$script:GS=@{Total=0;Scanned=0;Unreach=0;Findings=0;Crit=0;High=0;Med=0;Low=0;Fixes=0}
function Write-B{param([string]$T)Write-Host "`n$('='*80)" -ForegroundColor Cyan;Write-Host "  $T" -ForegroundColor Cyan;Write-Host "$('='*80)" -ForegroundColor Cyan}
function Write-S{param([string]$T)Write-Host "  [*] $T" -ForegroundColor White}
function Write-G{param([string]$T)Write-Host "  [OK] $T" -ForegroundColor Green}
function Write-A{param([string]$V,[string]$T)$c=switch($V){'CRITICAL'{'Red'}'HIGH'{'Magenta'}'MEDIUM'{'DarkYellow'}default{'Gray'}};Write-Host "  [$V] $T" -ForegroundColor $c}
function Write-F{param([string]$T)Write-Host "  [FIX] $T" -ForegroundColor Blue}
#endregion

#region ═══ REMOTE SCAN SCRIPTBLOCK ═══
$ScanSB = {
    param([string]$HN,[string]$HR,[int]$HB,[string]$Md,[hashtable]$SR,[hashtable]$Sig)
    $fin=[System.Collections.ArrayList]::new()
    $rem=[System.Collections.ArrayList]::new()
    $hi=@{Name=$env:COMPUTERNAME;Role=$HR;ScanTime=Get-Date;Status='OK'
        OS=(Get-CimInstance Win32_OperatingSystem).Caption
        Boot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime}

    function AF{param([string]$C,[string]$V,[string]$D,[string]$Dt='',[string]$R='',[string]$M='')
        $null=$fin.Add([PSCustomObject]@{Host=$env:COMPUTERNAME;Role=$HR;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss'
            Category=$C;Severity=$V;Description=$D;Detail=$Dt;Remediation=$R;MitreID=$M})}

    # 1. Processes
    $procs=Get-Process -EA SilentlyContinue|Select Id,Name,Path,StartTime
    foreach($p in $procs){
        if($p.Name -match $SR.Proc){
            AF 'SuspProcess' 'CRITICAL' "Подозрительный процесс: $($p.Name) (PID $($p.Id))" "Путь: $($p.Path)" 'Завершить. Изолировать хост.' 'T1003'
            if($Md -eq 'Remediate'){try{Stop-Process -Id $p.Id -Force -EA Stop;$null=$rem.Add("Процесс $($p.Name) PID$($p.Id) завершён")}catch{$null=$rem.Add("ERR: $($p.Name): $_")}}
        }
        if($p.Path -and $p.Path -match '(?i)\\Temp\\|\\Downloads\\|\\AppData\\Local\\Temp|\\Public\\|\\ProgramData\\debug|\\Recycle'){
            AF 'SuspProcess' 'HIGH' "Процесс из подозрительного пути: $($p.Name)" "Путь: $($p.Path)" 'Проверить.' 'T1059'
        }
    }

    # 2. LSA/WDigest/CredGuard/CredSSP
    $ppl=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name RunAsPPL -EA SilentlyContinue
    if(-not $ppl -or $ppl.RunAsPPL -ne 1){
        AF 'Config' 'HIGH' 'RunAsPPL не включён' '' 'Включить RunAsPPL=1' 'T1003.001'
        if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name RunAsPPL -Value 1 -Type DWord -Force;$null=$rem.Add("RunAsPPL=1")}
    }
    $wd=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest' -Name UseLogonCredential -EA SilentlyContinue
    if($wd -and $wd.UseLogonCredential -eq 1){
        AF 'Config' 'CRITICAL' 'WDigest UseLogonCredential=1' '' 'Установить 0' 'T1003.001'
        if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest' -Name UseLogonCredential -Value 0 -Type DWord -Force;$null=$rem.Add("WDigest=0")}
    }
    try{$dg=Get-CimInstance Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard -EA SilentlyContinue
        if(-not $dg -or $dg.SecurityServicesRunning -notcontains 1){AF 'Config' 'MEDIUM' 'Credential Guard не активен' '' 'GPO' 'T1003.001'}}catch{}
    $cs1=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation' -Name AllowDefaultCredentials -EA SilentlyContinue
    $cs2=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation' -Name AllowDefCredentialsWhenNTLMOnly -EA SilentlyContinue
    if($cs1 -or $cs2){AF 'Config' 'MEDIUM' 'CredSSP делегирование разрешено' '' 'Remote Credential Guard' 'T1550'}

    # 3. LSA Packages
    $lp=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Security Packages' -EA SilentlyContinue).'Security Packages'
    $uk=$lp|?{$_ -and $_ -notin $SR.LsaOK}
    if($uk){AF 'SkelKey' 'CRITICAL' "Нестандартные LSA Packages: $($uk -join ', ')" '' 'Удалить. Перезагрузить.' 'T1556.001'
        if($Md -eq 'Remediate'){$cl=$lp|?{$_ -in $SR.LsaOK -or $_ -eq ''};Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Security Packages' -Value $cl -Force;$null=$rem.Add("LSA packages cleaned")}}
    $ap=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Authentication Packages' -EA SilentlyContinue).'Authentication Packages'
    $ua=$ap|?{$_ -and $_ -notin $SR.AuthOK}
    if($ua){AF 'SkelKey' 'HIGH' "Нестандартные Auth Packages: $($ua -join ', ')" '' 'Проверить.' 'T1556.001'}
    $np=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Notification Packages' -EA SilentlyContinue).'Notification Packages'
    $un=$np|?{$_ -and $_ -notin $SR.NotifOK}
    if($un){AF 'Persistence' 'CRITICAL' "Password Notification Packages: $($un -join ', ')" 'Password filter DLL' 'Удалить.' 'T1556.002'}

    # 4. Files
    $sp=@("$env:SystemRoot\Temp","$env:SystemRoot\System32","$env:TEMP","$env:PUBLIC","$env:USERPROFILE\Downloads","$env:USERPROFILE\Desktop","$env:ProgramData")
    foreach($d in ($sp|Select -Unique)){
        if(-not(Test-Path $d)){continue}
        foreach($pat in $SR.Files){
            $ff=Get-ChildItem $d -Filter $pat -Recurse -Depth 2 -EA SilentlyContinue
            foreach($f in $ff){
                AF 'MalFile' 'CRITICAL' "Вредоносный файл: $($f.FullName)" "$([math]::Round($f.Length/1KB,1))KB | $($f.CreationTime)" 'Карантин.' 'T1003'
                if($Md -eq 'Remediate'){$q="$env:SystemRoot\Quarantine";if(-not(Test-Path $q)){New-Item $q -ItemType Directory -Force|Out-Null};Move-Item $f.FullName (Join-Path $q "$(Get-Date -F 'yyyyMMddHHmmss')_$($f.Name)") -Force -EA SilentlyContinue;$null=$rem.Add("Quarantine: $($f.FullName)")}
            }
        }
        $dps=Get-ChildItem $d -Include '*.dmp','*.kirbi','*.ccache' -Recurse -Depth 2 -EA SilentlyContinue|?{$_.Length -gt 1KB}
        foreach($dp in $dps){
            $sv=if($dp.Extension -in '.kirbi','.ccache'){'CRITICAL'}elseif($dp.Length -gt 30MB){'CRITICAL'}else{'HIGH'}
            AF 'CredDump' $sv "Дамп/билет: $($dp.FullName)" "$([math]::Round($dp.Length/1MB,2))MB" 'Изъять.' 'T1003.001'
        }
    }

    # 5. Services/Drivers
    $svcs=Get-CimInstance Win32_Service -EA SilentlyContinue
    foreach($sv in $svcs){
        $str="$($sv.Name) $($sv.DisplayName) $($sv.PathName)"
        if($str -match $SR.Svc){
            AF 'MalService' 'CRITICAL' "Служба: $($sv.Name)" "Путь: $($sv.PathName)" 'Удалить.' 'T1543.003'
            if($Md -eq 'Remediate' -and $sv.State -eq 'Running'){Stop-Service $sv.Name -Force -EA SilentlyContinue;Set-Service $sv.Name -StartupType Disabled -EA SilentlyContinue;$null=$rem.Add("Svc $($sv.Name) stopped")}
        }
        if($sv.StartName -match 'LocalSystem' -and $sv.PathName -and $sv.PathName -match '(?i)\\Temp\\|\\Downloads\\|\\AppData\\|\\Public\\|\\Recycle'){
            AF 'SuspService' 'HIGH' "SYSTEM из Temp: $($sv.Name)" "$($sv.PathName)" 'Проверить.' 'T1543.003'
        }
    }
    $drvs=Get-CimInstance Win32_SystemDriver -EA SilentlyContinue|?{$_.PathName -match $SR.Svc}
    foreach($dr in $drvs){AF 'MalDriver' 'CRITICAL' "Драйвер: $($dr.Name)" "$($dr.PathName)" 'Удалить.' 'T1014'}

    # 6. Scheduled Tasks
    try{$tasks=Get-ScheduledTask -EA SilentlyContinue|?{$_.State -ne 'Disabled'}
        foreach($t in $tasks){$act=($t.Actions|%{"$($_.Execute) $($_.Arguments)"})-join' '
            if($act -match $SR.Task){AF 'Persist' 'CRITICAL' "Task: $($t.TaskName)" $act.Substring(0,[Math]::Min(300,$act.Length)) 'Удалить.' 'T1053.005'
                if($Md -eq 'Remediate'){Unregister-ScheduledTask -TaskName $t.TaskName -Confirm:$false -EA SilentlyContinue;$null=$rem.Add("Task: $($t.TaskName)")}}
            elseif($act -match '(?i)encodedcommand|frombase64|downloadstring|hidden|bypass|iex\('){AF 'Persist' 'HIGH' "Подозр. task: $($t.TaskName)" $act.Substring(0,[Math]::Min(300,$act.Length)) 'Проверить.' 'T1053.005'}
        }}catch{}

    # 7. WMI
    try{$wc=Get-CimInstance -Namespace root\subscription -ClassName CommandLineEventConsumer -EA SilentlyContinue
        foreach($w in $wc){$cm="$($w.CommandLineTemplate) $($w.ExecutablePath)"
            if($cm -match $SR.Task -or $cm -match '(?i)powershell.*enc|downloadstring|bypass'){AF 'Persist' 'CRITICAL' "WMI: $($w.Name)" $w.CommandLineTemplate 'Удалить.' 'T1546.003'
                if($Md -eq 'Remediate'){$w|Remove-CimInstance -EA SilentlyContinue;$null=$rem.Add("WMI: $($w.Name)")}}}
        $sc=Get-CimInstance -Namespace root\subscription -ClassName ActiveScriptEventConsumer -EA SilentlyContinue
        foreach($s in $sc){AF 'Persist' 'HIGH' "WMI Script: $($s.Name)" $s.ScriptingEngine 'Проверить.' 'T1546.003'}}catch{}

    # 8. Autorun
    $arks=@('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run','HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce','HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce','HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon')
    foreach($k in $arks){if(-not(Test-Path $k)){continue};$vs=Get-ItemProperty $k -EA SilentlyContinue
        foreach($pr in ($vs.PSObject.Properties|?{$_.Name -notmatch '^PS'})){$vl="$($pr.Value)"
            if($vl -match $SR.Task){AF 'Persist' 'CRITICAL' "Autorun: $k\$($pr.Name)" $vl 'Удалить.' 'T1547.001'
                if($Md -eq 'Remediate'){Remove-ItemProperty $k -Name $pr.Name -Force -EA SilentlyContinue;$null=$rem.Add("Autorun: $($pr.Name)")}}
            elseif($vl -match '(?i)encodedcommand|frombase64|downloadstring|hidden.*bypass'){AF 'Persist' 'HIGH' "Подозр. autorun: $k\$($pr.Name)" $vl.Substring(0,[Math]::Min(200,$vl.Length)) 'Проверить.' 'T1547.001'}
        }}

    # 9. Event Logs
    $since=(Get-Date).AddHours(-$HB)
    try{$e4688=Get-WinEvent -FilterHashtable @{LogName='Security';Id=4688;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[5].Value -match $SR.Cmd}
        foreach($e in $e4688){AF 'EvtLog' 'CRITICAL' "Cmd(4688): $($e.Properties[5].Value)" "User: $($e.Properties[1].Value)" 'Расследовать.' 'T1059.001'}}catch{}
    try{$e4672=Get-WinEvent -FilterHashtable @{LogName='Security';Id=4672;StartTime=$since} -EA SilentlyContinue
        if($e4672){$gr=$e4672|Group-Object{$_.Properties[1].Value}|?{$_.Count -ge 50 -and $_.Name -notmatch '\$|SYSTEM|LOCAL SERVICE|NETWORK SERVICE'}|Sort Count -Desc|Select -First 5
            foreach($g in $gr){AF 'EvtLog' 'MEDIUM' "Привилегии: $($g.Name) ($($g.Count)x)" '' 'Проверить.' 'T1134'}}}catch{}
    try{$sm10=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=10;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[8].Value -match '(?i)lsass\.exe' -and $_.Properties[7].Value -match '0x1010|0x1410|0x1438|0x143a|0x1fffff'}
        foreach($s in $sm10){AF 'LSASS' 'CRITICAL' "LSASS access(Sysmon10): $($s.Properties[5].Value)" "Access: $($s.Properties[7].Value)" 'Изолировать.' 'T1003.001'}}catch{}
    try{$sm8=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=8;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[7].Value -match '(?i)lsass\.exe'}
        foreach($s in $sm8){AF 'LSASS' 'CRITICAL' "RemoteThread LSASS(Sysmon8): $($s.Properties[4].Value)" "PID: $($s.Properties[3].Value)" 'Изолировать.' 'T1055.003'}}catch{}
    try{$sm1=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=1;StartTime=$since} -EA SilentlyContinue|?{$_.Message -match $SR.Sm1}
        foreach($s in $sm1){AF 'EvtLog' 'CRITICAL' "Tool launch(Sysmon1)" $s.Properties[10].Value.Substring(0,[Math]::Min(400,$s.Properties[10].Value.Length)) 'Изолировать.' 'T1003'}}catch{}
    try{$ps4104=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-PowerShell/Operational';Id=4104;StartTime=$since} -EA SilentlyContinue|?{$_.Message -match $SR.PS}
        foreach($p in $ps4104){AF 'EvtLog' 'CRITICAL' "PS ScriptBlock(4104)" $p.Message.Substring(0,[Math]::Min(400,$p.Message.Length)) 'Расследовать.' 'T1059.001'}}catch{}
    try{$e7045=Get-WinEvent -FilterHashtable @{LogName='System';Id=7045;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[1].Value -match $SR.Svc -or $_.Properties[1].Value -match '(?i)cmd\.exe|powershell|comspec|\\Temp\\|\\Downloads\\' -or $_.Properties[0].Value -match $SR.Svc}
        foreach($e in $e7045){AF 'EvtLog' 'CRITICAL' "Svc install(7045): $($e.Properties[0].Value)" "$($e.Properties[1].Value)" 'Удалить.' 'T1543.003'}}catch{}

    # 10. Network
    $bad=@(4444,5555,8888,1234,31337,6666,9999,13337)
    try{$li=Get-NetTCPConnection -State Listen -EA SilentlyContinue|?{$_.LocalPort -in $bad}
        foreach($l in $li){$pr=Get-Process -Id $l.OwningProcess -EA SilentlyContinue;AF 'Net' 'HIGH' "Listen :$($l.LocalPort)" "Proc: $($pr.Name)" 'Проверить C2.' 'T1571'}}catch{}
    try{$ob=Get-NetTCPConnection -State Established -EA SilentlyContinue|?{$_.RemoteAddress -notmatch '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|::1|fe80)' -and $_.RemotePort -in $bad}
        foreach($o in $ob){$pr=Get-Process -Id $o.OwningProcess -EA SilentlyContinue;AF 'Net' 'HIGH' "Outbound $($o.RemoteAddress):$($o.RemotePort)" "$($pr.Name)" 'Block.' 'T1571'}}catch{}

    # 11. DC-specific
    if($HR -eq 'DC'){
        try{$ds=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name DsrmAdminLogonBehavior -EA SilentlyContinue
            if($ds -and $ds.DsrmAdminLogonBehavior -eq 2){AF 'DCBack' 'CRITICAL' 'DSRM=2 backdoor!' '' 'Set 0.' 'T1003.003'
                if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name DsrmAdminLogonBehavior -Value 0 -Type DWord -Force;$null=$rem.Add("DSRM=0")}}}catch{}
        $spl=Get-Service Spooler -EA SilentlyContinue
        if($spl -and $spl.Status -eq 'Running'){AF 'DCCfg' 'HIGH' 'Spooler on DC' 'PrintNightmare' 'Stop.' 'T1210'
            if($Md -eq 'Remediate'){Stop-Service Spooler -Force -EA SilentlyContinue;Set-Service Spooler -StartupType Disabled -EA SilentlyContinue;$null=$rem.Add("Spooler off")}}
        try{$smb=Get-SmbServerConfiguration -EA SilentlyContinue;if($smb.EnableSMB1Protocol){AF 'DCCfg' 'HIGH' 'SMBv1 on DC' '' 'Disable.' 'T1210'
            if($Md -eq 'Remediate'){Set-SmbServerConfiguration -EnableSMB1Protocol $false -Confirm:$false -EA SilentlyContinue;$null=$rem.Add("SMBv1 off")}}}catch{}
        try{$gd="$env:SystemRoot\SYSVOL\domain\Policies";if(Test-Path $gd){$gs=Get-ChildItem $gd -Recurse -Include '*.ps1','*.bat','*.cmd','*.vbs' -EA SilentlyContinue
            foreach($g in $gs){$ct=Get-Content $g.FullName -Raw -EA SilentlyContinue;if($ct -match $SR.GPO){AF 'GPOBack' 'CRITICAL' "GPO script: $($g.FullName)" "$($g.LastWriteTime)" 'Remove.' 'T1484.001'}}}}catch{}
    }

    # 12. Audit config
    try{$au=&auditpol /get /subcategory:"Process Creation" 2>$null;if($au -notmatch 'Success'){AF 'Audit' 'MEDIUM' 'Process Creation audit off' '' 'Enable.' 'T1562.002'}
        $ca=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -EA SilentlyContinue
        if(-not $ca -or $ca.ProcessCreationIncludeCmdLine_Enabled -ne 1){AF 'Audit' 'MEDIUM' 'CmdLine in 4688 off' '' 'GPO.' 'T1562.002'}}catch{}
    try{$pl=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Name EnableScriptBlockLogging -EA SilentlyContinue
        if(-not $pl -or $pl.EnableScriptBlockLogging -ne 1){AF 'Audit' 'MEDIUM' 'PS ScriptBlock Logging off' '' 'GPO.' 'T1562.002'}}catch{}

    return [PSCustomObject]@{HostInfo=$hi;Findings=$fin;Remediations=$rem}
}
#endregion

#region ═══ ORCHESTRATION ═══
function Get-Targets{
    param([string]$Domain)
    Write-S "Перечисление: $Domain"
    $p=@{Server=$Domain;ErrorAction='Stop'}
    $dcs=Get-ADDomainController -Filter * @p|%{[PSCustomObject]@{Name=$_.HostName;Role='DC';OS=$_.OperatingSystem}}
    Write-G "  DC: $($dcs.Count)"
    $fp=if($TargetOU){@{Filter='Enabled -eq $true';SearchBase=$TargetOU;Properties=@('OperatingSystem','LastLogonDate')}}else{@{Filter='Enabled -eq $true';Properties=@('OperatingSystem','LastLogonDate')}}
    $comps=Get-ADComputer @fp @p|?{$_.Name -notin $ExcludeComputers -and $_.Name -notin ($dcs.Name -replace '\..*$','') -and $_.LastLogonDate -gt (Get-Date).AddDays(-30) -and $_.DNSHostName}|%{
        $r=if($_.OperatingSystem -match 'Server'){'Server'}else{'Workstation'}
        [PSCustomObject]@{Name=$_.DNSHostName;Role=$r;OS=$_.OperatingSystem}}
    Write-G "  Servers: $(($comps|?{$_.Role -eq 'Server'}).Count) | WS: $(($comps|?{$_.Role -eq 'Workstation'}).Count)"
    return @($dcs)+@($comps)
}

function Start-Scan{
    Write-B "СКАНИРОВАНИЕ ИНФРАСТРУКТУРЫ"
    if(-not $Domains){try{$forest=Get-ADForest;$Domains=$forest.Domains;Write-G "Лес: $($forest.Name)"}catch{$Domains=@((Get-ADDomain).DNSRoot)}}
    $targets=foreach($d in $Domains){try{Get-Targets $d}catch{Write-Warning "Err $d : $_"}}
    $script:GS.Total=$targets.Count
    Write-S "Целей: $($targets.Count) | Потоков: $MaxConcurrent"

    $ss=[System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
    $pool=[System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1,$MaxConcurrent,$ss,$Host)
    $pool.Open()
    $jobs=[System.Collections.ArrayList]::new()
    $n=0
    foreach($tgt in $targets){
        $n++
        if(-not $tgt.Name){$script:GS.Unreach++;continue}
        Write-Progress -Activity Scan -Status "$($tgt.Name) [$n/$($targets.Count)]" -PercentComplete (($n/$targets.Count)*100)
        if(-not(Test-Connection $tgt.Name -Count 1 -Quiet -ErrorAction SilentlyContinue)){
            Write-Warning "  [SKIP] $($tgt.Name) ICMP";$script:GS.Unreach++
            $null=$script:AF.Add([PSCustomObject]@{Host=$tgt.Name;Role=$tgt.Role;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Connect';Severity='INFO';Description='Unreachable';Detail='ICMP';Remediation='Check net';MitreID=''})
            continue}
        $ok=$false
        try{$se=if($Credential){New-PSSession $tgt.Name -Credential $Credential -EA Stop}else{New-PSSession $tgt.Name -EA Stop};Remove-PSSession $se;$ok=$true}
        catch{Write-Warning "  [SKIP] $($tgt.Name) WinRM";$script:GS.Unreach++
            $null=$script:AF.Add([PSCustomObject]@{Host=$tgt.Name;Role=$tgt.Role;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Connect';Severity='LOW';Description='WinRM fail';Detail=$_.Exception.Message;Remediation='Enable-PSRemoting';MitreID=''})
            continue}
        if($ok){
            $ps=[PowerShell]::Create();$ps.RunspacePool=$pool
            $null=$ps.AddScript($ScanSB)
            $null=$ps.AddParameter('HN',$tgt.Name).AddParameter('HR',$tgt.Role).AddParameter('HB',$HoursBack).AddParameter('Md',$Mode).AddParameter('SR',$script:SR).AddParameter('Sig',$S)
            $h=$ps.BeginInvoke()
            $null=$jobs.Add([PSCustomObject]@{PS=$ps;H=$h;T=$tgt})
        }
    }
    Write-B "СБОР РЕЗУЛЬТАТОВ"
    $done=0
    foreach($j in $jobs){
        try{if($j.H.AsyncWaitHandle.WaitOne([TimeSpan]::FromMinutes(5))){
            $r=$j.PS.EndInvoke($j.H)
            if($r){$script:GS.Scanned++
                foreach($f in $r.Findings){$null=$script:AF.Add($f);$script:GS.Findings++
                    switch($f.Severity){'CRITICAL'{$script:GS.Crit++}'HIGH'{$script:GS.High++}'MEDIUM'{$script:GS.Med++}'LOW'{$script:GS.Low++}}
                    Write-A $f.Severity "$($f.Host)|$($f.Description)"}
                foreach($rm in $r.Remediations){$null=$script:RL.Add("$($j.T.Name)|$rm");$script:GS.Fixes++;Write-F "$($j.T.Name)|$rm"}
                if(@($r.Findings).Count -eq 0){Write-G "$($j.T.Name) clean"}
            }}else{Write-Warning "[TIMEOUT] $($j.T.Name)";$script:GS.Unreach++}
        }catch{Write-Warning "[ERR] $($j.T.Name): $_"}finally{$j.PS.Dispose()}
        $done++;Write-Progress -Activity Results -PercentComplete (($done/$jobs.Count)*100)
    }
    $pool.Close();$pool.Dispose();Write-Progress -Activity Results -Completed
}
#endregion

#region ═══ AD-WIDE CHECKS ═══
function Test-ADWide{
    Write-B "ПРОВЕРКИ ДОМЕНА / ЛЕСА"
    foreach($dom in $Domains){
        Write-S "Домен: $dom"
        $dc=(Get-ADDomainController -Discover -DomainName $dom -EA SilentlyContinue).HostName[0]
        if(-not $dc){Write-Warning "DC n/a: $dom";continue}

        try{$kb=Get-ADUser krbtgt -Properties PasswordLastSet -Server $dc
            $age=(New-TimeSpan -Start $kb.PasswordLastSet -End(Get-Date)).Days
            if($age -gt 180){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='GoldenTicket';Severity='HIGH';Description="[$dom] krbtgt: $age дней";Detail="Last: $($kb.PasswordLastSet)";Remediation='Double reset';MitreID='T1558.001'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] krbtgt $age d"}
            else{Write-G "[$dom] krbtgt ${age}d"}}catch{Write-Warning "krbtgt: $_"}

        try{$dn=(Get-ADDomain -Server $dc).DistinguishedName;$acl=Get-Acl "AD:\$dn"
            $rg=@('1131f6aa-9c07-11d1-f79f-00c04fc2dcd2','1131f6ad-9c07-11d1-f79f-00c04fc2dcd2')
            $lg=@('Domain Controllers','Enterprise Domain Controllers','Administrators','Enterprise Admins','Domain Admins')
            $sus=$acl.Access|?{$rg -contains $_.ObjectType.ToString() -and $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Value.Split('\')[-1] -notin $lg}
            foreach($s in $sus){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='ReplAbuse';Severity='CRITICAL';Description="[$dom] Repl ACL: $($s.IdentityReference)";Detail="$($s.ObjectType)";Remediation='Remove ACE';MitreID='T1003.006'});$script:GS.Crit++;$script:GS.Findings++;Write-A 'CRITICAL' "[$dom] Repl ACL abuse: $($s.IdentityReference)"
                if($Mode -eq 'Remediate'){$acl.RemoveAccessRule($s)|Out-Null;Set-Acl "AD:\$dn" $acl;$null=$script:RL.Add("AD|[$dom] Repl ACE removed: $($s.IdentityReference)");$script:GS.Fixes++}}
            if(-not $sus){Write-G "[$dom] Repl ACL OK"}}catch{Write-Warning "Repl ACL: $_"}

        try{$sh=Get-ADUser -Filter{SIDHistory -like '*'} -Properties SIDHistory -Server $dc
            foreach($u in $sh){foreach($sid in $u.SIDHistory){$rid=($sid -split '-')[-1]
                if($rid -in @('500','502','512','516','518','519','520')){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='SIDHistory';Severity='CRITICAL';Description="[$dom] SIDHistory priv RID: $($u.SamAccountName)";Detail="SID:$sid RID:$rid";Remediation='Remove SIDHistory';MitreID='T1134.005'});$script:GS.Crit++;$script:GS.Findings++;Write-A 'CRITICAL' "[$dom] SIDHist: $($u.SamAccountName)"}}}}catch{Write-Warning "SIDHistory: $_"}

        try{$npa=Get-ADUser -Filter{DoesNotRequirePreAuth -eq $true -and Enabled -eq $true} -Properties AdminCount -Server $dc
            foreach($u in $npa){$sv=if($u.AdminCount -eq 1){'CRITICAL'}else{'HIGH'}
                $null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='ASREP';Severity=$sv;Description="[$dom] ASREP: $($u.SamAccountName)";Detail="AC:$($u.AdminCount)";Remediation='Enable PreAuth';MitreID='T1558.004'});$script:GS.Findings++;if($sv -eq 'CRITICAL'){$script:GS.Crit++}else{$script:GS.High++};Write-A $sv "[$dom] ASREP: $($u.SamAccountName)"
                if($Mode -eq 'Remediate'){Set-ADAccountControl $u -DoesNotRequirePreAuth $false -Server $dc;$null=$script:RL.Add("AD|[$dom] PreAuth: $($u.SamAccountName)");$script:GS.Fixes++}}}catch{Write-Warning "ASREP: $_"}

        try{$uc=Get-ADComputer -Filter{TrustedForDelegation -eq $true -and Enabled -eq $true} -Properties TrustedForDelegation -Server $dc|?{$_.Name -ne(Get-ADDomainController -Discover -DomainName $dom).Name}
            foreach($c in $uc){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Deleg';Severity='HIGH';Description="[$dom] Unconstrained: $($c.Name)";Detail='TGT in memory';Remediation='Use RBCD';MitreID='T1558'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] Uncon: $($c.Name)"}}catch{Write-Warning "Deleg: $_"}

        try{$tr=Get-ADTrust -Filter * -Server $dc -EA SilentlyContinue
            foreach($t in $tr){if(-not $t.SIDFilteringQuarantined -and $t.TrustDirection -ne 'Outbound'){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Trust';Severity='HIGH';Description="[$dom] SIDFilter off: $($t.Target)";Detail="Dir:$($t.TrustDirection)";Remediation='netdom /quarantine:yes';MitreID='T1134.005'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] SIDFilt: $($t.Target)"}}}catch{Write-Warning "Trust: $_"}

        try{$pu=@(Get-ADGroupMember 'Protected Users' -Server $dc -EA SilentlyContinue)
            $da=@(Get-ADGroupMember 'Domain Admins' -Server $dc -EA SilentlyContinue)
            $np=$da|?{$_.SamAccountName -notin $pu.SamAccountName}
            if($np){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Config';Severity='MEDIUM';Description="[$dom] DA not in ProtectedUsers: $(($np.SamAccountName)-join', ')";Detail='';Remediation='Add to Protected Users';MitreID='T1550'});$script:GS.Med++;$script:GS.Findings++;Write-A 'MEDIUM' "[$dom] DA !PU: $(@($np).Count)"}}catch{Write-Warning "PU: $_"}
    }
}
#endregion

#region ═══ REPORT ═══
function Export-Report{
    Write-B "ОТЧЁТ"
    if(-not(Test-Path $OutputPath)){New-Item $OutputPath -ItemType Directory -Force|Out-Null}
    $rf=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').html"
    $af=@($script:AF)|Sort @{E={switch($_.Severity){'CRITICAL'{0}'HIGH'{1}'MEDIUM'{2}'LOW'{3}default{4}}}},Host
    $hg=$af|Group Host|Sort @{E={($_.Group|%{switch($_.Severity){'CRITICAL'{0}'HIGH'{1}'MEDIUM'{2}default{3}}}|Measure -Min).Minimum}}
    $tr=foreach($f in $af){$rc=switch($f.Severity){'CRITICAL'{'sc'}'HIGH'{'sh'}'MEDIUM'{'sm'}default{'sl'}};"<tr class='$rc'><td>$($f.Timestamp)</td><td>$($f.Host)</td><td>$($f.Role)</td><td><span class='b $rc'>$($f.Severity)</span></td><td>$($f.Category)</td><td>$($f.Description)</td><td class='d'>$($f.Detail)</td><td>$($f.Remediation)</td><td><code>$($f.MitreID)</code></td></tr>"}
    $hr=foreach($h in $hg){$c=@($h.Group|?{$_.Severity -eq 'CRITICAL'}).Count;$hi=@($h.Group|?{$_.Severity -eq 'HIGH'}).Count;$m=@($h.Group|?{$_.Severity -eq 'MEDIUM'}).Count;$rl=($h.Group|Select -First 1).Role;$cl=if($c){'sc'}elseif($hi){'sh'}elseif($m){'sm'}else{'sl'};"<tr class='$cl'><td>$($h.Name)</td><td>$rl</td><td>$c</td><td>$hi</td><td>$m</td><td>$($h.Count)</td></tr>"}
    $rr=foreach($r in $script:RL){"<tr><td>$r</td></tr>"}
    $el=(Get-Date)-$script:T0

    $html=@"
<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><title>InfraAudit $(Get-Date -F dd.MM.yyyy)</title>
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Segoe UI',system-ui,sans-serif;background:#080c14;color:#c9d1d9;padding:16px}.w{max-width:1600px;margin:0 auto}h1{color:#58a6ff;font-size:1.5em}h2{color:#8b949e;margin:28px 0 12px;border-bottom:1px solid #21262d;padding-bottom:6px}.mt{color:#6e7681;font-size:.85em;margin-bottom:20px}.gr{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin:16px 0}.cd{padding:14px;border-radius:6px;text-align:center}.cd .n{font-size:2em;font-weight:700}.cd .l{font-size:.78em;color:#8b949e}.ct{background:#161b22;border:1px solid #30363d}.ct .n{color:#58a6ff}.cs{background:#0d2818;border:1px solid #23883680}.cs .n{color:#3fb950}.cu{background:#1c1207;border:1px solid #9e6a0380}.cu .n{color:#d29922}.cc{background:#3d1418;border:1px solid #f8514940}.cc .n{color:#f85149}.ch{background:#341a04;border:1px solid #d2992240}.ch .n{color:#d29922}.cm{background:#0d1d31;border:1px solid #58a6ff40}.cm .n{color:#58a6ff}.cf{background:#0d1525;border:1px solid #388bfd40}.cf .n{color:#388bfd}table{width:100%;border-collapse:collapse;font-size:.8em;margin-top:8px}th{background:#0d1117;color:#58a6ff;padding:8px 6px;text-align:left;position:sticky;top:0;border-bottom:2px solid #21262d}td{padding:6px;border-bottom:1px solid #161b22;vertical-align:top}tr:hover{background:#161b2266}.b{padding:1px 6px;border-radius:3px;font-weight:600;font-size:.78em}.sc{color:#f85149}.sc .b{background:#3d1418}.sh{color:#d29922}.sh .b{background:#341a04}.sm{color:#58a6ff}.sm .b{background:#0d1d31}.sl{color:#8b949e}.d{max-width:250px;overflow-wrap:break-word;font-size:.82em;color:#6e7681}code{background:#161b22;padding:1px 4px;border-radius:2px;color:#79c0ff}.tb{display:flex;gap:0;margin:20px 0 0;border-bottom:2px solid #21262d}.t{padding:8px 18px;cursor:pointer;color:#8b949e;border-bottom:2px solid transparent;margin-bottom:-2px;font-size:.9em}.t.a{color:#58a6ff;border-bottom-color:#58a6ff}.p{display:none}.p.a{display:block}.ft{text-align:center;color:#484f58;font-size:.75em;margin-top:40px}</style></head>
<body><div class=w><h1>AD Infrastructure Security Audit</h1>
<div class=mt>Domains: $($Domains-join', ') | Mode: $Mode | Depth: ${HoursBack}h | $(Get-Date -F 'dd.MM.yyyy HH:mm') | $($el.ToString('hh\:mm\:ss'))</div>
<div class=gr><div class="cd ct"><div class=n>$($script:GS.Total)</div><div class=l>Total</div></div><div class="cd cs"><div class=n>$($script:GS.Scanned)</div><div class=l>Scanned</div></div><div class="cd cu"><div class=n>$($script:GS.Unreach)</div><div class=l>N/A</div></div><div class="cd cc"><div class=n>$($script:GS.Crit)</div><div class=l>CRIT</div></div><div class="cd ch"><div class=n>$($script:GS.High)</div><div class=l>HIGH</div></div><div class="cd cm"><div class=n>$($script:GS.Med)</div><div class=l>MED</div></div><div class="cd cf"><div class=n>$($script:GS.Fixes)</div><div class=l>FIX</div></div></div>
<div class=tb><div class="t a" onclick="st('h')">Hosts</div><div class=t onclick="st('f')">Findings</div><div class=t onclick="st('r')">Remediation</div><div class=t onclick="st('g')">Hardening</div></div>
<div id=th class="p a"><h2>By Host</h2><table><thead><tr><th>Host</th><th>Role</th><th>C</th><th>H</th><th>M</th><th>All</th></tr></thead><tbody>$($hr-join"`n")</tbody></table></div>
<div id=tf class=p><h2>Findings ($($script:GS.Findings))</h2><table><thead><tr><th>Time</th><th>Host</th><th>Role</th><th>Sev</th><th>Cat</th><th>Desc</th><th>Detail</th><th>Fix</th><th>MITRE</th></tr></thead><tbody>$($tr-join"`n")</tbody></table></div>
<div id=tr class=p><h2>Remediation ($($script:GS.Fixes))</h2>$(if($script:RL.Count){"<table><thead><tr><th>Action</th></tr></thead><tbody>$($rr-join"`n")</tbody></table>"}else{"<p style='color:#8b949e;padding:20px'>Mode: $Mode</p>"})</div>
<div id=tg class=p><h2>Hardening</h2><table><thead><tr><th>Control</th><th>Description</th><th>P</th></tr></thead><tbody><tr><td><b>LSA Protection</b></td><td>RunAsPPL=1</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Credential Guard</b></td><td>Hardware isolation</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Protected Users</b></td><td>DA/EA/SA</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Tiered Admin</b></td><td>T0/T1/T2</td><td><span class="b sc">P1</span></td></tr><tr><td><b>AES Kerberos</b></td><td>Disable RC4</td><td><span class="b sh">P2</span></td></tr><tr><td><b>NTLM Restrict</b></td><td>Audit then Deny</td><td><span class="b sh">P2</span></td></tr><tr><td><b>gMSA</b></td><td>SPN accounts</td><td><span class="b sh">P2</span></td></tr><tr><td><b>krbtgt</b></td><td>Double reset 90-180d</td><td><span class="b sh">P2</span></td></tr><tr><td><b>Sysmon+WEF</b></td><td>To SIEM</td><td><span class="b sh">P2</span></td></tr><tr><td><b>PS Logging</b></td><td>ScriptBlock+Module</td><td><span class="b sm">P3</span></td></tr><tr><td><b>LAPS</b></td><td>Local admin rotation</td><td><span class="b sm">P3</span></td></tr><tr><td><b>Spooler</b></td><td>Off on DC/servers</td><td><span class="b sm">P3</span></td></tr><tr><td><b>SMBv1</b></td><td>Disable</td><td><span class="b sm">P3</span></td></tr><tr><td><b>SID Filtering</b></td><td>All external trusts</td><td><span class="b sm">P3</span></td></tr></tbody></table></div>
<div class=ft>$(Get-Date -F 'dd.MM.yyyy HH:mm') | $($script:GS.Scanned)/$($script:GS.Total) | $($script:GS.Findings) findings | $($el.ToString('hh\:mm\:ss'))</div>
</div><script>function st(i){document.querySelectorAll('.t').forEach(t=>t.classList.remove('a'));document.querySelectorAll('.p').forEach(t=>t.classList.remove('a'));document.getElementById('t'+i).classList.add('a');event.target.classList.add('a')}</script></body></html>
"@
    $html|Out-File $rf -Encoding UTF8 -Force;Write-G "HTML: $rf"
    $csv=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').csv"
    @($af)|Export-Csv $csv -NoTypeInformation -Encoding UTF8;Write-G "CSV: $csv"
    $json=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').json"
    @{meta=@{time=Get-Date -F 'o';domains=$Domains;mode=$Mode;hours=$HoursBack;dur=[int]$el.TotalSeconds;stats=$script:GS};findings=@($af);fixes=@($script:RL)}|ConvertTo-Json -Depth 5|Out-File $json -Encoding UTF8;Write-G "JSON: $json"
}
#endregion

#region ═══ MAIN ═══
Write-Host "`n  AD INFRASTRUCTURE SECURITY SCANNER v1.1" -ForegroundColor Cyan
Write-Host "  $(Get-Date -F 'dd.MM.yyyy HH:mm:ss') | Mode: $Mode`n" -ForegroundColor DarkGray
try{
    Import-Module ActiveDirectory -EA Stop
    if(-not(Test-Path $OutputPath)){New-Item $OutputPath -ItemType Directory -Force|Out-Null}
    if($Mode -eq 'Report'){
        $lj=Get-ChildItem $OutputPath -Filter '*.json' -EA SilentlyContinue|Sort LastWriteTime -Desc|Select -First 1
        if($lj){$dt=Get-Content $lj.FullName -Raw|ConvertFrom-Json;foreach($f in $dt.findings){$null=$script:AF.Add($f)};$script:GS=$dt.meta.stats;Write-G "Loaded $(@($dt.findings).Count) from $($lj.Name)"}
        else{Write-Warning "No previous data. Full scan.";$Mode='Detect'}
    }
    if($Mode -ne 'Report'){Start-Scan;Test-ADWide}
    Export-Report
    Write-B "ИТОГО"
    Write-Host "  Hosts:    $($script:GS.Scanned)/$($script:GS.Total) (n/a: $($script:GS.Unreach))" -ForegroundColor White
    Write-Host "  CRITICAL: $($script:GS.Crit)" -ForegroundColor Red
    Write-Host "  HIGH:     $($script:GS.High)" -ForegroundColor Magenta
    Write-Host "  MEDIUM:   $($script:GS.Med)" -ForegroundColor DarkYellow
    Write-Host "  Fixes:    $($script:GS.Fixes)" -ForegroundColor Blue
    Write-Host "  Time:     $((Get-Date)-$script:T0|%{$_.ToString('hh\:mm\:ss')})" -ForegroundColor White
    Write-Host "  Reports:  $OutputPath`n" -ForegroundColor Cyan
    if($script:GS.Crit -gt 0){Write-Host "  *** CRITICAL IoC DETECTED — INVESTIGATE IMMEDIATELY ***`n" -ForegroundColor Red}
}catch{Write-Error "Fatal: $_";Write-Error $_.ScriptStackTrace;exit 1}
#endregion
MimiKatz, PowerShell, AD