Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,762 @@
#!/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 /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
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 "$@"