Files

777 lines
30 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<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 "Автоматические security updates"
cat >/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 <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
OPS_CIDRS=${OPS_CIDRS}
VM1_PRIVATE_CIDRS=${VM1_PRIVATE_CIDRS}
EOF
cat >/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 <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowAgentForwarding no
AllowTcpForwarding no
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-vm2.conf <<EOF
PermitRootLogin no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Прямой root SSH отключён; разрешены ${DEPLOY_USER} и break-glass ${ADMIN_USER}"
else
log "Root SSH пока не отключён. Проверьте deploy/admin и повторите с HARDEN_SSH=true"
fi
sshd -t || die "Конфигурация sshd не прошла проверку"
systemctl reload ssh
}
install_deploy_sudoers() {
step "Установка минимальных прав deploy"
cat >/etc/sudoers.d/han-vm2-deploy <<EOF
Cmnd_Alias HAN_VM2_UNITS = \\
/usr/bin/systemctl start han-secrets-vm2.service, \\
/usr/bin/systemctl restart han-secrets-vm2.service, \\
/usr/bin/systemctl start han-processing.service, \\
/usr/bin/systemctl restart han-processing.service, \\
/usr/bin/systemctl stop han-processing.service
Cmnd_Alias HAN_VM2_STATUS = \\
/usr/bin/systemctl --no-pager status han-secrets-vm2.service, \\
/usr/bin/systemctl --no-pager status han-processing.service, \\
/usr/bin/journalctl --no-pager -u han-secrets-vm2.service, \\
/usr/bin/journalctl --no-pager -u han-processing.service
${DEPLOY_USER} ALL=(root) NOPASSWD: HAN_VM2_UNITS, HAN_VM2_STATUS
EOF
chmod 0440 /etc/sudoers.d/han-vm2-deploy
visudo -cf /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 /usr/local/libexec
install -m 0750 -o root -g root \
"${deployment}/kesl/kesl_scan_broker.py" \
/usr/local/libexec/han-kesl-scan-broker
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
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.service" \
/etc/systemd/system/han-kesl-scan-broker.service
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.socket" \
/etc/systemd/system/han-kesl-scan-broker.socket
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.tmpfiles.conf" \
/etc/tmpfiles.d/han-kesl-scan-broker.conf
systemd-tmpfiles --create /etc/tmpfiles.d/han-kesl-scan-broker.conf
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 <<EOF | tee -a "$LOG_FILE"
Роль штатного деплоя: ${DEPLOY_USER}
Break-glass роль: ${ADMIN_USER}
Входящий staging: ${INCOMING_DIR}
Активный root release: ${DEPLOY_DIR}
SSH: public TCP/${SSH_PORT}, key-only, fail2ban
Private API 8443 из: ${VM1_PRIVATE_CIDRS}${OPS_CIDRS:+,${OPS_CIDRS}}
Public ingress: 80,443
Root SSH hardening: ${HARDEN_SSH}
Лог: ${LOG_FILE}
Следующие действия:
1. Задайте sudo-пароль break-glass роли: passwd ${ADMIN_USER}
2. Не закрывая root-сессию, проверьте отдельные SSH-ключи deploy и admin.
3. В сессии admin проверьте sudo -v и sudo -i, затем завершите root shell.
4. Передайте релиз в ${INCOMING_DIR} от имени deploy.
5. Активируйте проверенный релиз в ${DEPLOY_DIR} от root:root.
6. Повторите этот скрипт от root для установки helpers и units.
7. Настройте .env, Selectel credential/config и TLS от root.
8. Выполните deployment/preflight.sh от root.
9. После успешных gates запускайте только утверждённые systemd units.
Deploy не входит в docker group и не изменяет production-файлы.
Скрипт не запускал Compose или прикладные сервисы.
EOF
}
main() {
require_root
install -d -m 0755 "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 0600 "$LOG_FILE"
validate_parameters
check_os
update_system
configure_time
create_host_roles
configure_account_passwords
configure_layout
configure_swap
configure_sysctl
install_docker
configure_ufw
configure_fail2ban
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
install_deploy_sudoers
install_release_helpers_if_possible
verify
summary
}
main "$@"