From e2893f885d7d9c0738d714c65fa125f8baa809c9 Mon Sep 17 00:00:00 2001 From: Rey Date: Thu, 5 Nov 2026 14:01:13 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20de?= =?UTF-8?q?mosetup.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demosetup.sh | 863 --------------------------------------------------- 1 file changed, 863 deletions(-) delete mode 100644 demosetup.sh diff --git a/demosetup.sh b/demosetup.sh deleted file mode 100644 index 72e9007..0000000 --- a/demosetup.sh +++ /dev/null @@ -1,863 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# demo-setup.sh v3 — помощник настройки учебного стенда ДЭ 09.02.06 -# «Сетевое и системное администрирование». Целевая ОС: RED OS 8. -# Написан заново: NetworkManager (nmcli), firewalld, SELinux остаются включены. -# -# bash demo-setup.sh справка, ничего не меняет -# bash demo-setup.sh --dry-run меню; команды только показываются -# sudo bash demo-setup.sh --apply меню; команды выполняются -# -# Скрипт копируется на каждую ВМ вместе с demo-setup.conf. На ВМ выбираете -# её машину, затем отдельный пункт или весь модуль (М1/М2/М3). -# Перед применением сделайте снимок ВМ и работайте из консоли гипервизора. -# ============================================================================= -set -o pipefail -VERSION=3.0 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONF="${DEMO_CONF:-$SCRIPT_DIR/demo-setup.conf}" -LOG=/var/log/demo-setup.log -MODE=help -case ${1:-} in - --apply) MODE=apply ;; --dry-run|-n) MODE=dry ;; -h|--help|"") MODE=help ;; - *) echo "Неизвестный параметр: $1 (см. --help)" >&2; exit 2 ;; -esac -DRY=0; [[ $MODE == dry ]] && DRY=1 - -# ------------------------- параметры по умолчанию ---------------------------- -DOMAIN=au-team.irpo -TZ_NAME=Asia/Yekaterinburg -SSH_USER=sshuser; SSH_UID=2027; SSH_PORT=2027 -ADM_USER=net_admin -BANNER_TEXT='Authorized access only' -DNS_FWD=77.88.8.7; DNS_FWD2=77.88.8.3 -NTP_UPSTREAM=pool.ntp.org -ISO_SRC=/dev/sr0; ISO_DIR=/mnt/additional -AD_WORKGROUP=AU-TEAM; AD_ADMIN=Administrator -CLI_SSH_USER=sshuser; CLI_SSH_PORT=22 -RAID_LEVEL=0; RAID_MOUNT=/raid -APP_INNER_PORT=8000 -CERT_ALG=rsa -# WAN -ISP_HQ_IP=172.16.1.1; HQ_RTR_WAN_IP=172.16.1.2; HQ_WAN_PFX=28 -ISP_BR_IP=172.16.2.1; BR_RTR_WAN_IP=172.16.2.2; BR_WAN_PFX=28 -# HQ -VLAN_SRV=100; VLAN_CLI=200; VLAN_MGMT=999 -SRV_NET=192.168.100.0; SRV_PFX=27; HQ_RTR_SRV_IP=192.168.100.1; HQ_SRV_IP=192.168.100.2 -CLI_NET=192.168.200.0; CLI_PFX=28; HQ_RTR_CLI_IP=192.168.200.1 -DHCP_START=192.168.200.2; DHCP_END=192.168.200.14; HQ_CLI_IP=192.168.200.2; HQ_CLI_MAC= -MGMT_NET=192.168.99.0; MGMT_PFX=29; HQ_RTR_MGMT_IP=192.168.99.1; HQ_SW_MGMT_IP=192.168.99.2 -# BR -FW_NET=192.168.10.0; FW_PFX=30; BR_RTR_FW_IP=192.168.10.1; BR_FW_IP=192.168.10.2 -BR_NET=192.168.20.0; BR_PFX=28; BR_FW_LAN_IP=192.168.20.1; BR_SRV_IP=192.168.20.2 -# GRE / OSPF -GRE_IF=gre1; GRE_NET=10.10.10.0; GRE_PFX=30; HQ_GRE_IP=10.10.10.1; BR_GRE_IP=10.10.10.2 -OSPF_FW_AUTH=no -# DNS: имя=IP или имя=IP:ptr через пробел (пусто = по умолчанию) -DNS_RECORDS= -WEB_DNS_IP=; DOCKER_DNS_IP= -# Интерфейсы (запрашиваются при первой настройке) -ISP_WAN_IF=; ISP_HQ_IF=; ISP_BR_IF= -HQ_RTR_WAN_IF=; HQ_RTR_LAN_IF=; BR_RTR_WAN_IF=; BR_RTR_LAN_IF= -BR_FW_WAN_IF=; BR_FW_LAN_IF=; HQ_SW_UP_IF=; HQ_SW_SRV_IF=; HQ_SW_CLI_IF= -HQ_SRV_IF=; BR_SRV_IF=; HQ_CLI_IF= - -# Описание параметров: ПЕРЕМЕННАЯ|тип|подпись. Типы: ip ipo pfx port int name text yn alg list if -PARAMS=( -"DOMAIN|name|Домен" "TZ_NAME|text|Часовой пояс" "SSH_USER|name|Пользователь серверов" -"SSH_UID|int|UID пользователя серверов" "SSH_PORT|port|Порт SSH серверов" "ADM_USER|name|Пользователь маршрутизаторов" -"BANNER_TEXT|text|Баннер SSH" "DNS_FWD|ip|DNS-форвардер 1" "DNS_FWD2|ipo|DNS-форвардер 2 (можно пусто)" -"NTP_UPSTREAM|text|Внешний NTP для ISP (пусто = нет)" "ISO_SRC|text|Additional.iso: устройство или файл" "ISO_DIR|text|Точка монтирования ISO" -"AD_WORKGROUP|name|NetBIOS-имя домена" "AD_ADMIN|name|Администратор домена" -"CLI_SSH_USER|name|Пользователь HQ-CLI для Ansible" "CLI_SSH_PORT|port|Порт SSH HQ-CLI" -"RAID_LEVEL|int|Уровень RAID" "RAID_MOUNT|text|Точка монтирования RAID" "APP_INNER_PORT|port|Внутренний порт testapp" "CERT_ALG|alg|Сертификаты (rsa/gost)" -"ISP_HQ_IP|ip|ISP: IP в сторону HQ" "HQ_RTR_WAN_IP|ip|HQ-RTR: WAN IP" "HQ_WAN_PFX|pfx|Префикс ISP–HQ" -"ISP_BR_IP|ip|ISP: IP в сторону BR" "BR_RTR_WAN_IP|ip|BR-RTR: WAN IP" "BR_WAN_PFX|pfx|Префикс ISP–BR" -"VLAN_SRV|int|VLAN серверов" "VLAN_CLI|int|VLAN клиентов" "VLAN_MGMT|int|VLAN управления" -"SRV_NET|ip|Сеть серверов" "SRV_PFX|pfx|Префикс сети серверов" "HQ_RTR_SRV_IP|ip|HQ-RTR в VLAN серверов" "HQ_SRV_IP|ip|HQ-SRV" -"CLI_NET|ip|Сеть клиентов" "CLI_PFX|pfx|Префикс сети клиентов" "HQ_RTR_CLI_IP|ip|HQ-RTR в VLAN клиентов" -"DHCP_START|ip|Начало DHCP" "DHCP_END|ip|Конец DHCP" "HQ_CLI_IP|ip|HQ-CLI (DNS/Ansible)" "HQ_CLI_MAC|text|MAC HQ-CLI для резерва (пусто = нет)" -"MGMT_NET|ip|Сеть управления" "MGMT_PFX|pfx|Префикс управления" "HQ_RTR_MGMT_IP|ip|HQ-RTR в VLAN управления" "HQ_SW_MGMT_IP|ip|HQ-SW управление" -"FW_NET|ip|Сеть BR-RTR–BR-FW" "FW_PFX|pfx|Префикс BR-RTR–BR-FW" "BR_RTR_FW_IP|ip|BR-RTR к BR-FW" "BR_FW_IP|ip|BR-FW к BR-RTR" -"BR_NET|ip|Сеть BR-SRV" "BR_PFX|pfx|Префикс сети BR-SRV" "BR_FW_LAN_IP|ip|BR-FW в сети BR-SRV" "BR_SRV_IP|ip|BR-SRV" -"GRE_IF|name|Имя GRE-интерфейса" "GRE_NET|ip|Сеть GRE" "GRE_PFX|pfx|Префикс GRE" "HQ_GRE_IP|ip|HQ-RTR в туннеле" "BR_GRE_IP|ip|BR-RTR в туннеле" -"OSPF_FW_AUTH|yn|MD5 OSPF и на линке BR-FW (yes/no)" "DNS_RECORDS|list|DNS: имя=IP[:ptr] через пробел (пусто = по умолчанию)" -"WEB_DNS_IP|ipo|IP для web (пусто = ISP_BR_IP)" "DOCKER_DNS_IP|ipo|IP для docker (пусто = ISP_HQ_IP)" -) -IFACE_VARS=(ISP_WAN_IF ISP_HQ_IF ISP_BR_IF HQ_RTR_WAN_IF HQ_RTR_LAN_IF BR_RTR_WAN_IF BR_RTR_LAN_IF BR_FW_WAN_IF BR_FW_LAN_IF HQ_SW_UP_IF HQ_SW_SRV_IF HQ_SW_CLI_IF HQ_SRV_IF BR_SRV_IF HQ_CLI_IF) - -# ------------------------------- вывод --------------------------------------- -if [[ -t 1 ]]; then C_R=$'\e[31m'; C_G=$'\e[32m'; C_Y=$'\e[33m'; C_C=$'\e[36m'; C_B=$'\e[1m'; C_0=$'\e[0m'; else C_R=; C_G=; C_Y=; C_C=; C_B=; C_0=; fi -msg(){ echo "${C_C}==>${C_0} $*"; } -ok(){ echo "${C_G}[OK]${C_0} $*"; } -warn(){ echo "${C_Y}[!]${C_0} $*"; } -err(){ echo "${C_R}[ОШИБКА]${C_0} $*" >&2; } -hdr(){ echo; echo "${C_B}===== $* =====${C_0}"; } -have(){ command -v "$1" >/dev/null 2>&1; } -pause(){ read -r -p "Enter — продолжить..." _ || true; } -yesno(){ local q=$1 d=${2:-n} a h; [[ $d == y ]] && h="Y/n" || h="y/N" - read -r -p "$q [$h] " a || return 1; a=${a:-$d}; [[ $a =~ ^[YyДд] ]]; } - -usage(){ cat <&2; exit 1; } - [[ -t 0 ]] || { echo "--apply запускается только из интерактивной консоли." >&2; exit 1; } - grep -qi 'red' /etc/os-release 2>/dev/null || warn "Похоже, это не RED OS — скрипт рассчитан на RED OS 8." - touch "$LOG" && chmod 600 "$LOG" && exec > >(tee -a "$LOG") 2>&1 -else - warn "РЕЖИМ ПРОСМОТРА: изменяющие команды только выводятся (строки с '+')." -fi - -# --------------------------- выполнение команд ------------------------------- -# run КОМАНДА... — в режиме просмотра печатает команду, иначе выполняет. -exec 3>&1 -run(){ if ((DRY)); then { printf '%s+' "$C_C"; printf ' %q' "$@"; printf '%s\n' "$C_0"; } >&3; return 0; fi; "$@"; } -# put ФАЙЛ [ПРАВА] < "$f.demo-tmp") && chmod "$m" "$f.demo-tmp" && mv -f "$f.demo-tmp" "$f"; } -# backup ФАЙЛ — один раз сохраняет исходный файл как ФАЙЛ.demo-orig. -backup(){ [[ -f $1 && ! -e $1.demo-orig ]] && { ((DRY)) || cp -a "$1" "$1.demo-orig"; }; return 0; } -# set_kv ФАЙЛ КЛЮЧ ЗНАЧЕНИЕ — заменяет или добавляет строку «КЛЮЧ=ЗНАЧЕНИЕ». -set_kv(){ local f=$1 k=$2 v=$3 - if ((DRY)); then echo "${C_C}+ $f: $k=$v${C_0}"; return 0; fi - backup "$f"; touch "$f" || return 1 - if grep -qE "^[#[:space:]]*$k=" "$f"; then sed -i -E "s|^[#[:space:]]*$k=.*|$k=$v|" "$f"; else echo "$k=$v" >> "$f"; fi; } -pkg(){ local p missing=(); for p in "$@"; do rpm -q "$p" >/dev/null 2>&1 || missing+=("$p"); done - ((${#missing[@]})) || return 0; msg "Установка пакетов: ${missing[*]}" - run dnf install -y "${missing[@]}" || { err "Не удалось установить: ${missing[*]}"; return 1; }; } -# pkg_any "пакеты варианта 1" "пакеты варианта 2" ... — первый установившийся вариант. -pkg_any(){ local v; for v in "$@"; do read -ra P <<< "$v"; pkg "${P[@]}" 2>/dev/null && return 0; done; err "Ни один вариант пакетов не установился"; return 1; } -svc(){ local s; for s in "$@"; do if ((DRY)) || systemctl cat "$s" >/dev/null 2>&1; then - run systemctl enable "$s" && run systemctl restart "$s" && { ok "Служба $s запущена"; return 0; }; fi; done - err "Не удалось запустить службу: $*"; return 1; } - -# ------------------------------- проверки ------------------------------------ -is_ip(){ local IFS=. o; [[ $1 =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1; for o in $1; do ((10#$o <= 255)) || return 1; done; } -is_pfx(){ [[ $1 =~ ^([0-9]|[12][0-9]|3[0-2])$ ]]; } -is_port(){ [[ $1 =~ ^[0-9]{1,5}$ ]] && (($1 >= 1 && $1 <= 65535)); } -is_name(){ [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; } -is_iface(){ [[ $1 =~ ^[A-Za-z0-9._@-]{1,15}$ && -e /sys/class/net/$1 ]]; } -valid(){ local t=$1 v=$2 - case $t in - ip) is_ip "$v" ;; ipo) [[ -z $v ]] || is_ip "$v" ;; pfx) is_pfx "$v" ;; port) is_port "$v" ;; - int) [[ $v =~ ^[0-9]{1,6}$ ]] ;; name) is_name "$v" ;; yn) [[ $v == yes || $v == no ]] ;; alg) [[ $v == rsa || $v == gost ]] ;; - if) is_iface "$v" ;; text|list) [[ $v =~ ^[A-Za-z0-9_./:@,+\ =-]*$ ]] ;; *) return 1 ;; - esac; } -pfx2mask(){ local p=$1 m="" i b; is_pfx "$p" || return 1 - for i in 1 2 3 4; do b=$(( p >= 8 ? 8 : (p > 0 ? p : 0) )); m+=$(( 256 - (1 << (8 - b)) )); ((i < 4)) && m+=.; p=$(( p - 8 )); done; echo "$m"; } -rev_zone(){ local IFS=.; set -- $1; echo "$3.$2.$1"; } -fqdn(){ echo "$1.$DOMAIN"; } -REALM(){ echo "${DOMAIN^^}"; } -param_type(){ local e; for e in "${PARAMS[@]}"; do [[ ${e%%|*} == "$1" ]] && { e=${e#*|}; echo "${e%%|*}"; return; }; done - local i; for i in "${IFACE_VARS[@]}"; do [[ $i == "$1" ]] && { echo if; return; }; done; } - -# ------------------------------- параметры ----------------------------------- -conf_load(){ [[ -f $CONF ]] || return 0 - if [[ $MODE == apply ]]; then - [[ $(stat -c '%u %a' "$CONF") == "0 600" ]] || { err "$CONF должен принадлежать root и иметь права 600 (chown root: и chmod 600)"; exit 1; } - fi - local line k v t n=0 - while IFS= read -r line || [[ -n $line ]]; do n=$((n+1)) - [[ -z $line || $line == \#* ]] && continue - k=${line%%=*}; v=${line#*=} - t=$(param_type "$k"); [[ -n $t ]] || { err "$CONF:$n: неизвестный параметр $k"; exit 1; } - [[ $t == if ]] && { [[ -z $v || $v =~ ^[A-Za-z0-9._@-]{1,15}$ ]] || { err "$CONF:$n: плохое имя интерфейса"; exit 1; }; printf -v "$k" '%s' "$v"; continue; } - valid "$t" "$v" || { err "$CONF:$n: недопустимое значение $k"; exit 1; } - printf -v "$k" '%s' "$v" - done < "$CONF"; } -conf_save(){ local e k tmp - if ((DRY)); then return 0; fi - tmp=$(mktemp "$CONF.XXXX") || return 1 - { echo "# demo-setup v$VERSION — параметры стенда (без паролей)" - for e in "${PARAMS[@]}"; do k=${e%%|*}; echo "$k=${!k}"; done - for k in "${IFACE_VARS[@]}"; do echo "$k=${!k}"; done; } > "$tmp" - chmod 600 "$tmp" && mv -f "$tmp" "$CONF"; } -# ask ПЕРЕМЕННАЯ "вопрос" — спрашивает значение с проверкой и сохраняет. -ask(){ local v=$1 q=$2 t a; t=$(param_type "$v"); t=${t:-text} - while :; do read -r -p "$q [${!v}]: " a || return 1; a=${a:-${!v}} - if valid "$t" "$a"; then printf -v "$v" '%s' "$a"; conf_save; return 0; fi - warn "Неверное значение (ожидается: $t)"; done; } -ask_iface(){ local v=$1 q=$2 a - echo "Интерфейсы этой ВМ:"; ip -br link 2>/dev/null | grep -vE '^(lo|ovs-system)[[:space:]]' | sed 's/^/ /' - ((DRY)) && [[ -z ${!v} ]] && printf -v "$v" '%s' "eth0" - while :; do read -r -p "$q [${!v}]: " a || return 1; a=${a:-${!v}} - if is_iface "$a" || { ((DRY)) && [[ $a =~ ^[A-Za-z0-9._@-]{1,15}$ ]]; }; then printf -v "$v" '%s' "$a"; conf_save; return 0; fi - warn "Интерфейс $a не найден"; done; } -need_iface(){ [[ -n ${!1} ]] && { is_iface "${!1}" || ((DRY)); } && return 0; ask_iface "$1" "$2"; } -settings_menu(){ local i e k c - while :; do hdr "Параметры ($CONF)"; i=1 - for e in "${PARAMS[@]}"; do k=${e%%|*}; printf '%3d) %-44s %s\n' "$i" "${e##*|}" "${!k}"; i=$((i+1)); done - read -r -p "Номер параметра для изменения (0 — назад): " c || return - [[ $c == 0 || -z $c ]] && return - [[ $c =~ ^[0-9]+$ ]] && ((c >= 1 && c <= ${#PARAMS[@]})) || { warn "Нет такого номера"; continue; } - e=${PARAMS[c-1]}; ask "${e%%|*}" "${e##*|}"; done; } - -PASS= -need_pass(){ [[ -n $PASS ]] && return 0 - if ((DRY)); then PASS='DryRun.Pass1'; return 0; fi - local p2; echo "Нужен общий пароль стенда (не сохраняется; 8–64 символа: A-Z a-z 0-9 @ . _ ! + -)." - read -r -s -p "Пароль: " PASS; echo; read -r -s -p "Повторите: " p2; echo - [[ $PASS == "$p2" && ${#PASS} -ge 8 && ${#PASS} -le 64 && $PASS =~ ^[A-Za-z0-9@._!+-]+$ ]] || { PASS=; err "Пароли не совпадают или недопустимы"; return 1; }; } -over_ssh_warn(){ [[ -n ${SSH_CONNECTION:-} ]] && warn "Вы подключены по SSH: изменение сети/SSH может оборвать сеанс. Лучше работать из консоли."; return 0; } -conf_load - -# ============================== сеть (nmcli) ================================= -nm_ready(){ ((DRY)) && return 0; systemctl is-active --quiet NetworkManager || { err "NetworkManager не запущен"; return 1; }; } -nm_has(){ nmcli -t -f NAME con show 2>/dev/null | grep -Fxq "$1"; } -# Отключает автоподключение чужих профилей интерфейса (не удаляет их). -nm_quiet_others(){ local ifc=$1 keep=$2 line name dev - while IFS=: read -r name dev; do - [[ $name == "$keep" || -z $name ]] && continue - [[ $dev == "$ifc" || $(nmcli -g connection.interface-name con show "$name" 2>/dev/null) == "$ifc" ]] || continue - warn "Профиль «$name» на $ifc: автоподключение будет выключено (профиль не удаляется)" - run nmcli con mod "$name" connection.autoconnect no || return 1 - done < <(nmcli -t -f NAME,DEVICE con show 2>/dev/null); } -# nm_set ПРОФИЛЬ "аргументы add" ИНТЕРФЕЙС_ДЛЯ_ЧУЖИХ режим адрес шлюз dns -# режим: static | dhcp | none (без IP — порт моста/родитель VLAN) -nm_set(){ local c=$1 addargs=$2 ifc=$3 mode=$4 addr=${5-} gw=${6-} dns=${7-} rc - nm_ready || return 1 - [[ -n $ifc ]] && { nm_quiet_others "$ifc" "$c" || return 1; } - if ! nm_has "$c"; then read -ra A <<< "$addargs"; run nmcli con add con-name "$c" "${A[@]}" || { err "nmcli: не создан профиль $c"; return 1; }; fi - case $mode in - static) run nmcli con mod "$c" ipv4.method manual ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns" ipv4.ignore-auto-dns yes ;; - dhcp) run nmcli con mod "$c" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" ipv4.ignore-auto-dns no ;; - none) run nmcli con mod "$c" ipv4.method disabled ipv4.addresses "" ipv4.gateway "" ;; - esac; rc=$? - ((rc == 0)) && run nmcli con mod "$c" ipv6.method disabled connection.autoconnect yes; rc=$((rc + $?)) - ((rc == 0)) || { err "nmcli: не удалось изменить профиль $c"; return 1; } - run nmcli con up "$c" || { err "nmcli: профиль $c не поднялся"; return 1; } - ok "Профиль $c: $mode ${addr:-}"; } -net_eth(){ nm_set "demo-$1" "type ethernet ifname $1" "$1" "${@:2}"; } -net_vlan(){ local p=$1 vid=$2; shift 2; nm_set "demo-$p.$vid" "type vlan ifname $p.$vid dev $p id $vid" "" "$@"; } -net_gre(){ local l=$1 r=$2 addr=$3 - nm_set "demo-$GRE_IF" "type ip-tunnel ifname $GRE_IF mode gre local $l remote $r" "" static "$addr" || return 1 - run nmcli con mod "demo-$GRE_IF" ip-tunnel.ttl 64 && run nmcli con up "demo-$GRE_IF"; } -set_zone(){ local c=$1 z=$2 ifc=$3 - ((DRY)) || systemctl is-active --quiet firewalld || return 0 - run nmcli con mod "$c" connection.zone "$z" && run firewall-cmd --zone="$z" --change-interface="$ifc" >/dev/null && ok "$ifc → зона $z"; } -enable_forward(){ put /etc/sysctl.d/90-demo-forward.conf <<< 'net.ipv4.ip_forward = 1' && run sysctl -p /etc/sysctl.d/90-demo-forward.conf >/dev/null && ok "Маршрутизация IPv4 включена"; } - -# ============================== firewalld ==================================== -fw_on(){ ((DRY)) && return 0; systemctl is-active --quiet firewalld && return 0 - warn "firewalld не запущен."; yesno "Включить firewalld (нужен для NAT/DNAT)?" y || return 1 - pkg firewalld && run systemctl enable --now firewalld; } -fw(){ run firewall-cmd --permanent "$@" >/dev/null || { err "firewall-cmd $*"; return 1; }; } -fw_reload(){ run firewall-cmd --reload >/dev/null && ok "firewalld перезагружен"; } -# fw_open сервис|порт/proto ... — открывает на серверах в зоне по умолчанию (если firewalld активен). -fw_open(){ ((DRY)) || systemctl is-active --quiet firewalld || return 0; local x - for x in "$@"; do if [[ $x == */* ]]; then fw --add-port="$x"; else fw --add-service="$x" 2>/dev/null || warn "В firewalld нет сервиса $x"; fi; done - fw_reload; } - -# ============================== общие пункты ================================= -MACHINE= -c_hostname(){ local h; h=$(fqdn "$MACHINE"); run hostnamectl set-hostname "$h" || return 1; ok "Имя хоста: $h"; } -c_tz(){ run timedatectl set-timezone "$TZ_NAME" || return 1; ok "Часовой пояс: $TZ_NAME"; date; } -sudo_nopass(){ local u=$1 f="/etc/sudoers.d/demo-$1" - pkg sudo || return 1 - put "$f" 440 <<< "$u ALL=(ALL:ALL) NOPASSWD: ALL" || return 1 - ((DRY)) || visudo -cf "$f" >/dev/null || { err "Ошибка sudoers — файл удалён"; rm -f "$f"; return 1; } - ok "sudo без пароля: $u"; } -# mkuser ИМЯ [UID] — создаёт пользователя; существующего не трогает без согласия. -mkuser(){ local u=$1 uid=${2-}; need_pass || return 1 - if id "$u" >/dev/null 2>&1; then - warn "Пользователь $u уже существует ($(id "$u"))." - yesno "Задать ему общий пароль и sudo?" n || return 0 - else run useradd -m -s /bin/bash ${uid:+-u "$uid"} "$u" || return 1; fi - if ((DRY)); then echo "${C_C}+ chpasswd ($u:***)${C_0}"; else printf '%s:%s\n' "$u" "$PASS" | chpasswd || return 1; fi - sudo_nopass "$u"; ok "Пользователь $u готов"; } -u_srv(){ mkuser "$SSH_USER" "$SSH_UID"; } -u_rtr(){ mkuser "$ADM_USER"; } -selinux_on(){ have getenforce && [[ $(getenforce) == Enforcing ]]; } -se_port(){ selinux_on || return 0; pkg policycoreutils-python-utils || return 1 - run semanage port -a -t "$1" -p tcp "$2" 2>/dev/null || run semanage port -m -t "$1" -p tcp "$2" || return 1; ok "SELinux: tcp/$2 → $1"; } -sshd_include(){ local c=/etc/ssh/sshd_config - grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config.d/\*\.conf' "$c" 2>/dev/null && return 0 - ((DRY)) && { echo "${C_C}+ добавить Include sshd_config.d в начало $c${C_0}"; return 0; } - backup "$c"; sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' "$c"; } -sshd_apply(){ local port=$1 - ((DRY)) || sshd -t || { err "sshd -t: ошибка конфигурации, служба не перезапущена"; return 1; } - [[ $port != 22 ]] && { se_port ssh_port_t "$port" || return 1; } - fw_open "$port/tcp" - over_ssh_warn; run systemctl enable sshd && run systemctl restart sshd || return 1 - ((DRY)) || ss -tln | grep -q ":$port " || { err "sshd не слушает порт $port"; return 1; } - ok "SSH слушает порт $port"; } -# Защищённый SSH серверов: порт, только SSH_USER, 2 попытки, баннер. -c_sshd(){ pkg openssh-server || return 1; sshd_include || return 1 - put /etc/ssh/demo-banner <<< "$BANNER_TEXT" || return 1 - put /etc/ssh/sshd_config.d/10-demo.conf 600 <> "$f"; fi - svc chronyd || return 1; ((DRY)) || { sleep 3; chronyc sources; }; ok "NTP-клиент → $srv"; } -mount_iso(){ ((DRY)) && return 0; mountpoint -q "$ISO_DIR" && return 0; mkdir -p "$ISO_DIR" - if [[ -f $ISO_SRC ]]; then mount -o loop,ro "$ISO_SRC" "$ISO_DIR"; else mount -o ro "$ISO_SRC" "$ISO_DIR"; fi 2>/dev/null \ - && ok "ISO смонтирован в $ISO_DIR" || { err "Не смонтирован $ISO_SRC — подключите Additional.iso или измените ISO_SRC"; return 1; }; } -iso_find(){ mount_iso || return 1; ((DRY)) && { echo "$ISO_DIR/$1"; return 0; }; find "$ISO_DIR" -iname "$1" 2>/dev/null | head -1; } -os_prep(){ pkg_any "bind-utils tcpdump traceroute curl wget tar nano policycoreutils-python-utils" "bind-utils curl tar nano" || return 1 - echo "SELinux: $(getenforce 2>/dev/null || echo нет) firewalld: $(systemctl is-active firewalld 2>/dev/null)" - ok "Утилиты установлены (firewalld и SELinux не отключаются)"; } - -# frr_ospf ROUTER_ID "интерфейсы с MD5" "интерфейсы без MD5" СЕТЬ... -frr_ospf(){ local rid=$1 auth=$2 plain=$3 i n; shift 3; pkg frr || return 1; need_pass || return 1 - set_kv /etc/frr/daemons ospfd yes || return 1 - local key=${PASS:0:16} - { echo "frr defaults traditional"; echo "hostname ${MACHINE:-$(hostname -s 2>/dev/null)}"; echo "log syslog informational"; echo "!" - for i in $auth; do echo "interface $i"; echo " ip ospf network point-to-point"; echo " ip ospf mtu-ignore" - echo " ip ospf authentication message-digest"; echo " ip ospf message-digest-key 1 md5 $key"; echo "!"; done - for i in $plain; do echo "interface $i"; echo " ip ospf network point-to-point"; echo "!"; done - echo "router ospf"; echo " ospf router-id $rid"; echo " passive-interface default" - for i in $auth $plain; do echo " no passive-interface $i"; done - for n in "$@"; do echo " network $n area 0"; done; echo "!"; } | put /etc/frr/frr.conf 640 || return 1 - ((DRY)) || chown frr:frr /etc/frr/frr.conf 2>/dev/null - svc frr || return 1 - ok "OSPF: router-id $rid; ключ MD5 = первые 16 символов общего пароля" - ((DRY)) || { sleep 5; vtysh -c 'show ip ospf neighbor'; } - warn "Соседство появится, когда OSPF настроен на обоих концах."; } - -# =================================== ISP ===================================== -isp_net(){ over_ssh_warn - need_iface ISP_WAN_IF "Интерфейс в Интернет (DHCP)"; need_iface ISP_HQ_IF "Интерфейс к HQ-RTR"; need_iface ISP_BR_IF "Интерфейс к BR-RTR" - ask ISP_HQ_IP "IP в сторону HQ"; ask HQ_WAN_PFX "Префикс"; ask ISP_BR_IP "IP в сторону BR"; ask BR_WAN_PFX "Префикс" - net_eth "$ISP_WAN_IF" dhcp || return 1 - net_eth "$ISP_HQ_IF" static "$ISP_HQ_IP/$HQ_WAN_PFX" || return 1 - net_eth "$ISP_BR_IF" static "$ISP_BR_IP/$BR_WAN_PFX" || return 1 - enable_forward; } -isp_nat(){ fw_on || return 1 - set_zone "demo-$ISP_WAN_IF" external "$ISP_WAN_IF" && set_zone "demo-$ISP_HQ_IF" trusted "$ISP_HQ_IF" && set_zone "demo-$ISP_BR_IF" trusted "$ISP_BR_IF" || return 1 - fw --zone=external --add-masquerade && fw_reload && enable_forward && ok "NAT: $ISP_WAN_IF (зона external, masquerade)"; } -isp_chrony(){ pkg chrony || return 1 - { echo "# demo-setup: NTP-сервер ISP"; [[ -n $NTP_UPSTREAM ]] && echo "server $NTP_UPSTREAM iburst" - echo "local stratum 5"; echo "allow $ISP_HQ_IP/$HQ_WAN_PFX"; echo "allow $ISP_BR_IP/$BR_WAN_PFX" - echo "driftfile /var/lib/chrony/drift"; echo "makestep 1.0 3"; echo "rtcsync"; } | put /etc/chrony.conf || return 1 - svc chronyd && ok "chrony: сервер stratum 5 для HQ и BR"; } -ngx_server(){ # ngx_server ИМЯ БЭКЕНД https(yes/no) auth(yes/no) - local n=$1 b=$2 s=$3 a=$4 - echo "server {" - if [[ $s == yes ]]; then echo " listen 443 ssl;"; echo " ssl_certificate /etc/nginx/ssl/$n.crt;"; echo " ssl_certificate_key /etc/nginx/ssl/$n.key;" - else echo " listen 80;"; fi - echo " server_name $(fqdn "$n");"; echo " location / {"; echo " proxy_pass http://$b;" - echo " proxy_set_header Host \$host;"; echo " proxy_set_header X-Real-IP \$remote_addr;" - echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" - [[ $a == yes ]] && { echo " auth_basic \"Restricted\";"; echo " auth_basic_user_file /etc/nginx/.htpasswd;"; } - echo " }"; echo "}" - [[ $s == yes ]] && echo "server { listen 80; server_name $(fqdn "$n"); return 301 https://\$host\$request_uri; }"; return 0; } -ngx_write(){ local s=no a=no; [[ -f /etc/nginx/ssl/web.crt ]] && s=yes; [[ -f /etc/nginx/.htpasswd ]] && a=yes - pkg nginx || return 1 - { echo "# demo-setup: обратный прокси web/docker"; ngx_server web "$HQ_RTR_WAN_IP:8080" "$s" "$a"; ngx_server docker "$BR_RTR_WAN_IP:8080" "$s" no; } \ - | put /etc/nginx/conf.d/demo-proxy.conf || return 1 - selinux_on && { run setsebool -P httpd_can_network_connect 1 || return 1; } - ((DRY)) || nginx -t || { err "nginx -t: ошибка конфигурации"; return 1; } - svc nginx && ok "nginx: web → $HQ_RTR_WAN_IP:8080, docker → $BR_RTR_WAN_IP:8080 (HTTPS: $s, auth: $a)"; } -isp_proxy(){ ngx_write; } -isp_auth(){ need_pass || return 1; pkg nginx openssl || return 1 - local h; h=$(openssl passwd -apr1 -stdin <<< "$PASS") || return 1 - put /etc/nginx/.htpasswd 640 <<< "WEB:$h" || return 1; ((DRY)) || chgrp nginx /etc/nginx/.htpasswd - ok "Basic-аутентификация: пользователь WEB"; ngx_write; } -isp_https(){ run mkdir -p /etc/nginx/ssl || return 1 - msg "Копирую сертификаты с HQ-SRV через DNAT $HQ_RTR_WAN_IP:$SSH_PORT ($SSH_USER, нужен пароль)" - run scp -P "$SSH_PORT" -o StrictHostKeyChecking=accept-new "$SSH_USER@$HQ_RTR_WAN_IP:certs/{web,docker}.{crt,key}" /etc/nginx/ssl/ \ - || { err "Не скопировано. Положите web.crt web.key docker.crt docker.key в /etc/nginx/ssl вручную"; return 1; } - run chmod 600 /etc/nginx/ssl/web.key /etc/nginx/ssl/docker.key && ngx_write; } -chk_isp(){ ip -br a; ip r; firewall-cmd --get-active-zones 2>/dev/null; chronyc tracking 2>/dev/null | head -3 - systemctl is-active nginx chronyd 2>/dev/null; ping -c2 -W1 "$HQ_RTR_WAN_IP"; ping -c2 -W1 "$BR_RTR_WAN_IP"; } -M_isp=( -"М1|Сеть: Интернет по DHCP, адреса к HQ и BR|isp_net" "М1|NAT в Интернет (firewalld)|isp_nat" "М1|Часовой пояс|c_tz" -"М2|NTP-сервер chrony (stratum 5)|isp_chrony" "М3|nginx: обратный прокси web и docker|isp_proxy" -"М3|Basic-аутентификация WEB для web|isp_auth" "М3|HTTPS (сертификаты с HQ-SRV)|isp_https") - -# ================================= HQ-RTR ==================================== -hqr_net(){ over_ssh_warn - need_iface HQ_RTR_WAN_IF "WAN-интерфейс (к ISP)"; need_iface HQ_RTR_LAN_IF "LAN-интерфейс (транк к HQ-SW)" - ask HQ_RTR_WAN_IP "WAN IP"; ask HQ_WAN_PFX "Префикс"; ask ISP_HQ_IP "Шлюз (ISP)" - net_eth "$HQ_RTR_WAN_IF" static "$HQ_RTR_WAN_IP/$HQ_WAN_PFX" "$ISP_HQ_IP" "$DNS_FWD" || return 1 - net_eth "$HQ_RTR_LAN_IF" none || return 1 - net_vlan "$HQ_RTR_LAN_IF" "$VLAN_SRV" static "$HQ_RTR_SRV_IP/$SRV_PFX" || return 1 - net_vlan "$HQ_RTR_LAN_IF" "$VLAN_CLI" static "$HQ_RTR_CLI_IP/$CLI_PFX" || return 1 - net_vlan "$HQ_RTR_LAN_IF" "$VLAN_MGMT" static "$HQ_RTR_MGMT_IP/$MGMT_PFX" || return 1 - enable_forward; } -rtr_zones(){ local wan=$1 lan=$2 v; fw_on || return 1 - set_zone "demo-$wan" external "$wan" || return 1 - [[ -n $lan ]] && { set_zone "demo-$lan" trusted "$lan" || return 1; } - for v in "${@:3}"; do set_zone "demo-$v" trusted "$v" || return 1; done; } -hqr_vlans(){ echo "$HQ_RTR_LAN_IF.$VLAN_SRV $HQ_RTR_LAN_IF.$VLAN_CLI $HQ_RTR_LAN_IF.$VLAN_MGMT"; } -hqr_nat(){ need_iface HQ_RTR_WAN_IF "WAN-интерфейс"; need_iface HQ_RTR_LAN_IF "LAN-интерфейс" - rtr_zones "$HQ_RTR_WAN_IF" "$HQ_RTR_LAN_IF" $(hqr_vlans) || return 1 - fw --zone=external --add-masquerade && fw_reload && enable_forward && ok "NAT через $HQ_RTR_WAN_IF"; } -gre_up(){ local l=$1 r=$2 a=$3; fw_on || return 1 - net_gre "$l" "$r" "$a" || return 1 - set_zone "demo-$GRE_IF" trusted "$GRE_IF"; fw --zone=external --add-protocol=gre && fw_reload && ok "GRE $GRE_IF: $l → $r"; } -hqr_gre(){ gre_up "$HQ_RTR_WAN_IP" "$BR_RTR_WAN_IP" "$HQ_GRE_IP/$GRE_PFX"; } -hqr_ospf(){ frr_ospf "$HQ_GRE_IP" "$GRE_IF" "" "$GRE_NET/$GRE_PFX" "$SRV_NET/$SRV_PFX" "$CLI_NET/$CLI_PFX" "$MGMT_NET/$MGMT_PFX"; } -hqr_dhcp(){ need_iface HQ_RTR_LAN_IF "LAN-интерфейс (транк)"; pkg dnsmasq || return 1 - ask DHCP_START "Начало пула"; ask DHCP_END "Конец пула"; ask HQ_CLI_MAC "MAC HQ-CLI для резервирования (пусто = нет)" - { echo "# demo-setup: DHCP для VLAN $VLAN_CLI"; echo "port=0"; echo "interface=$HQ_RTR_LAN_IF.$VLAN_CLI"; echo "bind-interfaces" - echo "dhcp-authoritative"; echo "dhcp-range=$DHCP_START,$DHCP_END,$(pfx2mask "$CLI_PFX"),12h" - echo "dhcp-option=option:router,$HQ_RTR_CLI_IP"; echo "dhcp-option=option:dns-server,$HQ_SRV_IP" - echo "dhcp-option=option:domain-name,$DOMAIN"; echo "domain=$DOMAIN" - [[ -n $HQ_CLI_MAC ]] && echo "dhcp-host=$HQ_CLI_MAC,$HQ_CLI_IP"; true; } | put /etc/dnsmasq.conf || return 1 - ((DRY)) || dnsmasq --test || return 1 - svc dnsmasq && ok "DHCP: $DHCP_START–$DHCP_END, шлюз $HQ_RTR_CLI_IP, DNS $HQ_SRV_IP"; } -dnat(){ local p=$1 to=$2 tp=$3; fw --zone=external --add-forward-port="port=$p:proto=tcp:toport=$tp:toaddr=$to" && ok "DNAT tcp/$p → $to:$tp"; } -hqr_dnat(){ fw_on || return 1; dnat 8080 "$HQ_SRV_IP" 80 && dnat "$SSH_PORT" "$HQ_SRV_IP" "$SSH_PORT" && fw_reload; } -hqr_ntp(){ chrony_client "$ISP_HQ_IP"; } -chk_rtr(){ ip -br a; ip r; firewall-cmd --get-active-zones 2>/dev/null; firewall-cmd --zone=external --list-all 2>/dev/null - vtysh -c 'show ip ospf neighbor' 2>/dev/null; ping -c2 -W1 "$DNS_FWD"; } -chk_hqr(){ chk_rtr; ping -c2 -W1 "$BR_GRE_IP"; ping -c2 -W1 "$HQ_SRV_IP"; } -M_hqr=( -"М1|Сеть: WAN и VLAN $VLAN_SRV/$VLAN_CLI/$VLAN_MGMT (router-on-a-stick)|hqr_net" "М1|Пользователь $ADM_USER (sudo без пароля)|u_rtr" -"М1|NAT в сторону ISP|hqr_nat" "М1|GRE-туннель до BR-RTR|hqr_gre" "М1|OSPF по туннелю (MD5)|hqr_ospf" -"М1|DHCP для HQ-CLI|hqr_dhcp" "М1|Часовой пояс|c_tz" -"М2|SSH с паролем (Ansible)|c_sshd_basic" "М2|NTP-клиент → ISP|hqr_ntp" "М2|DNAT 8080 → web, $SSH_PORT → HQ-SRV|hqr_dnat") - -# ================================= BR-RTR ==================================== -brr_net(){ over_ssh_warn - need_iface BR_RTR_WAN_IF "WAN-интерфейс (к ISP)"; need_iface BR_RTR_LAN_IF "Интерфейс к BR-FW" - ask BR_RTR_WAN_IP "WAN IP"; ask BR_WAN_PFX "Префикс"; ask ISP_BR_IP "Шлюз (ISP)"; ask BR_RTR_FW_IP "IP к BR-FW"; ask FW_PFX "Префикс" - net_eth "$BR_RTR_WAN_IF" static "$BR_RTR_WAN_IP/$BR_WAN_PFX" "$ISP_BR_IP" "$DNS_FWD" || return 1 - net_eth "$BR_RTR_LAN_IF" static "$BR_RTR_FW_IP/$FW_PFX" || return 1 - enable_forward; } -brr_nat(){ need_iface BR_RTR_WAN_IF "WAN-интерфейс"; need_iface BR_RTR_LAN_IF "Интерфейс к BR-FW" - rtr_zones "$BR_RTR_WAN_IF" "$BR_RTR_LAN_IF" || return 1 - fw --zone=external --add-masquerade && fw_reload && enable_forward && ok "NAT через $BR_RTR_WAN_IF"; } -brr_gre(){ gre_up "$BR_RTR_WAN_IP" "$HQ_RTR_WAN_IP" "$BR_GRE_IP/$GRE_PFX"; } -brr_ospf(){ need_iface BR_RTR_LAN_IF "Интерфейс к BR-FW"; local a=$GRE_IF p=$BR_RTR_LAN_IF - [[ $OSPF_FW_AUTH == yes ]] && { a="$GRE_IF $BR_RTR_LAN_IF"; p=""; } - frr_ospf "$BR_GRE_IP" "$a" "$p" "$GRE_NET/$GRE_PFX" "$FW_NET/$FW_PFX"; } -brr_dnat(){ fw_on || return 1; dnat 8080 "$BR_SRV_IP" 8080 && dnat "$SSH_PORT" "$BR_SRV_IP" "$SSH_PORT" && fw_reload; } -brr_ntp(){ chrony_client "$ISP_BR_IP"; } -chk_brr(){ chk_rtr; ping -c2 -W1 "$HQ_GRE_IP"; ping -c2 -W1 "$BR_FW_IP"; } -M_brr=( -"М1|Сеть: WAN и линк к BR-FW|brr_net" "М1|Пользователь $ADM_USER (sudo без пароля)|u_rtr" "М1|NAT в сторону ISP|brr_nat" -"М1|GRE-туннель до HQ-RTR|brr_gre" "М1|OSPF: туннель (MD5) и линк к BR-FW|brr_ospf" "М1|Часовой пояс|c_tz" -"М2|SSH с паролем (Ansible)|c_sshd_basic" "М2|NTP-клиент → ISP|brr_ntp" "М2|DNAT 8080 → testapp, $SSH_PORT → BR-SRV|brr_dnat") - -# ================================= BR-FW ===================================== -brfw_net(){ over_ssh_warn; warn "Вариант для Linux. Если BR-FW — Ideco NGFW, настройте его в веб-интерфейсе." - need_iface BR_FW_WAN_IF "Интерфейс к BR-RTR"; need_iface BR_FW_LAN_IF "Интерфейс к BR-SRV" - ask BR_FW_IP "IP к BR-RTR"; ask FW_PFX "Префикс"; ask BR_RTR_FW_IP "Шлюз (BR-RTR)"; ask BR_FW_LAN_IP "IP в сети BR-SRV"; ask BR_PFX "Префикс" - net_eth "$BR_FW_WAN_IF" static "$BR_FW_IP/$FW_PFX" "$BR_RTR_FW_IP" "$HQ_SRV_IP" || return 1 - net_eth "$BR_FW_LAN_IF" static "$BR_FW_LAN_IP/$BR_PFX" || return 1 - enable_forward || return 1 - if ((DRY)) || systemctl is-active --quiet firewalld; then - set_zone "demo-$BR_FW_WAN_IF" trusted "$BR_FW_WAN_IF"; set_zone "demo-$BR_FW_LAN_IF" trusted "$BR_FW_LAN_IF" - warn "Оба интерфейса в зоне trusted. Ограничения межсетевого экрана задайте по заданию."; fi; } -brfw_ospf(){ need_iface BR_FW_WAN_IF "Интерфейс к BR-RTR"; local a="" p=$BR_FW_WAN_IF - [[ $OSPF_FW_AUTH == yes ]] && { a=$BR_FW_WAN_IF; p=""; } - frr_ospf "$BR_FW_IP" "$a" "$p" "$FW_NET/$FW_PFX" "$BR_NET/$BR_PFX"; } -chk_brfw(){ ip -br a; ip r; vtysh -c 'show ip ospf neighbor' 2>/dev/null; ping -c2 -W1 "$BR_RTR_FW_IP"; ping -c2 -W1 "$BR_SRV_IP"; } -M_brfw=("М1|Сеть и маршрутизация|brfw_net" "М1|OSPF на интерфейсе к BR-RTR|brfw_ospf" "М1|Часовой пояс|c_tz") - -# ================================= HQ-SW ===================================== -hqsw_ovs(){ over_ssh_warn - pkg_any "openvswitch" "openvswitch3" || return 1; svc openvswitch openvswitch-switch || return 1 - need_iface HQ_SW_UP_IF "Аплинк к HQ-RTR (транк)"; need_iface HQ_SW_SRV_IF "Порт к HQ-SRV"; need_iface HQ_SW_CLI_IF "Порт к HQ-CLI" - ask HQ_SW_MGMT_IP "IP управления" - local i um="interface-name:hq-sw;interface-name:mgmt;interface-name:ovs-system" - for i in "$HQ_SW_UP_IF" "$HQ_SW_SRV_IF" "$HQ_SW_CLI_IF"; do um+=";interface-name:$i"; done - put /etc/NetworkManager/conf.d/90-demo-ovs.conf <<< $'[keyfile]\n'"unmanaged-devices=$um" || return 1 - run systemctl reload NetworkManager || return 1 - run ovs-vsctl --may-exist add-br hq-sw || return 1 - run ovs-vsctl --may-exist add-port hq-sw "$HQ_SW_UP_IF" -- set port "$HQ_SW_UP_IF" trunks="$VLAN_SRV,$VLAN_CLI,$VLAN_MGMT" || return 1 - run ovs-vsctl --may-exist add-port hq-sw "$HQ_SW_SRV_IF" -- set port "$HQ_SW_SRV_IF" tag="$VLAN_SRV" || return 1 - run ovs-vsctl --may-exist add-port hq-sw "$HQ_SW_CLI_IF" -- set port "$HQ_SW_CLI_IF" tag="$VLAN_CLI" || return 1 - run ovs-vsctl --may-exist add-port hq-sw mgmt -- set port mgmt tag="$VLAN_MGMT" -- set interface mgmt type=internal || return 1 - put /etc/systemd/system/demo-hqsw-mgmt.service </dev/null | head -1) - while read -r d; do [[ $d == "$root" ]] && continue - [[ $(lsblk -no NAME "/dev/$d" | wc -l) -eq 1 && -z $(lsblk -no MOUNTPOINT "/dev/$d" | tr -d ' ') && -z $(blkid -p "/dev/$d" 2>/dev/null) ]] && cand+=("$d") - done < <(lsblk -dno NAME,TYPE | awk '$2=="disk"{print $1}') - ((${#cand[@]} >= 2)) || ((DRY)) || { err "Нужно минимум 2 пустых диска без разделов и файловых систем"; lsblk; return 1; } - ((DRY)) && cand=(sdb sdc) - lsblk -o NAME,SIZE,TYPE,MOUNTPOINT; echo "Пустые диски: ${cand[*]}" - read -r -p "Диски для RAID$RAID_LEVEL через пробел: " -a pick || return 1 - ((${#pick[@]} >= 2)) || { err "Нужно минимум 2 диска"; return 1; } - for d in "${pick[@]}"; do [[ " ${cand[*]} " == *" $d "* ]] || { err "$d не в списке пустых дисков"; return 1; }; done - warn "ВСЕ ДАННЫЕ на ${pick[*]} будут уничтожены." - read -r -p "Введите УДАЛИТЬ для подтверждения: " d; [[ $d == УДАЛИТЬ ]] || { warn "Отменено"; return 1; } - run mdadm --create /dev/md0 --run --level="$RAID_LEVEL" --raid-devices="${#pick[@]}" "${pick[@]/#//dev/}" || return 1 - run mkfs.ext4 -F /dev/md0 || return 1 - ((DRY)) || mdadm --detail --scan | grep md0 | put /etc/mdadm.conf - run mkdir -p "$RAID_MOUNT" || return 1 - if ((DRY)); then echo "${C_C}+ /etc/fstab: UUID=... $RAID_MOUNT ext4 defaults 0 0${C_0}" - else backup /etc/fstab; echo "UUID=$(blkid -s UUID -o value /dev/md0) $RAID_MOUNT ext4 defaults 0 0" >> /etc/fstab; fi - run systemctl daemon-reload; run mount "$RAID_MOUNT" && ok "RAID$RAID_LEVEL /dev/md0 смонтирован в $RAID_MOUNT"; ((DRY)) || df -h "$RAID_MOUNT"; } -hqs_nfs(){ pkg nfs-utils || return 1 - ((DRY)) || mountpoint -q "$RAID_MOUNT" || { err "$RAID_MOUNT не смонтирован — сначала RAID"; return 1; } - run mkdir -p "$RAID_MOUNT/nfs" && run chmod 1777 "$RAID_MOUNT/nfs" || return 1 - put /etc/exports.d/demo.exports <<< "$RAID_MOUNT/nfs $CLI_NET/$CLI_PFX(rw,sync,no_subtree_check,root_squash)" || return 1 - svc nfs-server || return 1; run exportfs -ra || return 1; fw_open nfs mountd rpc-bind - ((DRY)) || exportfs -v; ok "NFS: $RAID_MOUNT/nfs для $CLI_NET/$CLI_PFX"; } -hqs_ntp(){ chrony_client "$ISP_HQ_IP"; } -hqs_web(){ need_pass || return 1; local src sql root=/var/www/html - src=$(iso_find 'index.php'); [[ -n $src ]] || { err "index.php не найден в ISO ($ISO_DIR)"; return 1; }; src=${src%/*} - msg "Файлы веб-приложения: $src"; ((DRY)) || ls -l "$src" - pkg_any "httpd php php-mysqlnd mariadb-server mariadb" "httpd php php-mysqlnd mariadb-server" || return 1 - svc mariadb || return 1 - if ((DRY)); then echo "${C_C}+ mysql: CREATE DATABASE webdb; пользователь web@localhost${C_0}" - else mysql -u root </dev/null; rm -f "$root/index.html" - sed -i -E "s/(\\\$servername[[:space:]]*=[[:space:]]*)[\"'][^\"']*[\"']/\1\"localhost\"/; s/(\\\$username[[:space:]]*=[[:space:]]*)[\"'][^\"']*[\"']/\1\"web\"/; s/(\\\$password[[:space:]]*=[[:space:]]*)[\"'][^\"']*[\"']/\1\"$PASS\"/; s/(\\\$dbname[[:space:]]*=[[:space:]]*)[\"'][^\"']*[\"']/\1\"webdb\"/" "$root/index.php" - chown -R apache:apache "$root"; selinux_on && restorecon -R "$root"; fi - svc httpd || return 1; fw_open http - ((DRY)) || curl -s -o /dev/null -w "web: HTTP %{http_code}\n" http://localhost/ - warn "Сверьте параметры подключения в $root/index.php с заданием."; } -hqs_ca(){ local D=/etc/pki/demo-ca n H K CK MD=-sha256; pkg openssl || return 1; ask CERT_ALG "Алгоритм (rsa/gost)" - K="-newkey rsa:2048"; CK="-newkey rsa:4096" - if [[ $CERT_ALG == gost ]]; then pkg openssl-gost-engine || { err "ГОСТ-движок не установлен — выберите rsa"; return 1; } - K="-newkey gost2012_256 -pkeyopt paramset:A"; CK=$K; MD=-md_gost12_256; warn "ГОСТ-TLS должны поддерживать nginx и браузер."; fi - id "$SSH_USER" >/dev/null 2>&1 || ((DRY)) || { err "Нет пользователя $SSH_USER — выполните пункт «Пользователь»"; return 1; } - run mkdir -p "$D" && run chmod 700 "$D" || return 1 - run openssl req -x509 $CK -nodes $MD -days 3650 -keyout "$D/ca.key" -out "$D/ca.crt" -subj "/CN=AU-TEAM Root CA/O=AU-TEAM" \ - -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" || return 1 - for n in web docker; do - run openssl req $K -nodes $MD -keyout "$D/$n.key" -out "$D/$n.csr" -subj "/CN=$(fqdn $n)" || return 1 - printf 'subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' "$(fqdn $n)" | put "$D/$n.ext" || return 1 - run openssl x509 -req -in "$D/$n.csr" -CA "$D/ca.crt" -CAkey "$D/ca.key" -CAcreateserial -days 30 $MD -extfile "$D/$n.ext" -out "$D/$n.crt" || return 1 - done - H=$(getent passwd "$SSH_USER" | cut -d: -f6); H=${H:-/home/$SSH_USER} - run install -d -m 700 -o "$SSH_USER" "$H/certs" || return 1 - run install -m 600 -o "$SSH_USER" "$D/ca.crt" "$D/web.crt" "$D/web.key" "$D/docker.crt" "$D/docker.key" "$H/certs/" || return 1 - [[ -d $RAID_MOUNT/nfs ]] && run install -m 644 "$D/ca.crt" "$RAID_MOUNT/nfs/ca.crt" - ok "CA: $D; сертификаты web/docker (30 дней) — в $H/certs; ca.crt — в $RAID_MOUNT/nfs"; } -hqs_atop(){ pkg atop || return 1; set_kv /etc/sysconfig/atop LOGINTERVAL 420 || return 1; svc atop && ok "atop: интервал 420 с (7 минут)" - warn "Проверьте, что atop.service использует LOGINTERVAL: systemctl cat atop"; } -hqs_cups(){ pkg_any "cups cups-pdf" || return 1; svc cups || return 1 - run cupsctl --share-printers --remote-any || return 1 - run lpadmin -p PDF -E -v cups-pdf:/ -m "$(lpinfo -m 2>/dev/null | grep -i pdf | head -1 | cut -d' ' -f1)" || run lpadmin -p PDF -E -v cups-pdf:/ -m raw || return 1 - run lpadmin -p PDF -o printer-is-shared=true && run lpadmin -d PDF && run cupsenable PDF && run cupsaccept PDF || return 1 - run systemctl restart cups; fw_open ipp; ok "Принтер PDF: ipp://$(fqdn hq-srv):631/printers/PDF"; } -hqs_f2b(){ pkg_any "fail2ban fail2ban-firewalld" "fail2ban" || return 1 - put /etc/fail2ban/jail.d/demo-sshd.local </dev/null - for n in hq-rtr hq-srv br-srv web docker; do getent hosts "$(fqdn $n)"; done; cat /proc/mdstat 2>/dev/null; exportfs -v 2>/dev/null; } -M_hqs=( -"М1|Сеть|hqs_net" "М1|Пользователь $SSH_USER (uid $SSH_UID, sudo без пароля)|u_srv" "М1|SSH: порт $SSH_PORT, только $SSH_USER, 2 попытки, баннер|c_sshd" -"М1|DNS-сервер (dnsmasq: A/PTR, форвардер)|hqs_dns" "М1|Часовой пояс|c_tz" -"М2|DNS: пересылка зон AD на BR-SRV|hqs_adfwd" "М2|RAID$RAID_LEVEL → $RAID_MOUNT (уничтожает данные дисков)|hqs_raid" "М2|NFS $RAID_MOUNT/nfs|hqs_nfs" -"М2|NTP-клиент → ISP|hqs_ntp" "М2|Веб-приложение Apache + MariaDB|hqs_web" -"М3|Центр сертификации и сертификаты web/docker|hqs_ca" "М3|atop (7 минут)|hqs_atop" "М3|CUPS и виртуальный PDF-принтер|hqs_cups" -"М3|fail2ban для SSH|hqs_f2b" "М3|Кибер Бэкап (подсказки)|hqs_backup") - -# ================================= BR-SRV ==================================== -brs_net(){ over_ssh_warn; need_iface BR_SRV_IF "Интерфейс BR-SRV" - ask BR_SRV_IP "IP"; ask BR_PFX "Префикс"; ask BR_FW_LAN_IP "Шлюз (BR-FW)" - net_eth "$BR_SRV_IF" static "$BR_SRV_IP/$BR_PFX" "$BR_FW_LAN_IP" "$HQ_SRV_IP"; } -brs_dc(){ need_pass || return 1 - [[ -f /var/lib/samba/private/sam.ldb ]] && { err "Домен уже развёрнут (sam.ldb существует) — повторное развёртывание отключено"; return 1; } - pkg_any "samba-dc samba-client krb5-workstation" "samba samba-dc krb5-workstation" || return 1 - warn "Будет создан домен $(REALM) (NetBIOS $AD_WORKGROUP); smb.conf и krb5.conf будут заменены (копии .demo-orig)." - yesno "Развернуть контроллер домена?" n || return 1 - local s; for s in smb nmb winbind; do run systemctl disable --now "$s" 2>/dev/null; done - [[ -f /etc/samba/smb.conf ]] && { backup /etc/samba/smb.conf; run rm -f /etc/samba/smb.conf; } - run samba-tool domain provision --use-rfc2307 --realm="$(REALM)" --domain="$AD_WORKGROUP" --server-role=dc \ - --dns-backend=SAMBA_INTERNAL --adminpass="$PASS" --option="dns forwarder = $HQ_SRV_IP" || { err "Развёртывание не удалось"; return 1; } - backup /etc/krb5.conf; run cp -f /var/lib/samba/private/krb5.conf /etc/krb5.conf || return 1 - svc samba || return 1 - if ((DRY)) || firewall-cmd --get-services 2>/dev/null | grep -qw samba-dc; then fw_open samba-dc - else fw_open dns kerberos ldap ldaps 135/tcp 445/tcp 464/tcp 464/udp 3268/tcp 3269/tcp; fi - selinux_on && warn "SELinux в режиме enforcing: при ошибках Samba смотрите журнал (ausearch -m avc)." - ((DRY)) || { sleep 3; samba-tool domain level show; }; ok "Контроллер домена $(REALM) развёрнут"; } -brs_users(){ need_pass || return 1; local i - run samba-tool domain passwordsettings set --min-pwd-length=8 --account-lockout-threshold=5 --account-lockout-duration=1 --reset-account-lockout-after=1 || return 1 - run samba-tool group add hq 2>/dev/null - for i in 1 2 3 4 5; do run samba-tool user add "hquser$i" "$PASS" 2>/dev/null || warn "hquser$i не создан (возможно, уже есть)"; done - run samba-tool group addmembers hq hquser1,hquser2,hquser3,hquser4,hquser5 - ((DRY)) || samba-tool group listmembers hq; ok "Пользователи hquser1–5 в группе hq"; } -brs_import(){ need_pass || return 1; local c lc fc pc gc sep=';' line u p - c=$(iso_find 'users*.csv'); [[ -n $c ]] || { err "users.csv не найден в ISO"; return 1; }; msg "CSV: $c"; ((DRY)) && return 0 - head -3 "$c"; read -r -p "Номер колонки логина: " lc; read -r -p "Колонки имени (пусто = нет): " fc - read -r -p "Колонка пароля (пусто = общий пароль): " pc; read -r -p "Колонка группы (пусто = нет): " gc - for x in "$lc" "$fc" "$pc" "$gc"; do [[ -z $x || $x =~ ^[1-9][0-9]?$ ]] || { err "Номер колонки: $x"; return 1; }; done - [[ -n $lc ]] || return 1; head -1 "$c" | grep -q ';' || sep=',' - tail -n +2 "$c" | tr -d '\r' | while IFS= read -r line; do [[ -z $line ]] && continue - IFS="$sep" read -ra F <<< "$line"; u=${F[lc-1]}; p=${pc:+${F[pc-1]}}; p=${p:-$PASS} - is_name "$u" || { warn "Пропуск строки: логин «$u»"; continue; } - if samba-tool user add "$u" "$p" ${fc:+--given-name="${F[fc-1]}"} >/dev/null 2>&1; then echo "+ $u"; else echo "= $u (уже есть или ошибка)"; fi - [[ -n $gc && -n ${F[gc-1]} ]] && { samba-tool group add "${F[gc-1]}" >/dev/null 2>&1; samba-tool group addmembers "${F[gc-1]}" "$u" >/dev/null 2>&1; } - done; ok "Импорт завершён"; } -brs_ntp(){ chrony_client "$ISP_BR_IP"; } -brs_ansible(){ need_pass || return 1; pkg_any "ansible sshpass" "ansible-core sshpass" || return 1 - put /etc/ansible/hosts 600 <.yml - copy: - dest: "/etc/ansible/PC-INFO/{{ ansible_hostname }}.yml" - content: | - hostname: {{ ansible_hostname }} - ip_address: {{ ansible_default_ipv4.address }} - delegate_to: localhost - become: no -EOF - ((DRY)) || { ansible-playbook /etc/ansible/inventory.yml; cat /etc/ansible/PC-INFO/*.yml 2>/dev/null; } - warn "Если в задании другие хосты или имя плейбука — исправьте /etc/ansible/inventory.yml."; } -brs_docker(){ need_pass || return 1; local d f site db - pkg_any "docker-ce docker-ce-cli docker-compose-plugin" "docker-ce docker-compose" "docker docker-compose" || return 1; svc docker || return 1 - d=$(iso_find 'site_latest*'); [[ -n $d ]] || { err "Образ site_latest не найден в ISO"; return 1; }; d=${d%/*} - for f in "$d"/*.tar "$d"/*.tar.gz; do [[ -f $f ]] && run docker load -i "$f"; done - f=$(find "$d" -maxdepth 1 -iname '*readme*' 2>/dev/null | head -1); [[ -n $f ]] && { warn "README из ISO — сверьте переменные:"; cat "$f"; } - site=$(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i site | head -1) - db=$(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i mariadb | head -1) - ask APP_INNER_PORT "Внутренний порт приложения" - run install -d -m 700 /root/testapp || return 1 - put /root/testapp/compose.yaml 600 </dev/null 2>&1 || ((DRY)); then run docker compose -f /root/testapp/compose.yaml up -d || return 1 - else run docker-compose -f /root/testapp/compose.yaml up -d || return 1; fi - fw_open 8080/tcp; ((DRY)) || { sleep 5; docker ps; curl -s -o /dev/null -w "testapp: HTTP %{http_code}\n" http://localhost:8080/; }; } -chk_brs(){ ip -br a; ip r; systemctl is-active samba sshd chronyd docker 2>/dev/null; samba-tool user list 2>/dev/null | head - host -t SRV "_ldap._tcp.$DOMAIN" 127.0.0.1 2>/dev/null; docker ps 2>/dev/null; } -M_brs=( -"М1|Сеть|brs_net" "М1|Пользователь $SSH_USER (uid $SSH_UID, sudo без пароля)|u_srv" "М1|SSH: порт $SSH_PORT, только $SSH_USER, 2 попытки, баннер|c_sshd" "М1|Часовой пояс|c_tz" -"М2|Контроллер домена Samba AD|brs_dc" "М2|Пользователи hquser1–5, группа hq, политика паролей|brs_users" "М2|NTP-клиент → ISP|brs_ntp" -"М2|Ansible: инвентарь и проверка ping|brs_ansible" "М2|Docker: testapp и MariaDB из ISO|brs_docker" -"М3|Импорт пользователей из users.csv|brs_import" "М3|Ansible-плейбук PC-INFO|brs_pcinfo") - -# ================================= HQ-CLI ==================================== -hqc_net(){ over_ssh_warn; need_iface HQ_CLI_IF "Интерфейс HQ-CLI"; net_eth "$HQ_CLI_IF" dhcp || return 1; ((DRY)) || { ip -br a show "$HQ_CLI_IF"; cat /etc/resolv.conf; }; } -hqc_join(){ need_pass || return 1 - pkg_any "realmd sssd sssd-tools oddjob oddjob-mkhomedir adcli samba-common-tools krb5-workstation authselect" "realmd sssd adcli oddjob-mkhomedir krb5-workstation authselect" || return 1 - ((DRY)) || { getent hosts "$(fqdn br-srv)" >/dev/null || warn "br-srv не резолвится: проверьте DNS"; realm discover "$DOMAIN" || warn "Домен не обнаружен: проверьте пересылку зон AD на HQ-SRV"; } - if ((DRY)); then run realm join -U "$AD_ADMIN" "$DOMAIN" - else printf '%s\n' "$PASS" | realm join -v -U "$AD_ADMIN" "$DOMAIN" || { err "Ввод в домен не удался (DNS, время, пароль)"; return 1; }; fi - if ((DRY)); then echo "${C_C}+ sssd.conf: use_fully_qualified_names = False, fallback_homedir = /home/%u${C_0}" - else backup /etc/sssd/sssd.conf; sed -i -E 's/^use_fully_qualified_names.*/use_fully_qualified_names = False/; s|^fallback_homedir.*|fallback_homedir = /home/%u|' /etc/sssd/sssd.conf; fi - run authselect select sssd with-mkhomedir --force || return 1; svc oddjobd; svc sssd || return 1 - ((DRY)) || { realm list; id hquser1; }; ok "HQ-CLI в домене $DOMAIN; перезагрузите ВМ"; } -hqc_sudo(){ pkg sudo || return 1; local f=/etc/sudoers.d/demo-hq - put "$f" 440 <<< "%hq ALL=(ALL) /usr/bin/cat, /usr/bin/grep, /usr/bin/id" || return 1 - ((DRY)) || visudo -cf "$f" >/dev/null || { err "Ошибка sudoers — файл удалён"; rm -f "$f"; return 1; }; ok "Группа hq: sudo только cat, grep, id"; } -hqc_nfs(){ pkg nfs-utils || return 1; run mkdir -p /mnt/nfs || return 1 - if ((DRY)); then echo "${C_C}+ /etc/fstab: $HQ_SRV_IP:$RAID_MOUNT/nfs /mnt/nfs nfs defaults,_netdev,x-systemd.automount 0 0${C_0}" - else backup /etc/fstab; sed -i '\| /mnt/nfs |d' /etc/fstab; echo "$HQ_SRV_IP:$RAID_MOUNT/nfs /mnt/nfs nfs defaults,_netdev,x-systemd.automount 0 0" >> /etc/fstab; fi - run systemctl daemon-reload; run mount /mnt/nfs && ok "NFS смонтирован в /mnt/nfs"; ((DRY)) || df -h /mnt/nfs; } -hqc_ntp(){ chrony_client "$ISP_HQ_IP"; } -hqc_yandex(){ pkg_any "yandex-browser-stable" "yandex-browser" || warn "Пакет не найден — установите Яндекс Браузер вручную"; } -hqc_ssh(){ id "$CLI_SSH_USER" >/dev/null 2>&1 || mkuser "$CLI_SSH_USER" || return 1; c_sshd_basic "$CLI_SSH_PORT"; } -hqc_ca(){ local ca=/mnt/nfs/ca.crt h - [[ -f $ca ]] || ((DRY)) || read -r -p "Путь к ca.crt: " ca; [[ -f $ca ]] || ((DRY)) || { err "ca.crt не найден"; return 1; } - run install -m 644 "$ca" /etc/pki/ca-trust/source/anchors/au-team-ca.crt && run update-ca-trust || return 1 - pkg nss-tools || return 1 - for h in /etc/skel /root /home/*; do [[ -d $h ]] || continue - run mkdir -p "$h/.pki/nssdb"; [[ -f $h/.pki/nssdb/cert9.db ]] || run certutil -d "sql:$h/.pki/nssdb" -N --empty-password - run certutil -d "sql:$h/.pki/nssdb" -A -t "C,," -n "AU-TEAM CA" -i "$ca" - [[ $h == /home/* ]] && run chown -R "$(stat -c %U "$h")": "$h/.pki"; done - ok "CA добавлен в системное хранилище и NSS браузеров"; } -hqc_printer(){ pkg_any "cups cups-client" "cups" || return 1; svc cups || return 1 - run lpadmin -p PDF -E -v "ipp://$(fqdn hq-srv):631/printers/PDF" -m everywhere || return 1 - run lpadmin -d PDF && ok "Принтер PDF по умолчанию"; ((DRY)) || lpstat -d; } -hqc_backup(){ run install -d -m 700 /backup && ok "Создан /backup" - warn "Установите агент и узел хранения Кибер Бэкап, зарегистрируйте их на сервере управления; хранилище — /backup."; } -chk_hqc(){ ip -br a; ip r; cat /etc/resolv.conf; realm list 2>/dev/null; id hquser1 2>/dev/null; df -h /mnt/nfs 2>/dev/null; lpstat -d 2>/dev/null; } -M_hqc=( -"М1|Сеть: DHCP|hqc_net" "М1|Часовой пояс|c_tz" "М2|Ввод в домен $(REALM)|hqc_join" "М2|sudo для группы hq (cat, grep, id)|hqc_sudo" -"М2|NFS: автомонтирование /mnt/nfs|hqc_nfs" "М2|NTP-клиент → ISP|hqc_ntp" "М2|Яндекс Браузер|hqc_yandex" "М2|SSH для Ansible|hqc_ssh" -"М3|Доверие к CA (HTTPS без ошибок)|hqc_ca" "М3|Принтер PDF по умолчанию|hqc_printer" "М3|Кибер Бэкап: каталог /backup|hqc_backup") - -# ================================== меню ===================================== -run_item(){ local desc=$1 fn=$2 rc - hdr "$desc"; "$fn"; rc=$? - if ((rc == 0)); then ok "Готово: $desc"; else err "Не выполнено: $desc (код $rc)"; fi; return $rc; } -machine_menu(){ local id=$1 title=$2 host=$3; local -n items="M_$id"; local i e c m d fn mm - MACHINE=$host - while :; do hdr "$title — $(hostname 2>/dev/null)"; i=1 - for e in "${items[@]}"; do IFS='|' read -r m d _ <<< "$e"; printf '%3d) [%s] %s\n' "$i" "$m" "$d"; i=$((i+1)); done - echo " h) Имя хоста → $(fqdn "$host") p) Подготовка (утилиты) c) Проверка" - echo " m1/m2/m3) Выполнить модуль по порядку 0) Назад" - read -r -p "Выбор: " c || return - case $c in - 0|"") return ;; - h) yesno "Сменить имя хоста на $(fqdn "$host")?" y && run_item "Имя хоста" c_hostname; pause ;; - p) run_item "Подготовка" os_prep; pause ;; - c) hdr "Проверка $title"; "chk_$id"; pause ;; - m[123]|М[123]) m="М${c:1}" - for e in "${items[@]}"; do IFS='|' read -r mm d fn <<< "$e"; [[ $mm == "$m" ]] || continue - yesno "Выполнить «$d»?" y || continue - run_item "$d" "$fn" || { yesno "Ошибка. Продолжить модуль?" n || break; }; done; pause ;; - *) if [[ $c =~ ^[0-9]+$ ]] && ((c >= 1 && c <= ${#items[@]})); then IFS='|' read -r m d fn <<< "${items[c-1]}" - yesno "Выполнить «$d»?" y && run_item "$d" "$fn"; pause - else warn "Нет такого пункта"; fi ;; - esac; done; } -main_menu(){ local c - while :; do hdr "demo-setup v$VERSION — домен $DOMAIN — $([[ $MODE == apply ]] && echo ПРИМЕНЕНИЕ || echo ПРОСМОТР)" - cat <