763 lines
29 KiB
Bash
763 lines
29 KiB
Bash
#!/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 "$@"
|