#!/usr/bin/env bash # Первичная подготовка Ubuntu 24.04 для HAN Chat VM2 Processing. # # Скрипт устанавливает host-зависимости, Docker/Compose, создаёт непривилегированную # роль deploy, настраивает SSH/UFW/fail2ban/DOCKER-USER/swap и устанавливает # root-owned deployment helpers, если релиз уже размещён в DEPLOY_DIR. # # PostgreSQL, S3, Selectel IAM, DNS, TLS, образы, .env и значения секретов # скрипт не создаёт. Он не запускает Compose и прикладные контейнеры. # # Первый запуск на свежей VM выполняется root: # DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ # ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ # VM1_PRIVATE_CIDRS=10.10.1.5/32 \ # bash deployment/scripts/setup-vm.sh # # После задания sudo-пароля admin и проверки обоих входов: # DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ # ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ # VM1_PRIVATE_CIDRS=10.10.1.5/32 \ # HARDEN_SSH=true SKIP_APT_UPGRADE=true \ # bash deployment/scripts/setup-vm.sh # # Параметры: # DEPLOY_USER=deploy # ADMIN_USER=admin # DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub # ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub # DEPLOY_DIR=/opt/han-chat/services # INCOMING_DIR=/var/lib/han-deploy/incoming # SSH_PORT=22 # OPS_CIDRS=10.20.0.0/24 # необязательные приватные/VPN-сети для API 8443 # VM1_PRIVATE_CIDRS=10.10.1.5/32 # TIMEZONE=Europe/Moscow # SWAP_SIZE_GB=4 # EXTERNAL_IF=ens3 # HARDEN_SSH=false # LOCK_ACCOUNT_PASSWORDS=true # RESET_UFW=true # SKIP_APT_UPGRADE=false set -Eeuo pipefail IFS=$'\n\t' DEPLOY_USER="${DEPLOY_USER:-deploy}" ADMIN_USER="${ADMIN_USER:-admin}" DEPLOY_AUTHORIZED_KEY_FILE="${DEPLOY_AUTHORIZED_KEY_FILE:-}" ADMIN_AUTHORIZED_KEY_FILE="${ADMIN_AUTHORIZED_KEY_FILE:-}" DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/services}" INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}" SSH_PORT="${SSH_PORT:-22}" OPS_CIDRS="${OPS_CIDRS:-}" VM1_PRIVATE_CIDRS="${VM1_PRIVATE_CIDRS:-}" TIMEZONE="${TIMEZONE:-Europe/Moscow}" SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}" EXTERNAL_IF="${EXTERNAL_IF:-}" HARDEN_SSH="${HARDEN_SSH:-false}" LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}" RESET_UFW="${RESET_UFW:-true}" SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}" LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm2-setup.log}" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" } step() { log "" log "==> $*" } die() { log "ОШИБКА: $*" exit 1 } on_error() { local exit_code=$? log "ОШИБКА: команда завершилась с кодом ${exit_code}, строка ${BASH_LINENO[0]}" exit "$exit_code" } trap on_error ERR require_root() { [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Запустите скрипт от root" } is_true_or_false() { [[ "$1" == "true" || "$1" == "false" ]] } validate_cidr_list() { local label=$1 local value=$2 local item local octet local prefix [[ -n "$value" ]] || die "${label} обязателен и не может быть пустым" IFS=',' read -ra items <<<"$value" for item in "${items[@]}"; do [[ "$item" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]{1,2})$ ]] \ || die "${label} содержит некорректный IPv4 CIDR: ${item}" prefix="${item##*/}" ((10#$prefix >= 1 && 10#$prefix <= 32)) \ || die "${label}: префикс должен быть от /1 до /32: ${item}" IFS='.' read -ra octets <<<"${item%/*}" for octet in "${octets[@]}"; do ((10#$octet <= 255)) || die "${label} содержит некорректный IPv4 CIDR: ${item}" done done } validate_parameters() { [[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER" [[ "$DEPLOY_USER" == "deploy" ]] \ || die "VM2 units/sudoers используют фиксированную роль deploy" [[ "$ADMIN_USER" == "admin" ]] || die "VM2 break-glass роль должна называться admin" [[ "$DEPLOY_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "DEPLOY_AUTHORIZED_KEY_FILE должен быть безопасным абсолютным путём" [[ "$ADMIN_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "ADMIN_AUTHORIZED_KEY_FILE должен быть безопасным абсолютным путём" [[ "$DEPLOY_AUTHORIZED_KEY_FILE" != "$ADMIN_AUTHORIZED_KEY_FILE" ]] \ || die "deploy и admin должны использовать разные public key files" [[ "$DEPLOY_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "DEPLOY_DIR должен быть безопасным абсолютным путём" [[ "$INCOMING_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] \ || die "INCOMING_DIR должен быть безопасным абсолютным путём" [[ "$DEPLOY_DIR" != "$INCOMING_DIR" ]] || die "DEPLOY_DIR и INCOMING_DIR должны различаться" [[ "$TIMEZONE" =~ ^[A-Za-z0-9_+/-]+$ ]] || die "Некорректный TIMEZONE" [[ -z "$EXTERNAL_IF" || "$EXTERNAL_IF" =~ ^[A-Za-z0-9_.:-]+$ ]] \ || die "Некорректный EXTERNAL_IF" [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом" ((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона" [[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом" is_true_or_false "$HARDEN_SSH" || die "HARDEN_SSH должен быть true или false" is_true_or_false "$LOCK_ACCOUNT_PASSWORDS" \ || die "LOCK_ACCOUNT_PASSWORDS должен быть true или false" if [[ "$HARDEN_SSH" == "true" && "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then die "HARDEN_SSH=true требует LOCK_ACCOUNT_PASSWORDS=true" fi is_true_or_false "$RESET_UFW" || die "RESET_UFW должен быть true или false" is_true_or_false "$SKIP_APT_UPGRADE" || die "SKIP_APT_UPGRADE должен быть true или false" if [[ -n "$OPS_CIDRS" ]]; then validate_cidr_list OPS_CIDRS "$OPS_CIDRS" fi validate_cidr_list VM1_PRIVATE_CIDRS "$VM1_PRIVATE_CIDRS" } check_os() { step "Проверка операционной системы" [[ -r /etc/os-release ]] || die "Не найден /etc/os-release" # shellcheck disable=SC1091 source /etc/os-release [[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu" [[ "${VERSION_ID%%.*}" -ge 24 ]] || die "Требуется Ubuntu 24.04 или новее" log "Обнаружена ${PRETTY_NAME}" } update_system() { step "Обновление системы и установка host-пакетов" export DEBIAN_FRONTEND=noninteractive apt-get update if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then apt-get dist-upgrade -y fi apt-get install -y --no-install-recommends \ ca-certificates \ certbot \ curl \ fail2ban \ git \ gnupg \ iptables \ jq \ logrotate \ netcat-openbsd \ openssh-client \ openssl \ python3 \ rsync \ sudo \ unattended-upgrades \ ufw \ util-linux } configure_time() { step "Настройка времени" timedatectl set-timezone "$TIMEZONE" timedatectl set-ntp true } install_authorized_key() { local user=$1 local source=$2 local target="/home/${user}/.ssh/authorized_keys" [[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный public key file: ${source}" [[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать ровно один public key" ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH public key: ${source}" grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \ || die "Для ${user} разрешён только отдельный Ed25519 public key" install -d -m 0700 -o "$user" -g "$user" "/home/${user}/.ssh" install -m 0600 -o "$user" -g "$user" "$source" "$target" } create_host_roles() { step "Создание ролей deploy и break-glass admin" if ! id "$DEPLOY_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$DEPLOY_USER" log "Создан пользователь ${DEPLOY_USER}" fi if ! id "$ADMIN_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$ADMIN_USER" log "Создан break-glass пользователь ${ADMIN_USER}" fi install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE" install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE" local deploy_key local admin_key deploy_key="$(awk '{print $2}' "$DEPLOY_AUTHORIZED_KEY_FILE")" admin_key="$(awk '{print $2}' "$ADMIN_AUTHORIZED_KEY_FILE")" [[ "$deploy_key" != "$admin_key" ]] || die "deploy и admin не могут использовать один SSH key" if [[ -s /root/.ssh/authorized_keys ]] && { grep -Fq "$deploy_key" /root/.ssh/authorized_keys || grep -Fq "$admin_key" /root/.ssh/authorized_keys; }; then die "Ключ deploy/admin совпадает с одним из root authorized_keys" fi # Deploy получает только точные sudoers-команды, без широких групп. local forbidden_group for forbidden_group in docker sudo lxd adm systemd-journal; do if getent group "$forbidden_group" >/dev/null && id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$forbidden_group"; then gpasswd -d "$DEPLOY_USER" "$forbidden_group" fi done # Admin — персональная break-glass роль: sudo требует отдельный локальный пароль. usermod -aG sudo "$ADMIN_USER" for forbidden_group in docker lxd; do if getent group "$forbidden_group" >/dev/null && id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$forbidden_group"; then gpasswd -d "$ADMIN_USER" "$forbidden_group" fi done } configure_account_passwords() { step "Блокировка root/deploy и проверка break-glass admin" if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then log "LOCK_ACCOUNT_PASSWORDS=false: пароли root/deploy не изменены" else passwd --lock root passwd --lock "$DEPLOY_USER" fi local admin_password_status admin_password_status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')" if [[ "$HARDEN_SSH" == "true" && "$admin_password_status" != "P" ]]; then die "Перед HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}" fi if [[ "$admin_password_status" != "P" ]]; then log "ПРЕДУПРЕЖДЕНИЕ: admin пока без sudo-пароля; выполните passwd ${ADMIN_USER}" fi } configure_layout() { step "Создание каталогов и границ владения" install -d -m 0755 -o root -g root /opt/han-chat install -d -m 0755 -o root -g root "$DEPLOY_DIR" install -d -m 0755 -o root -g root /var/lib/han-deploy install -d -m 0750 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$INCOMING_DIR" install -d -m 0700 -o root -g root \ /etc/han \ /etc/han/secrets \ /etc/han/credentials \ /etc/han-chat if [[ -f "${DEPLOY_DIR}/.env" ]]; then chown root:root "${DEPLOY_DIR}/.env" chmod 0600 "${DEPLOY_DIR}/.env" fi } configure_swap() { step "Настройка swap" if ((SWAP_SIZE_GB == 0)); then log "Создание swap отключено" return fi if ! swapon --show=NAME --noheadings | grep -qx '/swapfile'; then if [[ ! -f /swapfile ]]; then fallocate -l "${SWAP_SIZE_GB}G" /swapfile chmod 0600 /swapfile mkswap /swapfile fi swapon /swapfile fi grep -q '^/swapfile ' /etc/fstab \ || printf '/swapfile none swap sw 0 0\n' >>/etc/fstab printf 'vm.swappiness = 10\n' >/etc/sysctl.d/99-han-chat-vm2-swappiness.conf } configure_sysctl() { step "Настройка сетевого стека" cat >/etc/sysctl.d/99-han-chat-vm2-hardening.conf <<'EOF' net.ipv4.ip_forward = 1 net.ipv4.tcp_syncookies = 1 net.ipv4.conf.all.accept_redirects = 0 net.ipv4.conf.default.accept_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.default.send_redirects = 0 net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 net.ipv4.icmp_echo_ignore_broadcasts = 1 net.ipv4.tcp_fin_timeout = 30 EOF sysctl --system >/dev/null } install_docker() { step "Установка Docker Engine и Compose plugin" if ! command -v docker >/dev/null 2>&1; then install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg # shellcheck disable=SC1091 source /etc/os-release printf '%s\n' \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \ >/etc/apt/sources.list.d/docker.list apt-get update apt-get install -y --no-install-recommends \ docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi install -d -m 0755 /etc/docker cat >/etc/docker/daemon.json <<'EOF' { "live-restore": true, "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "5" }, "userland-proxy": false } EOF systemctl enable --now docker systemctl restart docker docker compose version >/dev/null || die "Docker Compose plugin не установлен" log "$(docker --version)" log "$(docker compose version)" } for_each_cidr() { local value=$1 local callback=$2 local item [[ -n "$value" ]] || return 0 IFS=',' read -ra items <<<"$value" for item in "${items[@]}"; do "$callback" "$item" done } allow_private_api() { ufw allow from "$1" to any port 8443 proto tcp comment 'HAN VM2 private API' } configure_ufw() { step "Настройка UFW" if [[ "$RESET_UFW" == "true" ]]; then ufw --force reset else log "ПРЕДУПРЕЖДЕНИЕ: RESET_UFW=false сохраняет ранее созданные UFW allow rules" fi ufw default deny incoming ufw default allow outgoing ufw allow "${SSH_PORT}/tcp" comment 'HAN VM2 SSH' for_each_cidr "$OPS_CIDRS" allow_private_api for_each_cidr "$VM1_PRIVATE_CIDRS" allow_private_api ufw allow 80/tcp comment 'HAN VM2 public ACME' ufw allow 443/tcp comment 'HAN VM2 public Bitrix webhooks' ufw logging medium ufw --force enable } configure_fail2ban() { step "Настройка fail2ban" cat >/etc/fail2ban/jail.d/han-chat-vm2.local </etc/apt/apt.conf.d/51han-chat-vm2-unattended <<'EOF' Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; EOF dpkg-reconfigure -f noninteractive unattended-upgrades systemctl enable --now unattended-upgrades } configure_docker_firewall() { step "Фильтрация опубликованных Docker-портов" cat >/etc/default/han-chat-vm2-docker-firewall </usr/local/sbin/han-chat-vm2-docker-firewall <<'FIREWALL' #!/usr/bin/env bash set -Eeuo pipefail # shellcheck disable=SC1091 source /etc/default/han-chat-vm2-docker-firewall external_if="${EXTERNAL_IF:-}" if [[ -z "$external_if" ]]; then external_if="$(ip -4 route show default | awk '{print $5; exit}')" fi [[ -n "$external_if" ]] || { echo "Не удалось определить внешний интерфейс" >&2 exit 1 } iptables -N HAN-CHAT-VM2 2>/dev/null || true iptables -F HAN-CHAT-VM2 iptables -A HAN-CHAT-VM2 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN iptables -A HAN-CHAT-VM2 -i lo -j RETURN iptables -A HAN-CHAT-VM2 -i "$external_if" -p tcp \ -m conntrack --ctorigdstport 80 -j RETURN iptables -A HAN-CHAT-VM2 -i "$external_if" -p tcp \ -m conntrack --ctorigdstport 443 -j RETURN allow_private_8443() { local list=$1 local cidr [[ -n "$list" ]] || return 0 IFS=',' read -ra cidrs <<<"$list" for cidr in "${cidrs[@]}"; do iptables -A HAN-CHAT-VM2 -p tcp -s "$cidr" \ -m conntrack --ctorigdstport 8443 -j RETURN done } allow_private_8443 "$OPS_CIDRS" allow_private_8443 "$VM1_PRIVATE_CIDRS" iptables -A HAN-CHAT-VM2 -p tcp \ -m conntrack --ctorigdstport 8443 -j DROP iptables -A HAN-CHAT-VM2 -i "$external_if" -o docker+ -j DROP iptables -A HAN-CHAT-VM2 -i "$external_if" -o br+ -j DROP iptables -A HAN-CHAT-VM2 -j RETURN while iptables -C DOCKER-USER -j HAN-CHAT-VM2 2>/dev/null; do iptables -D DOCKER-USER -j HAN-CHAT-VM2 done iptables -I DOCKER-USER 1 -j HAN-CHAT-VM2 FIREWALL chmod 0750 /usr/local/sbin/han-chat-vm2-docker-firewall cat >/etc/systemd/system/han-chat-vm2-docker-firewall.service <<'EOF' [Unit] Description=HAN Chat VM2 firewall for Docker published ports After=docker.service network-online.target Wants=docker.service network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/han-chat-vm2-docker-firewall RemainAfterExit=yes [Install] WantedBy=multi-user.target EOF install -d -m 0755 /etc/systemd/system/docker.service.d cat >/etc/systemd/system/docker.service.d/han-chat-vm2-firewall.conf <<'EOF' [Service] ExecStartPost=-/usr/local/sbin/han-chat-vm2-docker-firewall EOF systemctl daemon-reload systemctl enable han-chat-vm2-docker-firewall.service systemctl restart han-chat-vm2-docker-firewall.service } configure_ssh() { step "Настройка SSH" [[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ || die "Нельзя включить key-only SSH без ключа deploy" [[ -s "/home/${ADMIN_USER}/.ssh/authorized_keys" ]] \ || die "Нельзя включить SSH hardening без отдельного ключа admin" cat >/etc/ssh/sshd_config.d/00-han-chat-vm2.conf <>/etc/ssh/sshd_config.d/00-han-chat-vm2.conf </etc/sudoers.d/han-vm2-deploy </dev/null \ || die "Некорректный sudoers для deploy" } install_release_helpers_if_possible() { step "Установка root-owned VM2 helpers и systemd units" local deployment="${DEPLOY_DIR}/deployment" local secret_source="${deployment}/secrets" local safety_sudoers local tls_group=han-nginx-tls local tls_gid=11001 local safety_group=han-message-safety local safety_gid=10001 if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" || ! -f "${secret_source}/secrets_loader.py" || ! -f "${secret_source}/han-secrets" ]]; then log "Активный релиз ещё не установлен; повторите скрипт после root-активации файлов" return fi if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then die "Активный релиз содержит symlink; установка helpers запрещена" fi chown -R root:root "$DEPLOY_DIR" chmod -R go-w "$DEPLOY_DIR" if getent group "$tls_group" >/dev/null; then [[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \ || die "Группа ${tls_group} существует с неожиданным GID" elif getent group "$tls_gid" >/dev/null; then die "GID ${tls_gid} уже занят другой группой" else groupadd --system --gid "$tls_gid" "$tls_group" fi if getent group "$safety_group" >/dev/null; then [[ "$(getent group "$safety_group" | cut -d: -f3)" == "$safety_gid" ]] \ || die "Группа ${safety_group} существует с неожиданным GID" elif getent group "$safety_gid" >/dev/null; then die "GID ${safety_gid} уже занят другой группой" else groupadd --system --gid "$safety_gid" "$safety_group" fi install -d -m 0750 -o root -g "$tls_group" /var/lib/han-chat/public-tls install -d -m 0755 -o root -g root /usr/local/lib/han-secrets-vm2 install -m 0750 -o root -g root \ "${secret_source}/secrets_loader.py" \ /usr/local/lib/han-secrets-vm2/secrets_loader.py install -m 0750 -o root -g root \ "${secret_source}/han-secrets" \ /usr/local/lib/han-secrets-vm2/han-secrets install -m 0750 -o root -g root \ "${secret_source}/han-compose" \ /usr/local/sbin/han-vm2-compose install -m 0755 -o root -g root \ "${deployment}/han-message-safety-mode" \ /usr/local/sbin/han-message-safety-mode install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy install -m 0755 -o root -g root \ "${deployment}/scripts/ssl-renew-deploy-hook.sh" \ /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx install -m 0644 -o root -g root \ "${secret_source}/han-secrets-vm2.service" \ /etc/systemd/system/han-secrets-vm2.service install -m 0644 -o root -g root \ "${deployment}/han-processing.service" \ /etc/systemd/system/han-processing.service safety_sudoers="$(mktemp)" sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers" chmod 0440 "$safety_sudoers" if ! visudo -cf "$safety_sudoers" >/dev/null; then rm -f "$safety_sudoers" die "Некорректный исходный sudoers Message Safety mode" fi install -m 0440 -o root -g root \ "$safety_sudoers" \ /etc/sudoers.d/deploy-message-safety-mode rm -f "$safety_sudoers" visudo -cf /etc/sudoers.d/deploy-message-safety-mode >/dev/null \ || die "Некорректный sudoers Message Safety mode" if [[ ! -e /etc/han/secrets/vm2-production-like.selectel.json.example ]]; then install -m 0600 -o root -g root \ "${secret_source}/config.example.json" \ /etc/han/secrets/vm2-production-like.selectel.json.example fi if [[ ! -e /etc/han-chat/message-safety-mode.env ]]; then cat >/etc/han-chat/message-safety-mode.env <<'EOF' MESSAGE_SAFETY_MOCK_ENABLED=false MESSAGE_SAFETY_MOCK_TEXT_FREE=false MESSAGE_SAFETY_MOCK_FILE_FREE=false EOF fi chown root:"$safety_group" /etc/han-chat/message-safety-mode.env chmod 0640 /etc/han-chat/message-safety-mode.env chmod 0755 "${deployment}/preflight.sh" systemctl daemon-reload log "Helpers и units установлены, но application units не включены и не запущены" } verify() { step "Проверка host baseline" local failed=0 local effective_external_if="${EXTERNAL_IF:-}" if [[ -z "$effective_external_if" ]]; then effective_external_if="$(ip -4 route show default | awk '{print $5; exit}')" fi systemctl is-active --quiet docker \ || { log "FAIL: Docker не активен"; failed=1; } systemctl is-active --quiet fail2ban \ || { log "FAIL: fail2ban не активен"; failed=1; } ufw status | grep -q 'Status: active' \ || { log "FAIL: UFW не активен"; failed=1; } iptables -C DOCKER-USER -j HAN-CHAT-VM2 2>/dev/null \ || { log "FAIL: HAN-CHAT-VM2 не подключена к DOCKER-USER"; failed=1; } iptables -C HAN-CHAT-VM2 -i "$effective_external_if" -p tcp \ -m conntrack --ctorigdstport 80 -j RETURN 2>/dev/null \ || { log "FAIL: DOCKER-USER не разрешает original host port 80"; failed=1; } iptables -C HAN-CHAT-VM2 -i "$effective_external_if" -p tcp \ -m conntrack --ctorigdstport 443 -j RETURN 2>/dev/null \ || { log "FAIL: DOCKER-USER не разрешает original host port 443"; failed=1; } iptables -C HAN-CHAT-VM2 -p tcp \ -m conntrack --ctorigdstport 8443 -j DROP 2>/dev/null \ || { log "FAIL: DOCKER-USER не закрывает original host port 8443"; failed=1; } docker compose version >/dev/null \ || { log "FAIL: Compose plugin недоступен"; failed=1; } if id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -Eq '^(docker|sudo|lxd|adm|systemd-journal)$'; then log "FAIL: deploy состоит в запрещённой привилегированной группе" failed=1 fi id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx sudo \ || { log "FAIL: break-glass admin не состоит в sudo"; failed=1; } if id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -Eq '^(docker|lxd)$'; then log "FAIL: admin не должен иметь прямой Docker/LXD доступ" failed=1 fi [[ "$(stat -c '%U:%G' "$DEPLOY_DIR")" == "root:root" ]] \ || { log "FAIL: DEPLOY_DIR не принадлежит root"; failed=1; } if [[ -f "${DEPLOY_DIR}/docker-compose.yml" ]]; then [[ "$(getent group han-nginx-tls | cut -d: -f3)" == "11001" ]] \ || { log "FAIL: группа han-nginx-tls с GID 11001 отсутствует"; failed=1; } [[ "$(getent group han-message-safety | cut -d: -f3)" == "10001" ]] \ || { log "FAIL: группа han-message-safety с GID 10001 отсутствует"; failed=1; } [[ "$(stat -c '%U:%G:%a' /var/lib/han-chat/public-tls)" == \ "root:han-nginx-tls:750" ]] \ || { log "FAIL: неверные права public TLS staging"; failed=1; } [[ "$(stat -c '%U:%G:%a' /etc/han-chat/message-safety-mode.env)" == \ "root:han-message-safety:640" ]] \ || { log "FAIL: неверные права Message Safety mode file"; failed=1; } fi [[ "$(stat -c '%U:%G' "$INCOMING_DIR")" == "${DEPLOY_USER}:${DEPLOY_USER}" ]] \ || { log "FAIL: INCOMING_DIR не принадлежит deploy"; failed=1; } ((failed == 0)) || die "Проверка VM2 baseline не пройдена" log "VM2 host baseline пройден" } summary() { step "Подготовка VM2 завершена" cat <