net/demo-setup-redos8-v5.sh

1085 lines
86 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# =============================================================================
# demo-setup.sh v5 — помощник настройки учебного стенда ДЭ 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=4.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; }
exec 5<&0 # исходный ввод (клавиатура) — для окон внутри конвейеров
pause(){ read -r -p "Enter — продолжить..." _ <&5 || true; }
yesno(){ ui_yesno "$@"; }
usage(){ cat <<EOF
demo-setup.sh v$VERSION — помощник настройки стенда ДЭ 09.02.06 (RED OS 8)
bash demo-setup.sh --dry-run посмотреть меню и команды без изменений
sudo bash demo-setup.sh --apply применить выбранные пункты
Порядок работы:
1. Сделайте снимок каждой ВМ. Работайте из консоли гипервизора, не по SSH.
2. На первой ВМ откройте «Параметры», сверьте адреса и имена с заданием.
Параметры сохраняются в demo-setup.conf (права 600). Пароль не сохраняется.
3. Скопируйте скрипт и demo-setup.conf на остальные ВМ.
4. В окнах выберите машину → модуль → пункт. В окне пункта видны его данные:
выберите строку, чтобы вписать своё значение, затем «▶ Выполнить пункт».
Префикс можно вводить как 28, /28 или 255.255.255.240.
5. Пункты М1 выполняйте раньше М2 и М3. После пункта используйте «Проверка».
6. Калькулятор масок: в главном меню (9), в меню машины (k) и в окне пункта (K).
Окна рисуются через whiptail (пакет newt). Без него — текстовое меню.
Текстовое меню всегда: DEMO_UI=text bash demo-setup.sh --apply
Ограничения: скрипт не проверен на настоящем стенде RED OS 8. Команды
пакетов, служб и параметры приложений из Additional.iso нужно сверить.
Перед экзаменом уточните, разрешено ли использовать скрипты.
EOF
}
[[ $MODE == help ]] && { usage; exit 0; }
if [[ $MODE == apply ]]; then
[[ $EUID -eq 0 ]] || { echo "Для --apply нужен root: sudo bash $0 --apply" >&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 ФАЙЛ [ПРАВА] <<EOF — записывает файл (с резервной копией оригинала).
put(){ local f=$1 m=${2:-644} body; body=$(cat)
if ((DRY)); then echo "${C_C}+ запись $f (права $m):${C_0}"; printf '%s\n' "$body" | sed 's/^/ | /'; return 0; fi
backup "$f"; mkdir -p "$(dirname "$f")" || return 1
(umask 077; printf '%s\n' "$body" > "$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"; }
PASS=
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
# Нет ключей хоста (свежий клон шаблона после очистки) — создаём, иначе sshd -t падает
ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1 || run ssh-keygen -A || return 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 <<EOF || return 1
# demo-setup: защищённый SSH
Port $SSH_PORT
AllowUsers $SSH_USER
MaxAuthTries 2
Banner /etc/ssh/demo-banner
PasswordAuthentication yes
EOF
sshd_apply "$SSH_PORT"; }
# Простой SSH с паролем (доступ Ansible). c_sshd_basic [ПОРТ]
c_sshd_basic(){ local port=${1:-22}; pkg openssh-server || return 1; sshd_include || return 1
put /etc/ssh/sshd_config.d/10-demo.conf 600 <<EOF || return 1
# demo-setup: SSH для Ansible
Port $port
PasswordAuthentication yes
EOF
sshd_apply "$port"; }
chrony_client(){ local srv=$1 f=/etc/chrony.conf; pkg chrony || return 1
if ((DRY)); then echo "${C_C}+ $f: закомментировать pool/server, добавить «server $srv iburst»${C_0}"
else backup "$f"; sed -i -E '/# demo-setup$/d; s/^[[:space:]]*(pool|server|peer)[[:space:]]/#&/' "$f"; echo "server $srv iburst # demo-setup" >> "$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"
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)"
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
{ 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"
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"
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"
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 <<EOF || return 1
[Unit]
Description=HQ-SW: IP управления (demo-setup)
After=openvswitch.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/ip link set $HQ_SW_UP_IF up
ExecStart=/usr/sbin/ip link set $HQ_SW_SRV_IF up
ExecStart=/usr/sbin/ip link set $HQ_SW_CLI_IF up
ExecStart=/usr/sbin/ip link set hq-sw up
ExecStart=/usr/sbin/ip link set mgmt up
ExecStart=/usr/sbin/ip addr replace $HQ_SW_MGMT_IP/$MGMT_PFX dev mgmt
ExecStart=/usr/sbin/ip route replace default via $HQ_RTR_MGMT_IP
[Install]
WantedBy=multi-user.target
EOF
run systemctl daemon-reload && run systemctl enable --now demo-hqsw-mgmt || return 1
((DRY)) || ovs-vsctl show; ok "HQ-SW: транк $HQ_SW_UP_IF, access $VLAN_SRV/$VLAN_CLI, mgmt $HQ_SW_MGMT_IP"; }
chk_hqsw(){ ovs-vsctl show; ip -br a; ping -c2 -W1 "$HQ_RTR_MGMT_IP"; }
M_hqsw=("М1|Коммутатор Open vSwitch: транк, access VLAN, управление|hqsw_ovs" "М1|Часовой пояс|c_tz")
# ================================= HQ-SRV ====================================
hqs_net(){ over_ssh_warn; need_iface HQ_SRV_IF "Интерфейс HQ-SRV"
net_eth "$HQ_SRV_IF" static "$HQ_SRV_IP/$SRV_PFX" "$HQ_RTR_SRV_IP" "$DNS_FWD"; }
dns_records(){ if [[ -n $DNS_RECORDS ]]; then echo "$DNS_RECORDS"; return; fi
echo "hq-rtr=$HQ_RTR_SRV_IP:ptr br-rtr=$BR_RTR_FW_IP hq-srv=$HQ_SRV_IP:ptr hq-cli=$HQ_CLI_IP:ptr br-srv=$BR_SRV_IP:ptr docker=${DOCKER_DNS_IP:-$ISP_HQ_IP} web=${WEB_DNS_IP:-$ISP_BR_IP}"; }
hqs_dns(){ pkg dnsmasq || return 1; local r n ip
yesno "Записи DNS (изменить можно в данных пункта → DNS_RECORDS):"$'\n'"$(dns_records | tr ' ' '\n')"$'\n\n'"Продолжить с этими записями?" y || return 1
for r in $(dns_records); do ip=${r#*=}; ip=${ip%:ptr}; is_name "${r%%=*}" && is_ip "$ip" || { err "Плохая запись: $r"; return 1; }; done
{ echo "# demo-setup: DNS-сервер HQ-SRV"; echo "no-resolv"; echo "no-hosts"; echo "domain-needed"; echo "bogus-priv"
echo "bind-interfaces"; echo "listen-address=127.0.0.1,$HQ_SRV_IP"; echo "domain=$DOMAIN"; echo "local=/$DOMAIN/"
echo "cache-size=1000"; echo "server=$DNS_FWD"; [[ -n $DNS_FWD2 ]] && echo "server=$DNS_FWD2"
for r in $(dns_records); do n=$(fqdn "${r%%=*}"); ip=${r#*=}
if [[ $ip == *:ptr ]]; then echo "host-record=$n,${ip%:ptr}"; else echo "address=/$n/$ip"; fi; done
echo "conf-dir=/etc/dnsmasq.d,.rpmnew,.rpmsave,.rpmorig"; } | put /etc/dnsmasq.conf || return 1
run mkdir -p /etc/dnsmasq.d; ((DRY)) || dnsmasq --test || return 1
svc dnsmasq || return 1; fw_open dns
run nmcli con mod "demo-$HQ_SRV_IF" ipv4.dns 127.0.0.1 ipv4.dns-search "$DOMAIN" && run nmcli con up "demo-$HQ_SRV_IF" \
|| warn "Не удалось переключить DNS HQ-SRV на 127.0.0.1 (профиль demo-$HQ_SRV_IF)"
((DRY)) || { for n in hq-srv web docker; do getent hosts "$(fqdn $n)"; done; }; ok "DNS настроен"; }
hqs_adfwd(){ local z
for z in _msdcs _tcp _udp _sites; do echo "server=/$z.$DOMAIN/$BR_SRV_IP"; done | put /etc/dnsmasq.d/demo-ad.conf || return 1
svc dnsmasq && ok "Служебные зоны AD пересылаются на BR-SRV ($BR_SRV_IP)"; }
hqs_raid(){ pkg mdadm || return 1; local d cand=() pick=() root
[[ -e /dev/md0 ]] && { err "/dev/md0 уже существует — RAID не создаётся повторно"; return 1; }
root=$(lsblk -no PKNAME "$(findmnt -no SOURCE /)" 2>/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[*]}"
d=$(ui_input "RAID$RAID_LEVEL" "Пустые диски: ${cand[*]}"$'\n'"Введите диски для RAID через пробел:" "${cand[*]}") || return 1; read -ra pick <<< "$d"
((${#pick[@]} >= 2)) || { err "Нужно минимум 2 диска"; return 1; }
for d in "${pick[@]}"; do [[ " ${cand[*]} " == *" $d "* ]] || { err "$d не в списке пустых дисков"; return 1; }; done
warn "ВСЕ ДАННЫЕ на ${pick[*]} будут уничтожены."
d=$(ui_input "Подтверждение" "ВСЕ ДАННЫЕ на ${pick[*]} будут уничтожены. Введите УДАЛИТЬ:" "") || return 1; [[ $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 <<EOF || { err "Ошибка MariaDB (если задан пароль root — выполните SQL вручную)"; return 1; }
CREATE DATABASE IF NOT EXISTS webdb;
CREATE USER IF NOT EXISTS 'web'@'localhost' IDENTIFIED BY '$PASS';
GRANT ALL PRIVILEGES ON webdb.* TO 'web'@'localhost';
FLUSH PRIVILEGES;
EOF
sql=$(find "$src" -maxdepth 2 -iname '*.sql' | head -1)
if [[ -n $sql ]]; then mysql -u root webdb < "$sql" && ok "Импортирован $sql"; else warn "SQL-дамп не найден — импортируйте вручную"; fi
backup "$root/index.php"; cp -f "$src"/* "$root"/ 2>/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
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
ok "CA: $D; сертификаты web/docker (30 дней) — в $H/certs"
if [[ -d $RAID_MOUNT/nfs ]]; then run install -m 644 "$D/ca.crt" "$RAID_MOUNT/nfs/ca.crt" && ok "ca.crt скопирован в $RAID_MOUNT/nfs"
else warn "$RAID_MOUNT/nfs нет — сначала выполните RAID+NFS, затем повторите CA (или скопируйте ca.crt вручную)"; fi; }
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 <<EOF || return 1
[sshd]
enabled = true
port = $SSH_PORT
backend = systemd
maxretry = 3
findtime = 10m
bantime = 1m
EOF
svc fail2ban || return 1; ((DRY)) || { sleep 2; fail2ban-client status sshd; }; }
hqs_backup(){ local f; f=$(iso_find '*yber*')
cat <<EOF
Кибер Бэкап устанавливается в основном вручную по документации:
1. На сервере управления создайте подразделение и администратора по заданию.
2. На HQ-CLI: агент и узел хранения, хранилище /backup (пункт меню HQ-CLI).
3. На HQ-SRV установите агент и зарегистрируйте его на сервере управления.
4. Создайте план резервного копирования /etc HQ-SRV в хранилище /backup и запустите.
EOF
[[ -n $f ]] && msg "Найдено в ISO: $f"; return 0; }
chk_hqs(){ ip -br a; ip r; systemctl is-active dnsmasq sshd chronyd nfs-server httpd mariadb cups 2>/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"
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" | ui_text "Начало $c"
lc=$(ui_input "Импорт" "Номер колонки логина:" "1") || return 1; fc=$(ui_input "Импорт" "Колонка имени (пусто = нет):" "") || return 1
pc=$(ui_input "Импорт" "Колонка пароля (пусто = общий пароль):" "") || return 1; gc=$(ui_input "Импорт" "Колонка группы (пусто = нет):" "") || return 1
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 <<EOF || return 1
[servers]
hq-srv ansible_host=$HQ_SRV_IP ansible_user=$SSH_USER ansible_port=$SSH_PORT
[clients]
hq-cli ansible_host=$HQ_CLI_IP ansible_user=$CLI_SSH_USER ansible_port=$CLI_SSH_PORT
[routers]
hq-rtr ansible_host=$HQ_GRE_IP ansible_user=$ADM_USER ansible_port=22
br-rtr ansible_host=$BR_RTR_FW_IP ansible_user=$ADM_USER ansible_port=22
[all:vars]
ansible_password=$PASS
ansible_become=yes
ansible_become_password=$PASS
EOF
put /etc/ansible/ansible.cfg <<'EOF' || return 1
[defaults]
inventory = /etc/ansible/hosts
host_key_checking = False
interpreter_python = auto_silent
deprecation_warnings = False
EOF
((DRY)) || ansible all -m ping; warn "Файл /etc/ansible/hosts содержит пароль (права 600)."; }
brs_pcinfo(){ run mkdir -p /etc/ansible/PC-INFO || return 1
put /etc/ansible/inventory.yml <<'EOF' || return 1
---
- name: Сбор информации о хостах
hosts: hq-srv,hq-cli
gather_facts: yes
tasks:
- name: Записать имя и IP в /etc/ansible/PC-INFO/<hostname>.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)
run install -d -m 700 /root/testapp || return 1
put /root/testapp/compose.yaml 600 <<EOF || return 1
services:
db:
image: ${db:-mariadb:latest}
container_name: db
restart: always
environment:
MARIADB_DATABASE: testdb
MARIADB_USER: test
MARIADB_PASSWORD: '$PASS'
MARIADB_ROOT_PASSWORD: '$PASS'
volumes:
- db_data:/var/lib/mysql
testapp:
image: ${site:-site:latest}
container_name: testapp
restart: always
depends_on: [db]
ports:
- "8080:$APP_INNER_PORT"
environment:
DB_TYPE: maria
DB_HOST: db
DB_NAME: testdb
DB_USER: test
DB_PASS: '$PASS'
DB_PORT: 3306
volumes:
db_data:
EOF
yesno "Открыть compose.yaml для правки перед запуском?" n && run "${EDITOR:-vi}" /root/testapp/compose.yaml
if docker compose version >/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)) || ca=$(ui_input "Сертификат CA" "Путь к 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")
# ============================ интерфейс (окна) ===============================
# Окна whiptail/dialog, если они есть; иначе текстовое меню. DEMO_UI=text — всегда текст.
UI=text; UI_TTY=${DEMO_TTY:-/dev/tty}
ui_init(){ local b
case ${DEMO_UI:-auto} in
text) UI=text; return ;;
whiptail|dialog) UI=$DEMO_UI; return ;;
esac
[[ -t 0 && -t 1 || -t 0 && $MODE == apply ]] || return 0
for b in whiptail dialog; do have "$b" && { UI=$b; return; }; done
if [[ $MODE == apply ]] && yesno "Окна выбора недоступны. Установить пакет newt (whiptail)?" y; then
dnf install -y newt >/dev/null 2>&1 && have whiptail && UI=whiptail; fi; return 0; }
ui_size(){ local l c; l=$(tput lines 2>/dev/null || echo 24); c=$(tput cols 2>/dev/null || echo 80)
UI_H=$(( l > 10 ? l - 4 : 20 )); UI_W=$(( c > 90 ? 86 : (c > 40 ? c - 4 : 76) )); }
# ui_call АРГУМЕНТЫ — запускает whiptail/dialog, результат печатает в stdout.
ui_call(){ local rc out; ui_size
out=$("$UI" --backtitle "demo-setup v$VERSION — $([[ $MODE == apply ]] && echo ПРИМЕНЕНИЕ || echo ПРОСМОТР)" "$@" 3>&1 1>"$UI_TTY" 2>&3 <&5); rc=$?
printf '%s' "$out"; return $rc; }
btn(){ if [[ $UI == dialog ]]; then echo "--yes-label $1 --no-label $2 --ok-label $1 --cancel-label $2"; else echo "--yes-button $1 --no-button $2 --ok-button $1 --cancel-button $2"; fi; }
# ui_menu "заголовок" "текст" ТЕГ ПУНКТ ТЕГ ПУНКТ ... → выбранный тег
ui_menu(){ local t=$1 x=$2; shift 2; local n=$(( $# / 2 )) a i
if [[ $UI != text ]]; then ui_size; local h=$(( n + 8 < UI_H ? n + 8 : UI_H ))
ui_call --title "$t" $(btn Выбрать Назад) --menu "$x" "$h" "$UI_W" $(( h - 7 )) "$@"; return; fi
local -a args=("$@")
{ echo; echo "${C_B}===== $t =====${C_0}"; [[ -n $x ]] && echo "$x"
for ((i = 0; i < ${#args[@]}; i += 2)); do printf ' %4s) %s\n' "${args[i]}" "${args[i+1]}"; done; } >&2
read -r -p "Выбор: " a || return 255
for ((i = 0; i < ${#args[@]}; i += 2)); do [[ ${args[i],,} == "${a,,}" ]] && { printf '%s' "${args[i]}"; return 0; }; done
[[ -z $a ]] && return 1; warn "Нет такого пункта: $a" >&2; ui_menu "$t" "$x" "$@"; }
# ui_input "заголовок" "вопрос" "значение по умолчанию" → введённая строка
ui_input(){ local a
if [[ $UI != text ]]; then ui_size; ui_call --title "$1" $(btn OK Отмена) --inputbox "$2" 12 "$UI_W" "$3"; return; fi
read -r -p "$2 [$3]: " a || return 1; printf '%s' "${a:-$3}"; }
ui_pass(){ local a
if [[ $UI != text ]]; then ui_call --title "Пароль" $(btn OK Отмена) --passwordbox "$1" 10 "$UI_W"; return; fi
read -r -s -p "$1: " a || return 1; echo >&2; printf '%s' "$a"; }
ui_yesno(){ local q=$1 d=${2:-n} a h
if [[ $UI != text ]]; then ui_size; local -a dn=(); [[ $d == n ]] && dn=(--defaultno)
ui_call --title "Подтверждение" $(btn Да Нет) "${dn[@]}" --yesno "$q" 12 "$UI_W" >/dev/null; return; fi
[[ $d == y ]] && h="Y/n" || h="y/N"; read -r -p "$q [$h] " a || return 1; a=${a:-$d}; [[ $a =~ ^[YyДд] ]]; }
# ui_text "заголовок" — показывает текст из stdin (с прокруткой).
ui_text(){ local f; f=$(mktemp) || return 1; cat > "$f"
if [[ $UI != text ]]; then ui_size; ui_call --title "$1" --scrolltext --textbox "$f" "$UI_H" "$UI_W" >/dev/null 2>&1 || ui_call --title "$1" --textbox "$f" "$UI_H" "$UI_W" >/dev/null
else echo; echo "${C_B}===== $1 =====${C_0}"; cat "$f"; pause; fi; rm -f "$f"; }
ui_clear(){ [[ $UI != text ]] && clear 2>/dev/null; return 0; }
# ========================== ввод данных пользователем ========================
declare -A IF_LABEL=(
[ISP_WAN_IF]="ISP: интерфейс в Интернет" [ISP_HQ_IF]="ISP: интерфейс к HQ-RTR" [ISP_BR_IF]="ISP: интерфейс к BR-RTR"
[HQ_RTR_WAN_IF]="HQ-RTR: WAN-интерфейс" [HQ_RTR_LAN_IF]="HQ-RTR: LAN (транк к HQ-SW)" [BR_RTR_WAN_IF]="BR-RTR: WAN-интерфейс"
[BR_RTR_LAN_IF]="BR-RTR: интерфейс к BR-FW" [BR_FW_WAN_IF]="BR-FW: интерфейс к BR-RTR" [BR_FW_LAN_IF]="BR-FW: интерфейс к BR-SRV"
[HQ_SW_UP_IF]="HQ-SW: аплинк к HQ-RTR" [HQ_SW_SRV_IF]="HQ-SW: порт к HQ-SRV" [HQ_SW_CLI_IF]="HQ-SW: порт к HQ-CLI"
[HQ_SRV_IF]="HQ-SRV: интерфейс" [BR_SRV_IF]="BR-SRV: интерфейс" [HQ_CLI_IF]="HQ-CLI: интерфейс")
label_of(){ local e; [[ -n ${IF_LABEL[$1]+x} ]] && { echo "${IF_LABEL[$1]}"; return; }
for e in "${PARAMS[@]}"; do [[ ${e%%|*} == "$1" ]] && { echo "${e##*|}"; return; }; done; echo "$1"; }
type_hint(){ case $1 in
ip) echo "IPv4-адрес, например 192.168.1.10" ;; ipo) echo "IPv4-адрес или пусто" ;;
pfx) echo "Префикс 0–32 (можно /28 или маску 255.255.255.240)" ;; port) echo "Порт 1–65535" ;; int) echo "Целое число" ;;
name) echo "Латиница, цифры, точка, дефис" ;; yn) echo "yes или no" ;; alg) echo "rsa или gost" ;;
list) echo "Записи имя=IP или имя=IP:ptr через пробел" ;; *) echo "Текст без кавычек" ;; esac; }
# Приводит «/28» и «255.255.255.240» к префиксу 28.
norm_pfx(){ local v=${1#/}; if is_pfx "$v"; then echo "$v"; elif is_ip "$v"; then mask2pfx "$v"; else return 1; fi; }
# Выбор интерфейса из списка интерфейсов ВМ.
pick_iface(){ local v=$1 i st mac items=() c
for i in /sys/class/net/*; do i=${i##*/}; [[ $i == lo || $i == ovs-system ]] && continue
st=$(cat "/sys/class/net/$i/operstate" 2>/dev/null); mac=$(cat "/sys/class/net/$i/address" 2>/dev/null)
items+=("$i" "$st $mac $(ip -4 -o addr show dev "$i" 2>/dev/null | awk '{print $4}' | head -1)"); done
items+=("*" "Ввести имя вручную")
c=$(ui_menu "$(label_of "$v")" "Текущее значение: ${!v:-не выбрано}. Выберите интерфейс этой ВМ:" "${items[@]}") || return 1
if [[ $c == "*" ]]; then c=$(ui_input "$(label_of "$v")" "Имя интерфейса (например ens19):" "${!v}") || return 1; fi
if is_iface "$c" || { ((DRY)) && [[ $c =~ ^[A-Za-z0-9._@-]{1,15}$ ]]; }; then printf -v "$v" '%s' "$c"; conf_save; return 0; fi
ui_msg "Интерфейс «$c» не найден на этой ВМ."; return 1; }
ui_msg(){ if [[ $UI != text ]]; then ui_call --title "Сообщение" --msgbox "$1" 12 "${UI_W:-76}" >/dev/null; else warn "$1"; fi; }
# edit_param ПЕРЕМЕННАЯ ["вопрос"] — ввод значения с проверкой.
edit_param(){ local v=$1 q=${2:-} t a; t=$(param_type "$v"); t=${t:-text}
[[ $t == if ]] && { pick_iface "$v"; return; }
[[ -n $q ]] || q=$(label_of "$v")
while :; do a=$(ui_input "$(label_of "$v")" "$q"$'\n'"$(type_hint "$t")" "${!v}") || return 1
[[ $t == pfx ]] && { a=$(norm_pfx "$a") || { ui_msg "Неверный префикс или маска."; continue; }; }
if valid "$t" "$a"; then printf -v "$v" '%s' "$a"; conf_save; return 0; fi
ui_msg "Неверное значение. $(type_hint "$t")."; done; }
ask(){ edit_param "$1" "$2"; }
ask_iface(){ pick_iface "$1"; }
need_iface(){ [[ -n ${!1} ]] && { is_iface "${!1}" || ((DRY)); } && return 0; pick_iface "$1"; }
show_val(){ local v=$1 t; t=$(param_type "$v")
if [[ -z ${!v} ]]; then [[ $t == if ]] && echo "(не выбран)" || echo "(пусто)"
elif [[ $t == pfx ]]; then echo "/${!v} ($(pfx2mask "${!v}"))"; else echo "${!v}"; fi; }
settings_menu(){ local items=() e k c i
while :; do items=(); i=1
for e in "${PARAMS[@]}"; do k=${e%%|*}; items+=("$i" "$(padr "${e##*|}" 40)$(show_val "$k")"); i=$((i+1)); done
for k in "${IFACE_VARS[@]}"; do items+=("$i" "$(padr "${IF_LABEL[$k]}" 40)$(show_val "$k")"); i=$((i+1)); done
items+=("0" "Назад")
c=$(ui_menu "Параметры стенда" "Файл: $CONF (пароль не сохраняется)" "${items[@]}") || return
[[ $c == 0 ]] && return
if ((c <= ${#PARAMS[@]})); then e=${PARAMS[c-1]}; edit_param "${e%%|*}"; else edit_param "${IFACE_VARS[c-1-${#PARAMS[@]}]}"; fi; done; }
need_pass(){ [[ -n $PASS ]] && return 0
if ((DRY)); then PASS='DryRun.Pass1'; return 0; fi
local p1 p2
p1=$(ui_pass "Общий пароль стенда (8–64 символа: A-Z a-z 0-9 @ . _ ! + -)") || return 1
p2=$(ui_pass "Повторите пароль") || return 1
[[ $p1 == "$p2" && ${#p1} -ge 8 && ${#p1} -le 64 && $p1 =~ ^[A-Za-z0-9@._!+-]+$ ]] || { ui_msg "Пароли не совпадают или недопустимы."; return 1; }
PASS=$p1; }
# padr ТЕКСТ ШИРИНА — дополняет пробелами по числу символов (корректно для кириллицы).
padr(){ local n=$(( $2 - ${#1} )); ((n < 1)) && n=1; printf '%s%*s' "$1" "$n" ''; }
# ============================ калькулятор масок ==============================
ip2int(){ local IFS=. a b c d; read -r a b c d <<< "$1"; echo $(( (10#$a << 24) | (10#$b << 16) | (10#$c << 8) | 10#$d )); }
int2ip(){ local n=$1; echo "$(( (n >> 24) & 255 )).$(( (n >> 16) & 255 )).$(( (n >> 8) & 255 )).$(( n & 255 ))"; }
pfx2int(){ (( $1 == 0 )) && { echo 0; return; }; echo $(( (0xFFFFFFFF << (32 - $1)) & 0xFFFFFFFF )); }
mask2pfx(){ is_ip "$1" || return 1; local m p; m=$(ip2int "$1")
for ((p = 0; p <= 32; p++)); do (( $(pfx2int "$p") == m )) && { echo "$p"; return 0; }; done; return 1; }
hosts_of(){ local p=$1; if ((p == 32)); then echo 1; elif ((p == 31)); then echo 2; else echo $(( (1 << (32 - p)) - 2 )); fi; }
# net_info IP ПРЕФИКС — сведения о подсети.
net_info(){ local ip=$1 p=$2 a m net bc first last
a=$(ip2int "$ip"); m=$(pfx2int "$p"); net=$(( a & m )); bc=$(( net | (~m & 0xFFFFFFFF) ))
if ((p >= 31)); then first=$net; last=$bc; else first=$((net + 1)); last=$((bc - 1)); fi
local k v; while IFS='|' read -r k v; do echo "$(padr "$k" 22)$v"; done <<EOT
Адрес:|$ip
Префикс / маска:|/$p $(pfx2mask "$p")
Обратная маска:|$(int2ip $(( ~m & 0xFFFFFFFF )))
Сеть:|$(int2ip $net)/$p
Широковещательный:|$(int2ip $bc)
Первый узел:|$(int2ip $first)
Последний узел:|$(int2ip $last)
Узлов:|$(hosts_of "$p")
EOT
(( a == net && p < 31 )) && echo "Примечание: это адрес сети, узлу его назначать нельзя."
(( a == bc && p < 31 )) && echo "Примечание: это широковещательный адрес, узлу его назначать нельзя."; return 0; }
# parse_cidr "IP/префикс" | "IP маска" | "IP" → CALC_IP CALC_PFX
parse_cidr(){ local s=${1//,/ } ip p; s=$(echo "$s" | xargs)
if [[ $s == */* ]]; then ip=${s%/*}; p=${s#*/}; elif [[ $s == *' '* ]]; then ip=${s%% *}; p=${s##* }; else ip=$s; p=; fi
is_ip "$ip" || return 1
if [[ -z $p ]]; then p=$(ui_input "Калькулятор" "Префикс или маска для $ip:" "24") || return 1; fi
p=$(norm_pfx "$p") || return 1; CALC_IP=$ip; CALC_PFX=$p; }
calc_net(){ local s; s=$(ui_input "Расчёт подсети" "Адрес и префикс/маска: 192.168.100.5/27 или 192.168.100.5 255.255.255.224" "${CALC_LAST:-192.168.100.0/27}") || return
parse_cidr "$s" || { ui_msg "Не удалось разобрать «$s»."; return; }; CALC_LAST="$CALC_IP/$CALC_PFX"
net_info "$CALC_IP" "$CALC_PFX" | ui_text "Подсеть $CALC_IP/$CALC_PFX"; }
calc_hosts(){ local n p
n=$(ui_input "Подбор префикса" "Сколько адресов узлов нужно в сети (включая шлюз)?" "14") || return
[[ $n =~ ^[0-9]+$ ]] && ((n >= 1 && n <= 4294967294)) || { ui_msg "Нужно число узлов."; return; }
for ((p = 32; p >= 0; p--)); do (( $(hosts_of "$p") >= n )) && break; done
{ echo "Для $n узлов подходит минимальный префикс /$p (маска $(pfx2mask "$p"))."
echo "Узлов в такой сети: $(hosts_of "$p"); всего адресов: $(( 1 << (32 - p) ))."
((p > 0)) && echo "Префикс /$((p - 1)) даст $(hosts_of $((p - 1))) узлов (с запасом)."; } | ui_text "Подбор префикса"; }
calc_split(){ local s n p np i base step
s=$(ui_input "Деление сети" "Сеть для деления, например 192.168.0.0/24:" "192.168.0.0/24") || return
parse_cidr "$s" || { ui_msg "Не удалось разобрать «$s»."; return; }
np=$(ui_input "Деление сети" "Новый префикс подсетей (больше /$CALC_PFX):" "$((CALC_PFX + 2 > 32 ? 32 : CALC_PFX + 2))") || return
np=$(norm_pfx "$np") || { ui_msg "Неверный префикс."; return; }
((np > CALC_PFX)) || { ui_msg "Новый префикс должен быть больше /$CALC_PFX."; return; }
n=$(( 1 << (np - CALC_PFX) )); ((n <= 256)) || { ui_msg "Получится $n подсетей — слишком много для вывода (максимум 256)."; return; }
base=$(( $(ip2int "$CALC_IP") & $(pfx2int "$CALC_PFX") )); step=$(( 1 << (32 - np) ))
{ printf '%-4s %-20s %-17s %-17s %s\n' "№" "Подсеть" "Первый узел" "Последний узел" "Широковещ."
for ((i = 0; i < n; i++)); do p=$(( base + i * step ))
if ((np >= 31)); then printf '%-4s %-20s %-17s %-17s %s\n' $((i + 1)) "$(int2ip $p)/$np" "$(int2ip $p)" "$(int2ip $((p + step - 1)))" "-"
else printf '%-4s %-20s %-17s %-17s %s\n' $((i + 1)) "$(int2ip $p)/$np" "$(int2ip $((p + 1)))" "$(int2ip $((p + step - 2)))" "$(int2ip $((p + step - 1)))"; fi
done; } | ui_text "Деление $(int2ip $base)/$CALC_PFX на /$np"; }
calc_table(){ local p; { printf '%-6s %-17s %-12s %s\n' "Префикс" "Маска" "Адресов" "Узлов"
for ((p = 32; p >= 8; p--)); do printf '/%-5s %-17s %-12s %s\n' "$p" "$(pfx2mask "$p")" "$(( 1 << (32 - p) ))" "$(hosts_of "$p")"; done; } | ui_text "Таблица префиксов"; }
# Проверка стенда: каждый адрес должен входить в свою сеть.
in_net(){ local m; m=$(pfx2int "$3"); (( ($(ip2int "$1") & m) == ($(ip2int "$2") & m) )); }
calc_check(){ local line ip net p name bad=0
{ echo "$(padr Узел 18)$(padr IP 17)$(padr Сеть 21)Итог"
while read -r name ip net p; do
if in_net "${!ip}" "${!net}" "${!p}"; then r=OK; else r="НЕ В СЕТИ"; bad=1; fi
printf '%-18s %-16s %-20s %s\n' "$name" "${!ip}" "$(int2ip $(( $(ip2int "${!net}") & $(pfx2int "${!p}") )))/${!p}" "$r"
done <<'EOT'
ISP-to-HQ ISP_HQ_IP HQ_RTR_WAN_IP HQ_WAN_PFX
HQ-RTR_WAN HQ_RTR_WAN_IP ISP_HQ_IP HQ_WAN_PFX
ISP-to-BR ISP_BR_IP BR_RTR_WAN_IP BR_WAN_PFX
HQ-RTR_VLAN_SRV HQ_RTR_SRV_IP SRV_NET SRV_PFX
HQ-SRV HQ_SRV_IP SRV_NET SRV_PFX
HQ-RTR_VLAN_CLI HQ_RTR_CLI_IP CLI_NET CLI_PFX
DHCP-start DHCP_START CLI_NET CLI_PFX
DHCP-end DHCP_END CLI_NET CLI_PFX
HQ-CLI HQ_CLI_IP CLI_NET CLI_PFX
HQ-RTR_MGMT HQ_RTR_MGMT_IP MGMT_NET MGMT_PFX
HQ-SW_MGMT HQ_SW_MGMT_IP MGMT_NET MGMT_PFX
BR-RTR-to-FW BR_RTR_FW_IP FW_NET FW_PFX
BR-FW-to-RTR BR_FW_IP FW_NET FW_PFX
BR-FW_LAN BR_FW_LAN_IP BR_NET BR_PFX
BR-SRV BR_SRV_IP BR_NET BR_PFX
HQ-RTR_GRE HQ_GRE_IP GRE_NET GRE_PFX
BR-RTR_GRE BR_GRE_IP GRE_NET GRE_PFX
EOT
echo; echo "Сети серверов/клиентов: узлов в /$SRV_PFX — $(hosts_of "$SRV_PFX"), в /$CLI_PFX — $(hosts_of "$CLI_PFX"), в /$MGMT_PFX — $(hosts_of "$MGMT_PFX")."
} | ui_text "Проверка адресов стенда"; }
calc_menu(){ local c
while :; do c=$(ui_menu "Калькулятор масок" "Выберите действие:" \
1 "Рассчитать подсеть (IP/префикс или IP маска)" 2 "Подобрать префикс по числу узлов" 3 "Разделить сеть на подсети" \
4 "Таблица префиксов /8–/32" 5 "Проверить адреса стенда (входят ли в свои сети)" 0 "Назад") || return
case $c in 1) calc_net ;; 2) calc_hosts ;; 3) calc_split ;; 4) calc_table ;; 5) calc_check ;; 0) return ;; esac; done; }
# ================================== меню =====================================
# Данные, которые показываются и вводятся перед выполнением пункта.
declare -A TASK_PARAMS=(
[c_tz]="TZ_NAME" [c_hostname]="DOMAIN" [u_rtr]="ADM_USER" [u_srv]="SSH_USER SSH_UID" [c_sshd]="SSH_USER SSH_PORT BANNER_TEXT"
[isp_net]="ISP_WAN_IF ISP_HQ_IF ISP_BR_IF ISP_HQ_IP HQ_WAN_PFX ISP_BR_IP BR_WAN_PFX"
[isp_nat]="ISP_WAN_IF ISP_HQ_IF ISP_BR_IF" [isp_chrony]="NTP_UPSTREAM ISP_HQ_IP HQ_WAN_PFX ISP_BR_IP BR_WAN_PFX"
[isp_proxy]="DOMAIN HQ_RTR_WAN_IP BR_RTR_WAN_IP" [isp_auth]="DOMAIN HQ_RTR_WAN_IP BR_RTR_WAN_IP" [isp_https]="HQ_RTR_WAN_IP SSH_PORT SSH_USER"
[hqr_net]="HQ_RTR_WAN_IF HQ_RTR_LAN_IF HQ_RTR_WAN_IP HQ_WAN_PFX ISP_HQ_IP DNS_FWD VLAN_SRV HQ_RTR_SRV_IP SRV_PFX VLAN_CLI HQ_RTR_CLI_IP CLI_PFX VLAN_MGMT HQ_RTR_MGMT_IP MGMT_PFX"
[hqr_nat]="HQ_RTR_WAN_IF HQ_RTR_LAN_IF" [hqr_gre]="GRE_IF HQ_RTR_WAN_IP BR_RTR_WAN_IP HQ_GRE_IP GRE_PFX"
[hqr_ospf]="HQ_GRE_IP GRE_NET GRE_PFX SRV_NET SRV_PFX CLI_NET CLI_PFX MGMT_NET MGMT_PFX"
[hqr_dhcp]="HQ_RTR_LAN_IF VLAN_CLI DHCP_START DHCP_END CLI_PFX HQ_RTR_CLI_IP HQ_SRV_IP DOMAIN HQ_CLI_MAC HQ_CLI_IP"
[hqr_ntp]="ISP_HQ_IP" [hqr_dnat]="HQ_SRV_IP SSH_PORT"
[brr_net]="BR_RTR_WAN_IF BR_RTR_LAN_IF BR_RTR_WAN_IP BR_WAN_PFX ISP_BR_IP DNS_FWD BR_RTR_FW_IP FW_PFX"
[brr_nat]="BR_RTR_WAN_IF BR_RTR_LAN_IF" [brr_gre]="GRE_IF BR_RTR_WAN_IP HQ_RTR_WAN_IP BR_GRE_IP GRE_PFX"
[brr_ospf]="BR_RTR_LAN_IF OSPF_FW_AUTH BR_GRE_IP GRE_NET GRE_PFX FW_NET FW_PFX" [brr_ntp]="ISP_BR_IP" [brr_dnat]="BR_SRV_IP SSH_PORT"
[brfw_net]="BR_FW_WAN_IF BR_FW_LAN_IF BR_FW_IP FW_PFX BR_RTR_FW_IP BR_FW_LAN_IP BR_PFX HQ_SRV_IP"
[brfw_ospf]="BR_FW_WAN_IF OSPF_FW_AUTH BR_FW_IP FW_NET FW_PFX BR_NET BR_PFX"
[hqsw_ovs]="HQ_SW_UP_IF HQ_SW_SRV_IF HQ_SW_CLI_IF VLAN_SRV VLAN_CLI VLAN_MGMT HQ_SW_MGMT_IP MGMT_PFX HQ_RTR_MGMT_IP"
[hqs_net]="HQ_SRV_IF HQ_SRV_IP SRV_PFX HQ_RTR_SRV_IP DNS_FWD"
[hqs_dns]="DOMAIN HQ_SRV_IP DNS_FWD DNS_FWD2 DNS_RECORDS WEB_DNS_IP DOCKER_DNS_IP" [hqs_adfwd]="DOMAIN BR_SRV_IP"
[hqs_raid]="RAID_LEVEL RAID_MOUNT" [hqs_nfs]="RAID_MOUNT CLI_NET CLI_PFX" [hqs_ntp]="ISP_HQ_IP" [hqs_web]="ISO_SRC ISO_DIR"
[hqs_ca]="CERT_ALG SSH_USER DOMAIN RAID_MOUNT" [hqs_f2b]="SSH_PORT" [hqs_backup]="ISO_SRC ISO_DIR"
[brs_net]="BR_SRV_IF BR_SRV_IP BR_PFX BR_FW_LAN_IP HQ_SRV_IP" [brs_dc]="DOMAIN AD_WORKGROUP HQ_SRV_IP" [brs_ntp]="ISP_BR_IP"
[brs_ansible]="HQ_SRV_IP SSH_USER SSH_PORT HQ_CLI_IP CLI_SSH_USER CLI_SSH_PORT HQ_GRE_IP BR_RTR_FW_IP ADM_USER"
[brs_docker]="ISO_SRC ISO_DIR APP_INNER_PORT" [brs_import]="ISO_SRC ISO_DIR"
[hqc_net]="HQ_CLI_IF" [hqc_join]="DOMAIN AD_ADMIN" [hqc_nfs]="HQ_SRV_IP RAID_MOUNT" [hqc_ntp]="ISP_HQ_IP"
[hqc_ssh]="CLI_SSH_USER CLI_SSH_PORT" [hqc_printer]="DOMAIN"
)
run_item(){ local desc=$1 fn=$2 rc
ui_clear; hdr "$desc"; "$fn"; rc=$?
if ((rc == 0)); then ok "Готово: $desc"; else err "Не выполнено: $desc (код $rc)"; fi; pause; return $rc; }
# task_screen ФУНКЦИЯ "описание" [skip] — окно пункта: данные + выполнить.
# Код: 0 выполнено, 1 ошибка, 2 назад/пропуск.
task_screen(){ local fn=$1 desc=$2 skip=${3:-} vars c i items v
read -ra vars <<< "${TASK_PARAMS[$fn]:-}"
while :; do items=("R" "▶ Выполнить пункт")
i=1; for v in "${vars[@]}"; do items+=("$i" "$(padr "$(label_of "$v")" 38)$(show_val "$v")"); i=$((i+1)); done
((${#vars[@]})) && items+=("A" "Ввести все данные по очереди")
items+=("K" "Калькулятор масок"); [[ -n $skip ]] && items+=("S" "Пропустить пункт"); items+=("0" "Назад")
c=$(ui_menu "$desc" "Проверьте данные пункта. Выберите строку, чтобы вписать своё значение." "${items[@]}") || return 2
case $c in
R|r) run_item "$desc" "$fn"; return ;;
A|a) for v in "${vars[@]}"; do edit_param "$v" || break; done ;;
K|k) calc_menu ;;
S|s|0) return 2 ;;
*) [[ $c =~ ^[0-9]+$ ]] && ((c >= 1 && c <= ${#vars[@]})) && edit_param "${vars[c-1]}" ;;
esac; done; }
# module_menu id "заголовок" М1 — список пунктов модуля.
module_menu(){ local id=$1 title=$2 mod=$3; local -n items="M_$id"; local e m d fn i c tags=() fns=() descs=()
while :; do tags=(); fns=(); descs=(); i=1
for e in "${items[@]}"; do IFS='|' read -r m d fn <<< "$e"; [[ $m == "$mod" ]] || continue
tags+=("$i" "$d"); fns+=("$fn"); descs+=("$d"); i=$((i+1)); done
((${#fns[@]})) || { ui_msg "В модуле $mod для $title нет пунктов."; return; }
tags+=("*" "Выполнить весь модуль по порядку" "0" "Назад")
c=$(ui_menu "$title — модуль $mod" "Выберите пункт:" "${tags[@]}") || return
case $c in
0) return ;;
"*") for ((i = 0; i < ${#fns[@]}; i++)); do task_screen "${fns[i]}" "${descs[i]}" skip; rc=$?
((rc == 1)) && { ui_yesno "Пункт «${descs[i]}» не выполнен. Продолжить модуль?" n || break; }; done ;;
*) [[ $c =~ ^[0-9]+$ ]] && ((c >= 1 && c <= ${#fns[@]})) && task_screen "${fns[c-1]}" "${descs[c-1]}" ;;
esac; done; }
machine_menu(){ local id=$1 title=$2 host=$3; local -n items="M_$id"; local e m c n1=0 n2=0 n3=0
MACHINE=$host
for e in "${items[@]}"; do case ${e%%|*} in М1) n1=$((n1+1));; М2) n2=$((n2+1));; М3) n3=$((n3+1));; esac; done
while :; do
local -a it=(); ((n1)) && it+=(1 "Модуль 1 ($n1 пунктов)"); ((n2)) && it+=(2 "Модуль 2 ($n2 пунктов)"); ((n3)) && it+=(3 "Модуль 3 ($n3 пунктов)")
it+=(h "Имя хоста → $(fqdn "$host")" p "Подготовка: установить утилиты" c "Проверка состояния машины" k "Калькулятор масок" 0 "Назад")
c=$(ui_menu "$title" "Машина: $title. Выберите модуль:" "${it[@]}") || return
case $c in
0) return ;; 1|2|3) module_menu "$id" "$title" "М$c" ;;
h) task_screen c_hostname "Имя хоста $(fqdn "$host")" ;;
p) task_screen os_prep "Подготовка: утилиты" ;;
c) ui_clear; hdr "Проверка $title"; "chk_$id"; pause ;;
k) calc_menu ;;
esac; done; }
main_menu(){ local c rc
while :; do
c=$(ui_menu "demo-setup v$VERSION — домен $DOMAIN" "Какую машину настраиваем?" \
1 "ISP" 2 "HQ-RTR" 3 "BR-RTR" 4 "BR-FW" 5 "HQ-SW" 6 "HQ-SRV" 7 "BR-SRV" 8 "HQ-CLI" \
9 "Калькулятор масок" P "Параметры стенда (все адреса и имена)" 0 "Выход"); rc=$?
((rc == 255)) && exit 0
((rc != 0)) && { ui_yesno "Выйти из программы?" y && { ui_clear; exit 0; }; continue; }
case $c in
1) machine_menu isp ISP isp ;; 2) machine_menu hqr HQ-RTR hq-rtr ;; 3) machine_menu brr BR-RTR br-rtr ;; 4) machine_menu brfw BR-FW br-fw ;;
5) machine_menu hqsw HQ-SW hq-sw ;; 6) machine_menu hqs HQ-SRV hq-srv ;; 7) machine_menu brs BR-SRV br-srv ;; 8) machine_menu hqc HQ-CLI hq-cli ;;
9) calc_menu ;; P|p) settings_menu ;; 0) ui_clear; exit 0 ;;
esac; done; }
ui_init
[[ -f $CONF ]] || { ui_yesno "Файл параметров не найден. Значения по умолчанию примерные — открыть параметры и сверить их с заданием?" n && settings_menu; }
main_menu