Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -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 "$@"
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
COMPOSE=/usr/local/sbin/han-vm2-compose
|
||||
TLS_DIR=/var/lib/han-chat/public-tls
|
||||
TLS_GROUP=han-nginx-tls
|
||||
|
||||
lineage=${RENEWED_LINEAGE:?Certbot did not provide RENEWED_LINEAGE}
|
||||
test -s "$lineage/fullchain.pem"
|
||||
test -s "$lineage/privkey.pem"
|
||||
test -d "$TLS_DIR"
|
||||
getent group "$TLS_GROUP" >/dev/null
|
||||
|
||||
staging=$(mktemp -d "${TLS_DIR}/.renew.XXXXXX")
|
||||
trap 'rm -rf -- "$staging"' EXIT HUP INT TERM
|
||||
install -m 0640 -o root -g "$TLS_GROUP" \
|
||||
"$lineage/fullchain.pem" "$staging/fullchain.pem"
|
||||
install -m 0640 -o root -g "$TLS_GROUP" \
|
||||
"$lineage/privkey.pem" "$staging/privkey.pem"
|
||||
mv -f "$staging/fullchain.pem" "$TLS_DIR/fullchain.pem"
|
||||
mv -f "$staging/privkey.pem" "$TLS_DIR/privkey.pem"
|
||||
rmdir "$staging"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
container=$("$COMPOSE" ps --status running --quiet nginx)
|
||||
[ -n "$container" ] || {
|
||||
echo "HAN VM2 nginx is not running" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! nginx_test_output=$(
|
||||
"$COMPOSE" exec -T nginx nginx -t -c /etc/nginx/nginx.conf 2>&1
|
||||
); then
|
||||
printf '%s\n' "$nginx_test_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/bin/docker kill --signal HUP "$container" >/dev/null
|
||||
Reference in New Issue
Block a user