#!/usr/bin/env bash # Fresh Ubuntu 24.04 bootstrap for production VM1. This script never starts # Compose/application containers and never creates DNS, PG, S3, TLS or secrets. # Run twice: first with HARDEN_SSH=false, then only after independent deploy and # admin login/sudo checks with HARDEN_SSH=true. # # Required: # DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub # ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub # # Optional: SSH_PORT=22 EXTERNAL_IF=ens3 SWAP_SIZE_GB=4 HARDEN_SSH=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/current/backend}" INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}" SSH_PORT="${SSH_PORT:-22}" 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-vm1-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" == deploy ]] || die "Production unit/sudoers требуют DEPLOY_USER=deploy" [[ "$ADMIN_USER" == admin ]] || die "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 должны использовать разные 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 "Production и incoming должны различаться" [[ "$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 должен быть целым числом" [[ "$HARDEN_SSH" == true || "$HARDEN_SSH" == false ]] || die "Некорректный HARDEN_SSH" [[ "$LOCK_ACCOUNT_PASSWORDS" == true || "$LOCK_ACCOUNT_PASSWORDS" == false ]] \ || die "Некорректный LOCK_ACCOUNT_PASSWORDS" [[ "$RESET_UFW" == true || "$RESET_UFW" == false ]] || die "Некорректный RESET_UFW" [[ "$SKIP_APT_UPGRADE" == true || "$SKIP_APT_UPGRADE" == false ]] \ || die "Некорректный SKIP_APT_UPGRADE" } 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 \ certbot \ fail2ban \ git \ gnupg \ iptables \ jq \ logrotate \ netcat-openbsd \ openssl \ python3 \ rsync \ sudo \ unattended-upgrades \ ufw } configure_time() { step "Настройка времени" timedatectl set-timezone "$TIMEZONE" timedatectl set-ntp true } install_authorized_key() { local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys" [[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}" [[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать один ключ" ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH key ${source}" grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \ || die "Разрешены только Ed25519 ключи" 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" id "$DEPLOY_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$DEPLOY_USER" id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER" install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE" install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE" local deploy_key admin_key group 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 используют один ключ" 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 key совпадает с root key" fi for group in docker sudo lxd adm systemd-journal; do if getent group "$group" >/dev/null && id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$group"; then gpasswd -d "$DEPLOY_USER" "$group" fi done usermod -aG sudo "$ADMIN_USER" for group in docker lxd; do if getent group "$group" >/dev/null && id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$group"; then gpasswd -d "$ADMIN_USER" "$group" fi done } configure_account_passwords() { step "Пароли host-ролей" if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then passwd --lock root passwd --lock "$DEPLOY_USER" fi local status status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')" if [[ "$HARDEN_SSH" == true && "$status" != P ]]; then die "До HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}" fi [[ "$status" == P ]] || log "ПРЕДУПРЕЖДЕНИЕ: задайте sudo-пароль admin" } configure_layout() { step "Root-owned releases и incoming staging" install -d -m 0755 -o root -g root /opt/han-chat /opt/han-chat/releases 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 install -d -m 0755 -o root -g root /etc/han/ca /var/lib/han-chat/acme if [[ -f /etc/han/vm1.env ]]; then [[ "$(stat -c '%U:%G:%a' /etc/han/vm1.env)" == root:root:600 ]] \ || die "/etc/han/vm1.env должен быть root:root 0600" fi if [[ -d "$DEPLOY_DIR" ]]; then if find "$DEPLOY_DIR" ! -user root -print -quit | grep -q .; then die "Активный release содержит файлы не root-owned" fi if find "$DEPLOY_DIR" -perm /022 -print -quit | grep -q .; then die "Активный release доступен на запись group/other" fi 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 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 VM1 SSH temporary public access' 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 iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \ -m conntrack --ctorigdstport 80 -j RETURN iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \ -m conntrack --ctorigdstport 443 -j RETURN # Блокируется только новый входящий трафик с внешнего интерфейса в 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 han-chat-docker-firewall.service systemctl restart 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 </etc/sudoers.d/deploy <<'EOF' Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS EOF chmod 0440 /etc/sudoers.d/deploy visudo -cf /etc/sudoers.d/deploy >/dev/null || die "Некорректный sudoers" } install_release_helpers_if_possible() { step "Root-owned helpers, TLS hook и units" local source_dir="${DEPLOY_DIR}/deployment/secrets" local deployment="${DEPLOY_DIR}/deployment" local tls_group=han-nginx-tls tls_gid=11001 if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" || ! -f "${source_dir}/secrets_loader.py" || ! -f "${deployment}/han-stack@.service" ]]; then log "Активный релиз отсутствует; повторите после root-активации" return fi if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then die "Внутри активного release обнаружен symlink" fi [[ -x "${deployment}/preflight.sh" ]] || die "preflight.sh не executable" [[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable" [[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable" [[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable" if getent group "$tls_group" >/dev/null; then [[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \ || die "han-nginx-tls имеет неожиданный GID" elif getent group "$tls_gid" >/dev/null; then die "GID ${tls_gid} уже занят" else groupadd --system --gid "$tls_gid" "$tls_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 /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/sbin/han-vm1-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 \ "${deployment}/han-stack@.service" \ /etc/systemd/system/han-stack@.service install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy install -m 0755 -o root -g root \ "${deployment}/scripts/tls-deploy-hook.sh" \ /etc/letsencrypt/renewal-hooks/deploy/han-chat-nginx install -m 0644 -o root -g root \ "${source_dir}/SELECTEL_RUNBOOK.ru.md" \ /usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md systemctl daemon-reload systemctl enable certbot.timer log "Helpers установлены; application units не запущены" } 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; } 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: admin не состоит в sudo"; failed=1; } [[ "$(stat -c '%U:%G:%a' "$INCOMING_DIR")" == deploy:deploy:750 ]] \ || { log "FAIL: неверные права incoming"; failed=1; } local external_if="${EXTERNAL_IF:-}" [[ -n "$external_if" ]] || external_if="$(ip -4 route show default | awk '{print $5; exit}')" for port in 80 443; do iptables -C HAN-CHAT-DOCKER -i "$external_if" -p tcp \ -m conntrack --ctorigdstport "$port" -j RETURN 2>/dev/null \ || { log "FAIL: нет original host port ${port}"; failed=1; } done ((failed == 0)) || die "Базовая проверка VM не пройдена" log "Базовая проверка VM пройдена" } summary() { step "Настройка VM завершена" cat <