Files
han-app/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh
T

592 lines
20 KiB
Bash

#!/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 <<EOF
[DEFAULT]
bantime = 2h
findtime = 10m
maxretry = 5
backend = systemd
banaction = ufw
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
}
configure_unattended_upgrades() {
step "Автоматические обновления безопасности"
cat >/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 <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
EOF
cat >/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 <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 120
ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/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 <<EOF
[Unit]
Description=Renew HAN Chat TLS certificate
After=docker.service
[Service]
Type=oneshot
User=${DEPLOY_USER}
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${renew_script}
EOF
cat >/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 <<EOF | tee -a "$LOG_FILE"
Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${LOG_FILE}
Следующие действия:
1. Проверьте вход в новой SSH-сессии:
ssh ${DEPLOY_USER}@<VM_IP>
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 <command>
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 "$@"