493 lines
15 KiB
Bash
493 lines
15 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
|
|
# RESET_UFW=false
|
|
# SKIP_APT_UPGRADE=false
|
|
#
|
|
# HARDEN_SSH=true разрешено использовать только после проверки входа по ключу
|
|
# в отдельной SSH-сессии. По умолчанию парольный вход не отключается.
|
|
|
|
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}"
|
|
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 должен быть целым числом"
|
|
[[ "$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"
|
|
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_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 hardening"
|
|
if [[ "$HARDEN_SSH" != "true" ]]; then
|
|
log "HARDEN_SSH=false: парольный вход не изменен"
|
|
return
|
|
fi
|
|
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|
|
|| die "Нельзя включить HARDEN_SSH: authorized_keys пользователя пуст"
|
|
|
|
cat >/etc/ssh/sshd_config.d/99-han-chat.conf <<EOF
|
|
PermitRootLogin no
|
|
PasswordAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
PubkeyAuthentication yes
|
|
X11Forwarding no
|
|
AllowTcpForwarding no
|
|
MaxAuthTries 3
|
|
ClientAliveInterval 120
|
|
ClientAliveCountMax 2
|
|
Port ${SSH_PORT}
|
|
EOF
|
|
sshd -t || die "Проверка конфигурации sshd не пройдена"
|
|
systemctl reload ssh
|
|
log "SSH hardening включен"
|
|
}
|
|
|
|
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
|
|
Лог настройки: ${LOG_FILE}
|
|
|
|
Следующие действия:
|
|
1. Проверьте вход в новой SSH-сессии:
|
|
ssh ${DEPLOY_USER}@<VM_IP>
|
|
2. Скопируйте содержимое codebase/backend в:
|
|
${DEPLOY_DIR}
|
|
3. Поместите CA PostgreSQL:
|
|
${DEPLOY_DIR}/secrets/pg/ca.pem
|
|
4. Создайте и заполните окружение:
|
|
cd ${DEPLOY_DIR}
|
|
cp .env.example .env
|
|
chmod 600 .env
|
|
./scripts/validate-env .env
|
|
5. Продолжите с Gate 7 в:
|
|
deployment/RUNBOOK.ru.md
|
|
6. После копирования проекта повторно запустите этот скрипт для установки TLS-таймера.
|
|
|
|
Важно: членство в группе 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_layout
|
|
configure_swap
|
|
configure_sysctl
|
|
install_docker
|
|
configure_ufw
|
|
configure_fail2ban
|
|
configure_unattended_upgrades
|
|
configure_docker_firewall
|
|
configure_ssh
|
|
install_ssl_timer_if_possible
|
|
verify
|
|
summary
|
|
}
|
|
|
|
main "$@"
|