From 2312290a4c977c0e8e57415da7b386fde632fd18 Mon Sep 17 00:00:00 2001 From: Rey Date: Thu, 5 Nov 2026 12:56:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demosetup.sh | 863 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 863 insertions(+) create mode 100644 demosetup.sh diff --git a/demosetup.sh b/demosetup.sh new file mode 100644 index 0000000..72e9007 --- /dev/null +++ b/demosetup.sh @@ -0,0 +1,863 @@ +#!/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 <