Files
han-app/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh
T

601 lines
23 KiB
Bash
Raw 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
# Fresh Ubuntu 24.04 bootstrap for production VM1. This script never starts
# Compose/application containers and never creates DNS, PG, S3, TLS or secrets.
# Run twice: first with HARDEN_SSH=false, then only after independent deploy and
# admin login/sudo checks with HARDEN_SSH=true.
#
# Required:
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub
#
# Optional: SSH_PORT=22 EXTERNAL_IF=ens3 SWAP_SIZE_GB=4 HARDEN_SSH=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/current/backend}"
INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}"
SSH_PORT="${SSH_PORT:-22}"
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}"
OTEL_HOST_COLLECTOR_VERSION="${OTEL_HOST_COLLECTOR_VERSION:-}"
OTEL_HOST_COLLECTOR_SHA256="${OTEL_HOST_COLLECTOR_SHA256:-}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
step() {
log ""
log "==> $*"
}
die() {
log "ОШИБКА: $*"
exit 1
}
on_error() {
local exit_code=$?
log "ОШИБКА: команда завершилась с кодом ${exit_code}, строка ${BASH_LINENO[0]}"
exit "$exit_code"
}
trap on_error ERR
require_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Запустите скрипт через sudo"
}
validate_parameters() {
[[ "$DEPLOY_USER" == deploy ]] || die "Production unit/sudoers требуют DEPLOY_USER=deploy"
[[ "$ADMIN_USER" == admin ]] || die "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 должны использовать разные 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 "Production и incoming должны различаться"
[[ "$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 должен быть целым числом"
[[ "$HARDEN_SSH" == true || "$HARDEN_SSH" == false ]] || die "Некорректный HARDEN_SSH"
[[ "$LOCK_ACCOUNT_PASSWORDS" == true || "$LOCK_ACCOUNT_PASSWORDS" == false ]] \
|| die "Некорректный LOCK_ACCOUNT_PASSWORDS"
[[ "$RESET_UFW" == true || "$RESET_UFW" == false ]] || die "Некорректный RESET_UFW"
[[ "$SKIP_APT_UPGRADE" == true || "$SKIP_APT_UPGRADE" == false ]] \
|| die "Некорректный SKIP_APT_UPGRADE"
}
check_os() {
step "Проверка операционной системы"
[[ -r /etc/os-release ]] || die "Не найден /etc/os-release"
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu"
local major="${VERSION_ID%%.*}"
((major >= 24)) || die "Требуется Ubuntu 24.04 или новее"
log "Обнаружена ${PRETTY_NAME}"
}
update_system() {
step "Обновление системы и установка пакетов"
export DEBIAN_FRONTEND=noninteractive
apt-get update
if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then
apt-get dist-upgrade -y
fi
apt-get install -y \
ca-certificates \
curl \
certbot \
fail2ban \
git \
gnupg \
iptables \
jq \
logrotate \
netcat-openbsd \
openssl \
python3 \
rsync \
sudo \
unattended-upgrades \
ufw
}
configure_time() {
step "Настройка времени"
timedatectl set-timezone "$TIMEZONE"
timedatectl set-ntp true
}
configure_journald() {
step "Persistent journald для host telemetry"
install -d -m 2755 -o root -g systemd-journal /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
journalctl --flush
}
install_authorized_key() {
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
[[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать один ключ"
ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH key ${source}"
grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \
|| die "Разрешены только Ed25519 ключи"
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"
id "$DEPLOY_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$DEPLOY_USER"
id "$ADMIN_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$ADMIN_USER"
install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE"
install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE"
local deploy_key admin_key group
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 используют один ключ"
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 key совпадает с root key"
fi
for group in docker sudo lxd adm systemd-journal; do
if getent group "$group" >/dev/null &&
id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$group"; then
gpasswd -d "$DEPLOY_USER" "$group"
fi
done
usermod -aG sudo "$ADMIN_USER"
for group in docker lxd; do
if getent group "$group" >/dev/null &&
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$group"; then
gpasswd -d "$ADMIN_USER" "$group"
fi
done
}
configure_account_passwords() {
step "Пароли host-ролей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then
passwd --lock root
passwd --lock "$DEPLOY_USER"
fi
local status
status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')"
if [[ "$HARDEN_SSH" == true && "$status" != P ]]; then
die "До HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}"
fi
[[ "$status" == P ]] || log "ПРЕДУПРЕЖДЕНИЕ: задайте sudo-пароль admin"
}
configure_layout() {
step "Root-owned releases и incoming staging"
install -d -m 0755 -o root -g root /opt/han-chat /opt/han-chat/releases
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
install -d -m 0755 -o root -g root /etc/han/ca /var/lib/han-chat/acme
if [[ -f /etc/han/vm1.env ]]; then
[[ "$(stat -c '%U:%G:%a' /etc/han/vm1.env)" == root:root:600 ]] \
|| die "/etc/han/vm1.env должен быть root:root 0600"
fi
if [[ -d "$DEPLOY_DIR" ]]; then
if find "$DEPLOY_DIR" ! -user root -print -quit | grep -q .; then
die "Активный release содержит файлы не root-owned"
fi
if find "$DEPLOY_DIR" -perm /022 -print -quit | grep -q .; then
die "Активный release доступен на запись group/other"
fi
fi
}
configure_swap() {
step "Настройка swap"
if ((SWAP_SIZE_GB == 0)); then
log "Создание swap отключено"
return
fi
if swapon --show=NAME --noheadings | grep -qx '/swapfile'; then
log "Swap уже подключен"
return
fi
if [[ ! -f /swapfile ]]; then
fallocate -l "${SWAP_SIZE_GB}G" /swapfile
chmod 600 /swapfile
mkswap /swapfile
fi
swapon /swapfile
grep -q '^/swapfile ' /etc/fstab \
|| printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
printf 'vm.swappiness = 10\n' >/etc/sysctl.d/99-han-chat-swappiness.conf
sysctl --system >/dev/null
}
configure_sysctl() {
step "Настройка сетевого стека"
cat >/etc/sysctl.d/99-han-chat-hardening.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_fin_timeout = 30
EOF
sysctl --system >/dev/null
}
install_docker() {
step "Установка Docker Engine и Compose"
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# shellcheck disable=SC1091
source /etc/os-release
printf '%s\n' \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
install -d -m 755 /etc/docker
cat >/etc/docker/daemon.json <<'EOF'
{
"live-restore": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
},
"userland-proxy": false
}
EOF
systemctl enable --now docker
systemctl restart docker
docker compose version >/dev/null \
|| die "Docker Compose plugin не установлен"
log "$(docker --version)"
log "$(docker compose version)"
}
configure_ufw() {
step "Настройка UFW"
if [[ "$RESET_UFW" == "true" ]]; then
ufw --force reset
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow "$SSH_PORT"/tcp comment 'HAN VM1 SSH temporary public access'
ufw allow 80/tcp comment 'HAN Chat HTTP'
ufw allow 443/tcp comment 'HAN Chat HTTPS'
ufw logging medium
ufw --force enable
}
configure_fail2ban() {
step "Настройка fail2ban для SSH"
cat >/etc/fail2ban/jail.d/han-chat.local <<EOF
[DEFAULT]
bantime = 2h
findtime = 10m
maxretry = 5
backend = systemd
banaction = ufw
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
}
configure_unattended_upgrades() {
step "Автоматические обновления безопасности"
cat >/etc/apt/apt.conf.d/51han-chat-unattended <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
dpkg-reconfigure -f noninteractive unattended-upgrades
systemctl enable --now unattended-upgrades
}
configure_docker_firewall() {
step "Фильтрация опубликованных Docker-портов"
cat >/etc/default/han-chat-docker-firewall <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
EOF
cat >/usr/local/sbin/han-chat-docker-firewall <<'FIREWALL'
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck disable=SC1091
source /etc/default/han-chat-docker-firewall
external_if="${EXTERNAL_IF:-}"
if [[ -z "$external_if" ]]; then
external_if="$(ip -4 route show default | awk '{print $5; exit}')"
fi
[[ -n "$external_if" ]] || {
echo "Не удалось определить внешний интерфейс; задайте EXTERNAL_IF" >&2
exit 1
}
iptables -N HAN-CHAT-DOCKER 2>/dev/null || true
iptables -F HAN-CHAT-DOCKER
iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A HAN-CHAT-DOCKER -i lo -j RETURN
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 80 -j RETURN
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 443 -j RETURN
# Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge.
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o docker+ -j DROP
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o br+ -j DROP
iptables -A HAN-CHAT-DOCKER -j RETURN
while iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null; do
iptables -D DOCKER-USER -j HAN-CHAT-DOCKER
done
iptables -I DOCKER-USER 1 -j HAN-CHAT-DOCKER
FIREWALL
chmod 750 /usr/local/sbin/han-chat-docker-firewall
cat >/etc/systemd/system/han-chat-docker-firewall.service <<'EOF'
[Unit]
Description=HAN Chat firewall for Docker published ports
After=docker.service network-online.target
Wants=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/han-chat-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
install -d -m 755 /etc/systemd/system/docker.service.d
cat >/etc/systemd/system/docker.service.d/han-chat-firewall.conf <<'EOF'
[Service]
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
EOF
systemctl daemon-reload
systemctl enable han-chat-docker-firewall.service
systemctl restart han-chat-docker-firewall.service
}
configure_ssh() {
step "Настройка SSH"
[[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys"
cat >/etc/ssh/sshd_config.d/00-han-chat.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.conf <<EOF
PermitRootLogin no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Root SSH отключён; разрешены deploy и admin"
else
log "Root SSH пока не отключён: выполните двухфазную проверку"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
sshd -t || die "Проверка конфигурации sshd не пройдена"
systemctl reload ssh
}
install_deploy_sudoers() {
step "Exact sudoers для deploy"
cat >/etc/sudoers.d/deploy <<'EOF'
Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service
Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/systemctl --no-pager status han-host-otel-collector@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service, /usr/bin/journalctl --no-pager -u han-host-otel-collector@production.service
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
EOF
chmod 0440 /etc/sudoers.d/deploy
visudo -cf /etc/sudoers.d/deploy >/dev/null || die "Некорректный sudoers"
}
install_release_helpers_if_possible() {
step "Root-owned helpers, TLS hook и units"
local source_dir="${DEPLOY_DIR}/deployment/secrets"
local deployment="${DEPLOY_DIR}/deployment"
local tls_group=han-nginx-tls tls_gid=11001
if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" ||
! -f "${source_dir}/secrets_loader.py" ||
! -f "${deployment}/han-stack@.service" ]]; then
log "Активный релиз отсутствует; повторите после root-активации"
return
fi
if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then
die "Внутри активного release обнаружен symlink"
fi
[[ -x "${deployment}/preflight.sh" ]] || die "preflight.sh не executable"
[[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable"
[[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable"
[[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable"
[[ -f "${deployment}/han-host-otel-collector@.service" ]] \
|| die "han-host-otel-collector@.service отсутствует"
[[ -f "${deployment}/observability/otel-host-collector.yaml" ]] \
|| die "otel-host-collector.yaml отсутствует"
[[ "$OTEL_HOST_COLLECTOR_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| die "Задайте OTEL_HOST_COLLECTOR_VERSION=x.y.z"
[[ "$OTEL_HOST_COLLECTOR_SHA256" =~ ^[a-f0-9]{64}$ ]] \
|| die "Задайте проверенный OTEL_HOST_COLLECTOR_SHA256"
[[ "$(uname -m)" == x86_64 ]] || die "Host Collector artifact рассчитан на amd64"
if getent group "$tls_group" >/dev/null; then
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|| die "han-nginx-tls имеет неожиданный GID"
elif getent group "$tls_gid" >/dev/null; then
die "GID ${tls_gid} уже занят"
else
groupadd --system --gid "$tls_gid" "$tls_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 /usr/local/share/doc/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/secrets_loader.py" \
/usr/local/lib/han-secrets/secrets_loader.py
install -m 0750 -o root -g root \
"${source_dir}/han-secrets" \
/usr/local/lib/han-secrets/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/han-compose" \
/usr/local/sbin/han-vm1-compose
install -m 0644 -o root -g root \
"${source_dir}/han-secrets@.service" \
/etc/systemd/system/han-secrets@.service
install -m 0644 -o root -g root \
"${deployment}/han-stack@.service" \
/etc/systemd/system/han-stack@.service
local otel_archive otel_tmp otel_url
otel_archive="$(mktemp)"
otel_tmp="$(mktemp -d)"
otel_url="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_HOST_COLLECTOR_VERSION}/otelcol-contrib_${OTEL_HOST_COLLECTOR_VERSION}_linux_amd64.tar.gz"
curl --fail --location --proto '=https' --tlsv1.2 \
--output "$otel_archive" "$otel_url"
printf '%s %s\n' "$OTEL_HOST_COLLECTOR_SHA256" "$otel_archive" | sha256sum -c -
tar -xzf "$otel_archive" -C "$otel_tmp" otelcol-contrib
install -m 0755 -o root -g root "$otel_tmp/otelcol-contrib" /usr/local/bin/otelcol-contrib
install -d -m 0755 -o root -g root /usr/local/share/han-otel
sha256sum /usr/local/bin/otelcol-contrib \
>/usr/local/share/han-otel/otelcol-contrib.sha256
printf '%s\n' "$OTEL_HOST_COLLECTOR_VERSION" \
>/usr/local/share/han-otel/otelcol-contrib.version
chmod 0644 /usr/local/share/han-otel/otelcol-contrib.sha256 \
/usr/local/share/han-otel/otelcol-contrib.version
rm -rf "$otel_archive" "$otel_tmp"
install -d -m 0700 -o root -g root /var/lib/han-otel/host-collector
install -m 0644 -o root -g root \
"${deployment}/han-host-otel-collector@.service" \
/etc/systemd/system/han-host-otel-collector@.service
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
install -m 0755 -o root -g root \
"${deployment}/scripts/tls-deploy-hook.sh" \
/etc/letsencrypt/renewal-hooks/deploy/han-chat-nginx
install -m 0644 -o root -g root \
"${source_dir}/SELECTEL_RUNBOOK.ru.md" \
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
systemctl daemon-reload
systemctl enable certbot.timer
systemctl enable han-host-otel-collector@production.service
log "Helpers установлены; application units не запущены"
}
verify() {
step "Проверка результата"
local failed=0
systemctl is-active --quiet docker || { log "FAIL: Docker не активен"; failed=1; }
systemctl is-active --quiet fail2ban || { log "FAIL: fail2ban не активен"; failed=1; }
ufw status | grep -q 'Status: active' || { log "FAIL: UFW не активен"; failed=1; }
iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null \
|| { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; }
docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; }
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: admin не состоит в sudo"; failed=1; }
[[ "$(stat -c '%U:%G:%a' "$INCOMING_DIR")" == deploy:deploy:750 ]] \
|| { log "FAIL: неверные права incoming"; failed=1; }
local external_if="${EXTERNAL_IF:-}"
[[ -n "$external_if" ]] || external_if="$(ip -4 route show default | awk '{print $5; exit}')"
for port in 80 443; do
iptables -C HAN-CHAT-DOCKER -i "$external_if" -p tcp \
-m conntrack --ctorigdstport "$port" -j RETURN 2>/dev/null \
|| { log "FAIL: нет original host port ${port}"; failed=1; }
done
((failed == 0)) || die "Базовая проверка VM не пройдена"
log "Базовая проверка VM пройдена"
}
summary() {
step "Настройка VM завершена"
cat <<EOF | tee -a "$LOG_FILE"
Deploy: ${DEPLOY_USER} (без docker/sudo/lxd/adm/journal)
Break-glass: ${ADMIN_USER} (sudo с отдельным паролем)
Incoming: ${INCOMING_DIR}
Активный release: ${DEPLOY_DIR}
Public ingress: 80,443
SSH ingress: ${SSH_PORT}/tcp из любой сети (временно)
Root SSH hardening: ${HARDEN_SSH}
Лог: ${LOG_FILE}
Продолжайте только по deployment/RUNBOOK.production.ru.md.
Скрипт не запускал Compose и прикладные сервисы.
EOF
}
main() {
require_root
install -d -m 755 "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
validate_parameters
check_os
update_system
configure_time
configure_journald
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 "$@"