#!/usr/bin/env bash # Первичная подготовка Ubuntu 24.04 для HAN Chat. # # Скрипт настраивает только VM: пользователя развертывания, базовые пакеты, # Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта. # PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает # .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты. # # Запуск на свежей VM: # chmod +x deployment/scripts/setup-vm.sh # sudo deployment/scripts/setup-vm.sh # # Основные параметры: # DEPLOY_USER=deploy # DEPLOY_DIR=/opt/han-chat/backend # SSH_PORT=22 # TIMEZONE=Europe/Moscow # SWAP_SIZE_GB=4 # EXTERNAL_IF=ens3 # PUBLIC_DOCKER_PORTS=80,443 # COPY_SSH_KEYS=true # HARDEN_SSH=false # LOCK_ACCOUNT_PASSWORDS=true # HSTS_MAX_AGE_SECONDS=31536000 # RESET_UFW=false # SKIP_APT_UPGRADE=false # # Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются # по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно # запрещает прямой root-вход и SSH TCP forwarding. set -Eeuo pipefail IFS=$'\n\t' DEPLOY_USER="${DEPLOY_USER:-deploy}" DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}" SSH_PORT="${SSH_PORT:-22}" TIMEZONE="${TIMEZONE:-Europe/Moscow}" SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}" EXTERNAL_IF="${EXTERNAL_IF:-}" PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}" COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}" HARDEN_SSH="${HARDEN_SSH:-false}" LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}" HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}" RESET_UFW="${RESET_UFW:-false}" SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}" LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-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 "Запустите скрипт через sudo" } validate_parameters() { [[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER" [[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем" [[ "$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 должен быть целым числом" [[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \ || die "HSTS_MAX_AGE_SECONDS должен быть целым числом" ((HSTS_MAX_AGE_SECONDS >= 31536000)) \ || die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000" [[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \ || die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443" } check_os() { step "Проверка операционной системы" [[ -r /etc/os-release ]] || die "Не найден /etc/os-release" # shellcheck disable=SC1091 source /etc/os-release [[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu" local major="${VERSION_ID%%.*}" ((major >= 24)) || die "Требуется Ubuntu 24.04 или новее" log "Обнаружена ${PRETTY_NAME}" } update_system() { step "Обновление системы и установка пакетов" export DEBIAN_FRONTEND=noninteractive apt-get update if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then apt-get dist-upgrade -y fi apt-get install -y \ ca-certificates \ curl \ dos2unix \ fail2ban \ git \ gnupg \ iptables \ jq \ logrotate \ netcat-openbsd \ openssl \ python3 \ python3-venv \ rsync \ unattended-upgrades \ ufw apt-get autoremove -y } configure_time() { step "Настройка времени" timedatectl set-timezone "$TIMEZONE" timedatectl set-ntp true } create_deploy_user() { step "Пользователь развертывания" if ! id "$DEPLOY_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$DEPLOY_USER" log "Создан пользователь ${DEPLOY_USER}" else log "Пользователь ${DEPLOY_USER} уже существует" fi install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \ "/home/${DEPLOY_USER}/.ssh" local source_user="${SUDO_USER:-}" local source_keys="" local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys" if [[ -n "$source_user" && "$source_user" != "root" ]]; then source_keys="/home/${source_user}/.ssh/authorized_keys" elif [[ -s /root/.ssh/authorized_keys ]]; then source_user="root" source_keys="/root/.ssh/authorized_keys" fi if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys" log "SSH-ключи скопированы от ${source_user}" fi if [[ ! -s "$target_keys" ]]; then log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys" fi } configure_account_passwords() { step "Блокировка локальных паролей привилегированных учетных записей" if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены" return fi [[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ || die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст" passwd --lock root passwd --lock "$DEPLOY_USER" log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен" } configure_layout() { step "Каталоги HAN Chat" install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR" install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \ "${DEPLOY_DIR}/secrets" \ "${DEPLOY_DIR}/secrets/pg" \ "${DEPLOY_DIR}/backups" local env_file="${DEPLOY_DIR}/.env" if [[ -f "$env_file" ]]; then chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file" chmod 600 "$env_file" fi } configure_swap() { step "Настройка swap" if ((SWAP_SIZE_GB == 0)); then log "Создание swap отключено" return fi if swapon --show=NAME --noheadings | grep -qx '/swapfile'; then log "Swap уже подключен" return fi if [[ ! -f /swapfile ]]; then fallocate -l "${SWAP_SIZE_GB}G" /swapfile chmod 600 /swapfile mkswap /swapfile fi swapon /swapfile 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-swappiness.conf sysctl --system >/dev/null } configure_sysctl() { step "Настройка сетевого стека" cat >/etc/sysctl.d/99-han-chat-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" 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 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi install -d -m 755 /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 usermod -aG docker "$DEPLOY_USER" docker compose version >/dev/null \ || die "Docker Compose plugin не установлен" log "$(docker --version)" log "$(docker compose version)" } configure_ufw() { step "Настройка UFW" if [[ "$RESET_UFW" == "true" ]]; then ufw --force reset fi ufw default deny incoming ufw default allow outgoing ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH' ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit' ufw allow 80/tcp comment 'HAN Chat HTTP' ufw allow 443/tcp comment 'HAN Chat HTTPS' ufw logging medium ufw --force enable } configure_fail2ban() { step "Настройка fail2ban для SSH" cat >/etc/fail2ban/jail.d/han-chat.local </etc/apt/apt.conf.d/51han-chat-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-docker-firewall </usr/local/sbin/han-chat-docker-firewall <<'FIREWALL' #!/usr/bin/env bash set -Eeuo pipefail # shellcheck disable=SC1091 source /etc/default/han-chat-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 "Не удалось определить внешний интерфейс; задайте EXTERNAL_IF" >&2 exit 1 } iptables -N HAN-CHAT-DOCKER 2>/dev/null || true iptables -F HAN-CHAT-DOCKER iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN iptables -A HAN-CHAT-DOCKER -i lo -j RETURN IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS" for port in "${ports[@]}"; do [[ "$port" =~ ^[0-9]+$ ]] || { echo "Некорректный порт: $port" >&2 exit 1 } iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN done # Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge. # Исходящий и межконтейнерный трафик этой цепочкой не затрагивается. iptables -A HAN-CHAT-DOCKER -i "$external_if" -o docker+ -j DROP iptables -A HAN-CHAT-DOCKER -i "$external_if" -o br+ -j DROP iptables -A HAN-CHAT-DOCKER -j RETURN while iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null; do iptables -D DOCKER-USER -j HAN-CHAT-DOCKER done iptables -I DOCKER-USER 1 -j HAN-CHAT-DOCKER FIREWALL chmod 750 /usr/local/sbin/han-chat-docker-firewall cat >/etc/systemd/system/han-chat-docker-firewall.service <<'EOF' [Unit] Description=HAN Chat 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-docker-firewall RemainAfterExit=yes [Install] WantedBy=multi-user.target EOF install -d -m 755 /etc/systemd/system/docker.service.d cat >/etc/systemd/system/docker.service.d/han-chat-firewall.conf <<'EOF' [Service] ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall EOF systemctl daemon-reload systemctl enable --now han-chat-docker-firewall.service } configure_ssh() { step "Настройка SSH" [[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \ || die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys" cat >/etc/ssh/sshd_config.d/00-han-chat.conf <>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF' PermitRootLogin no AllowTcpForwarding no EOF log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены" else log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены" fi rm -f /etc/ssh/sshd_config.d/99-han-chat.conf sshd -t || die "Проверка конфигурации sshd не пройдена" systemctl reload ssh } configure_application_security() { step "Безопасные HTTP-заголовки приложения" local env_file="${DEPLOY_DIR}/.env" if [[ -f "$env_file" ]]; then if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file" else printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file" fi chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file" chmod 600 "$env_file" log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}" else log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию" fi } install_secret_loader_if_possible() { step "Загрузчик секретов" local source_dir="${DEPLOY_DIR}/deployment/secrets" if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске" return fi chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose" install -d -m 0700 -o root -g root \ /etc/han \ /etc/han/secrets \ /etc/han/credentials install -d -m 0755 -o root -g root \ /usr/local/lib/han-secrets \ /usr/local/share/doc/han-secrets install -m 0750 -o root -g root \ "${source_dir}/secrets_loader.py" \ /usr/local/lib/han-secrets/secrets_loader.py install -m 0750 -o root -g root \ "${source_dir}/han-secrets" \ /usr/local/lib/han-secrets/han-secrets install -m 0750 -o root -g root \ "${source_dir}/han-compose" \ /usr/local/bin/han-compose install -m 0644 -o root -g root \ "${source_dir}/han-secrets@.service" \ /etc/systemd/system/han-secrets@.service install -m 0644 -o root -g root \ "${source_dir}/SELECTEL_RUNBOOK.ru.md" \ /usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then install -m 0600 -o root -g root \ "${source_dir}/config.example.json" \ /etc/han/secrets/production.selectel.json.example fi systemctl daemon-reload log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md" } install_ssl_timer_if_possible() { step "Таймер продления TLS" local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh" if [[ ! -x "$renew_script" ]]; then log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске" return fi cat >/etc/systemd/system/han-chat-ssl-renew.service </etc/systemd/system/han-chat-ssl-renew.timer <<'EOF' [Unit] Description=Run HAN Chat TLS renewal twice daily [Timer] OnCalendar=*-*-* 03,15:20:00 RandomizedDelaySec=30m Persistent=true [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now han-chat-ssl-renew.timer } verify() { step "Проверка результата" local failed=0 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-DOCKER 2>/dev/null \ || { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; } docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; } [[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; failed=1; } ((failed == 0)) || die "Базовая проверка VM не пройдена" log "Базовая проверка VM пройдена" } summary() { step "Настройка VM завершена" cat < 2. Скопируйте содержимое codebase/backend в: ${DEPLOY_DIR} 3. Поместите CA PostgreSQL: ${DEPLOY_DIR}/secrets/pg/ca.pem 4. Создайте только несекретный config: cd ${DEPLOY_DIR} cp .env.example .env chmod 600 .env ./scripts/validate-env .env 5. Настройте Selectel, encrypted bootstrap credential и fallback map: deployment/secrets/SELECTEL_RUNBOOK.ru.md 6. Выполняйте Compose только через: sudo deployment/secrets/han-compose 7. Продолжите с Gate 7 в: deployment/RUNBOOK.ru.md 8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов. Важно: членство в группе docker начнет действовать после нового входа в систему. EOF } main() { require_root install -d -m 755 "$(dirname "$LOG_FILE")" touch "$LOG_FILE" chmod 600 "$LOG_FILE" validate_parameters check_os update_system configure_time create_deploy_user configure_account_passwords configure_layout configure_swap configure_sysctl install_docker configure_ufw configure_fail2ban configure_unattended_upgrades configure_docker_firewall configure_ssh configure_application_security install_secret_loader_if_possible install_ssl_timer_if_possible verify summary } main "$@"