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