Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений

This commit is contained in:
mi
2026-08-19 18:24:00 +03:00
parent bbef7a30c9
commit c7a80e7256
103 changed files with 3457 additions and 3725 deletions
@@ -25,10 +25,8 @@ fi
docker compose --env-file "$CONFIG_FILE" config --quiet
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms
echo "Migrations completed; record revisions in release evidence."
@@ -0,0 +1,189 @@
#!/bin/sh
# Print frontend build-time env derived from deployment .env.
# Usage:
# ./deployment/scripts/print-frontend-env.sh
# CONFIG_FILE=/opt/han-chat/backend/.env ./deployment/scripts/print-frontend-env.sh --check-oidc
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
CHECK_OIDC=0
FRONTEND_CLIENT_ID=${FRONTEND_CLIENT_ID:-han-chat-frontend}
for arg in "$@"; do
case "$arg" in
--check-oidc) CHECK_OIDC=1 ;;
-h|--help)
cat <<'EOF'
Usage: print-frontend-env.sh [--check-oidc]
Reads deployment .env and prints EXPO_PUBLIC_* build args for frontend-test-site.
Environment:
CONFIG_FILE path to .env (default: .env)
FRONTEND_CLIENT_ID OIDC client id (default: han-chat-frontend)
Options:
--check-oidc curl OpenID discovery using derived auth base URL
EOF
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 64
;;
esac
done
[ -f "$CONFIG_FILE" ] || {
echo "Config file not found: $CONFIG_FILE" >&2
exit 66
}
env_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import sys
from pathlib import Path
path, wanted = sys.argv[1:]
found = False
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value)
found = True
break
if not found:
raise SystemExit(f"missing environment variable: {wanted}")
PY
}
env_value_or_default() {
python3 - "$CONFIG_FILE" "$1" "$2" <<'PY'
import sys
from pathlib import Path
path, wanted, default = sys.argv[1:]
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value or default)
break
else:
print(default)
PY
}
strip_trailing_slash() {
python3 - "$1" <<'PY'
import sys
print(sys.argv[1].rstrip("/"))
PY
}
PUBLIC_HOST=$(env_value PUBLIC_HOST)
PUBLIC_WEB_URL=$(strip_trailing_slash "$(env_value PUBLIC_WEB_URL)")
PUBLIC_AUTH_URL=$(strip_trailing_slash "$(env_value_or_default PUBLIC_AUTH_URL "")")
KEYCLOAK_PUBLIC_URL=$(strip_trailing_slash "$(env_value KEYCLOAK_PUBLIC_URL)")
KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM)
APP_ENV=$(env_value_or_default APP_ENV production-like)
FRONTEND_STATIC_IMAGE=$(env_value_or_default FRONTEND_STATIC_IMAGE "")
if [ -z "$PUBLIC_AUTH_URL" ]; then
PUBLIC_AUTH_URL="$KEYCLOAK_PUBLIC_URL"
fi
EXPO_PUBLIC_API_BASE_URL="$PUBLIC_WEB_URL"
EXPO_PUBLIC_AUTH_BASE_URL="$PUBLIC_AUTH_URL"
EXPO_PUBLIC_KEYCLOAK_REALM="$KEYCLOAK_REALM"
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID="$FRONTEND_CLIENT_ID"
EXPO_PUBLIC_APP_ENV="$APP_ENV"
OIDC_ISSUER="${EXPO_PUBLIC_AUTH_BASE_URL}/realms/${EXPO_PUBLIC_KEYCLOAK_REALM}"
OIDC_DISCOVERY="${OIDC_ISSUER}/.well-known/openid-configuration"
errors=0
warn() {
printf 'WARN: %s\n' "$1" >&2
errors=$((errors + 1))
}
for name in \
EXPO_PUBLIC_API_BASE_URL \
EXPO_PUBLIC_AUTH_BASE_URL \
EXPO_PUBLIC_KEYCLOAK_REALM \
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID; do
eval "value=\$$name"
case "$value" in
""|http://localhost:*|https://chat.example.ru*|https://tohin.ru*)
warn "$name looks unset or still uses example/default value: '$value'"
;;
esac
done
printf '%s\n' "=== deployment .env (source: ${CONFIG_FILE}) ==="
printf '%s=%s\n' PUBLIC_HOST "$PUBLIC_HOST"
printf '%s=%s\n' PUBLIC_WEB_URL "$PUBLIC_WEB_URL"
printf '%s=%s\n' PUBLIC_AUTH_URL "$PUBLIC_AUTH_URL"
printf '%s=%s\n' KEYCLOAK_PUBLIC_URL "$KEYCLOAK_PUBLIC_URL"
printf '%s=%s\n' KEYCLOAK_REALM "$KEYCLOAK_REALM"
printf '%s=%s\n' APP_ENV "$APP_ENV"
printf '%s=%s\n' FRONTEND_STATIC_IMAGE "${FRONTEND_STATIC_IMAGE:-<not set>}"
printf '\n%s\n' "=== frontend build-time env (EXPO_PUBLIC_*) ==="
printf '%s=%s\n' EXPO_PUBLIC_API_BASE_URL "$EXPO_PUBLIC_API_BASE_URL"
printf '%s=%s\n' EXPO_PUBLIC_AUTH_BASE_URL "$EXPO_PUBLIC_AUTH_BASE_URL"
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_REALM "$EXPO_PUBLIC_KEYCLOAK_REALM"
printf '%s=%s\n' EXPO_PUBLIC_KEYCLOAK_CLIENT_ID "$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID"
printf '%s=%s\n' EXPO_PUBLIC_APP_ENV "$EXPO_PUBLIC_APP_ENV"
printf '\n%s\n' "=== derived OIDC ==="
printf '%s=%s\n' OIDC_ISSUER "$OIDC_ISSUER"
printf '%s=%s\n' OIDC_DISCOVERY "$OIDC_DISCOVERY"
printf '%s=%s\n' AUTH_CALLBACK "${PUBLIC_WEB_URL}/auth/callback"
printf '\n%s\n' "=== docker build (frontend-test-site) ==="
cat <<EOF
docker build --target static \\
-t han-chat-frontend-static:local \\
--build-arg EXPO_PUBLIC_API_BASE_URL=${EXPO_PUBLIC_API_BASE_URL} \\
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=${EXPO_PUBLIC_AUTH_BASE_URL} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=${EXPO_PUBLIC_KEYCLOAK_REALM} \\
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=${EXPO_PUBLIC_KEYCLOAK_CLIENT_ID} \\
--build-arg EXPO_PUBLIC_APP_ENV=${EXPO_PUBLIC_APP_ENV} \\
frontend-test-site
EOF
printf '\n%s\n' "=== redeploy static into compose volume ==="
cat <<'EOF'
docker compose --env-file .env up -d --force-recreate frontend-static
docker compose --env-file .env up -d --force-recreate nginx
EOF
if [ "$CHECK_OIDC" -eq 1 ]; then
printf '\n%s\n' "=== OIDC discovery check ==="
curl -fsS "$OIDC_DISCOVERY" | python3 - <<'PY'
import json, sys
doc = json.load(sys.stdin)
print("issuer=", doc.get("issuer"))
print("authorization_endpoint=", doc.get("authorization_endpoint"))
PY
fi
if [ "$errors" -gt 0 ]; then
printf '\nFound %s warning(s). Fix .env or rebuild frontend with the command above.\n' "$errors" >&2
exit 1
fi
printf '\nOK: frontend build env looks consistent with %s\n' "$CONFIG_FILE"
@@ -1,51 +1,33 @@
#!/usr/bin/env bash
# Первичная подготовка Ubuntu 24.04 для HAN Chat.
# 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.
#
# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты,
# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта.
# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает
# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты.
# Required:
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub
#
# Запуск на свежей VM:
# chmod +x deployment/scripts/setup-vm.sh
# sudo deployment/scripts/setup-vm.sh
#
# Основные параметры:
# DEPLOY_USER=deploy
# DEPLOY_DIR=/opt/han-chat/backend
# SSH_PORT=22
# TIMEZONE=Europe/Moscow
# SWAP_SIZE_GB=4
# EXTERNAL_IF=ens3
# PUBLIC_DOCKER_PORTS=80,443
# COPY_SSH_KEYS=true
# HARDEN_SSH=false
# LOCK_ACCOUNT_PASSWORDS=true
# HSTS_MAX_AGE_SECONDS=31536000
# RESET_UFW=false
# SKIP_APT_UPGRADE=false
#
# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
# запрещает прямой root-вход и SSH TCP forwarding.
# 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}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}"
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:-}"
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
HARDEN_SSH="${HARDEN_SSH:-false}"
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}"
RESET_UFW="${RESET_UFW:-false}"
RESET_UFW="${RESET_UFW:-true}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
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"
@@ -73,17 +55,26 @@ require_root() {
}
validate_parameters() {
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
[[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем"
[[ "$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 должен быть целым числом"
[[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \
|| die "HSTS_MAX_AGE_SECONDS должен быть целым числом"
((HSTS_MAX_AGE_SECONDS >= 31536000)) \
|| die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000"
[[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
[[ "$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() {
@@ -107,7 +98,7 @@ update_system() {
apt-get install -y \
ca-certificates \
curl \
dos2unix \
certbot \
fail2ban \
git \
gnupg \
@@ -117,11 +108,10 @@ update_system() {
netcat-openbsd \
openssl \
python3 \
python3-venv \
rsync \
sudo \
unattended-upgrades \
ufw
apt-get autoremove -y
}
configure_time() {
@@ -130,65 +120,79 @@ configure_time() {
timedatectl set-ntp true
}
create_deploy_user() {
step "Пользователь развертывания"
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$DEPLOY_USER"
log "Создан пользователь ${DEPLOY_USER}"
else
log "Пользователь ${DEPLOY_USER} уже существует"
fi
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"
}
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
"/home/${DEPLOY_USER}/.ssh"
local source_user="${SUDO_USER:-}"
local source_keys=""
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
if [[ -n "$source_user" && "$source_user" != "root" ]]; then
source_keys="/home/${source_user}/.ssh/authorized_keys"
elif [[ -s /root/.ssh/authorized_keys ]]; then
source_user="root"
source_keys="/root/.ssh/authorized_keys"
fi
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys"
log "SSH-ключи скопированы от ${source_user}"
fi
if [[ ! -s "$target_keys" ]]; then
log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys"
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 "Блокировка локальных паролей привилегированных учетных записей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
return
step "Пароли host-ролей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" == true ]]; then
passwd --lock root
passwd --lock "$DEPLOY_USER"
fi
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст"
passwd --lock root
passwd --lock "$DEPLOY_USER"
log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен"
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 "Каталоги HAN Chat"
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
"${DEPLOY_DIR}/secrets" \
"${DEPLOY_DIR}/secrets/pg" \
"${DEPLOY_DIR}/backups"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
chmod 600 "$env_file"
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
}
@@ -261,7 +265,6 @@ install_docker() {
EOF
systemctl enable --now docker
systemctl restart docker
usermod -aG docker "$DEPLOY_USER"
docker compose version >/dev/null \
|| die "Docker Compose plugin не установлен"
@@ -276,8 +279,7 @@ configure_ufw() {
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH'
ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit'
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
@@ -317,7 +319,6 @@ configure_docker_firewall() {
step "Фильтрация опубликованных Docker-портов"
cat >/etc/default/han-chat-docker-firewall <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
EOF
cat >/usr/local/sbin/han-chat-docker-firewall <<'FIREWALL'
@@ -341,14 +342,10 @@ 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
IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS"
for port in "${ports[@]}"; do
[[ "$port" =~ ^[0-9]+$ ]] || {
echo "Некорректный порт: $port" >&2
exit 1
}
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN
done
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.
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
@@ -384,7 +381,8 @@ EOF
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
EOF
systemctl daemon-reload
systemctl enable --now han-chat-docker-firewall.service
systemctl enable han-chat-docker-firewall.service
systemctl restart han-chat-docker-firewall.service
}
configure_ssh() {
@@ -396,6 +394,9 @@ configure_ssh() {
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 120
@@ -403,13 +404,13 @@ ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PermitRootLogin no
AllowTcpForwarding no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
log "Root SSH отключён; разрешены deploy и admin"
else
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
log "Root SSH пока не отключён: выполните двухфазную проверку"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
@@ -417,40 +418,45 @@ EOF
systemctl reload ssh
}
configure_application_security() {
step "Безопасные HTTP-заголовки приложения"
local env_file="${DEPLOY_DIR}/.env"
if [[ -f "$env_file" ]]; then
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
else
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file"
fi
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
chmod 600 "$env_file"
log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}"
else
log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию"
fi
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/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@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_secret_loader_if_possible() {
step "Загрузчик секретов"
install_release_helpers_if_possible() {
step "Root-owned helpers, TLS hook и units"
local source_dir="${DEPLOY_DIR}/deployment/secrets"
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
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
chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose"
install -d -m 0700 -o root -g root \
/etc/han \
/etc/han/secrets \
/etc/han/credentials
install -d -m 0755 -o root -g root \
/usr/local/lib/han-secrets \
/usr/local/share/doc/han-secrets
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"
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
@@ -459,56 +465,23 @@ install_secret_loader_if_possible() {
/usr/local/lib/han-secrets/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/han-compose" \
/usr/local/bin/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
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
if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then
install -m 0600 -o root -g root \
"${source_dir}/config.example.json" \
/etc/han/secrets/production.selectel.json.example
fi
systemctl daemon-reload
log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md"
}
install_ssl_timer_if_possible() {
step "Таймер продления TLS"
local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh"
if [[ ! -x "$renew_script" ]]; then
log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске"
return
fi
cat >/etc/systemd/system/han-chat-ssl-renew.service <<EOF
[Unit]
Description=Renew HAN Chat TLS certificate
After=docker.service
[Service]
Type=oneshot
User=${DEPLOY_USER}
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${renew_script}
EOF
cat >/etc/systemd/system/han-chat-ssl-renew.timer <<'EOF'
[Unit]
Description=Run HAN Chat TLS renewal twice daily
[Timer]
OnCalendar=*-*-* 03,15:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now han-chat-ssl-renew.timer
systemctl enable certbot.timer
log "Helpers установлены; application units не запущены"
}
verify() {
@@ -520,7 +493,21 @@ verify() {
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; }
[[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; 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 пройдена"
}
@@ -529,35 +516,17 @@ summary() {
step "Настройка VM завершена"
cat <<EOF | tee -a "$LOG_FILE"
Пользователь развертывания: ${DEPLOY_USER}
Каталог Compose: ${DEPLOY_DIR}
Открытые порты: ${SSH_PORT}, 80, 443
Парольный SSH/X11: отключены
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
Лог настройки: ${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}
Следующие действия:
1. Проверьте вход в новой SSH-сессии:
ssh ${DEPLOY_USER}@<VM_IP>
2. Скопируйте содержимое codebase/backend в:
${DEPLOY_DIR}
3. Поместите CA PostgreSQL:
${DEPLOY_DIR}/secrets/pg/ca.pem
4. Создайте только несекретный config:
cd ${DEPLOY_DIR}
cp .env.example .env
chmod 600 .env
./scripts/validate-env .env
5. Настройте Selectel, encrypted bootstrap credential и fallback map:
deployment/secrets/SELECTEL_RUNBOOK.ru.md
6. Выполняйте Compose только через:
sudo deployment/secrets/han-compose <command>
7. Продолжите с Gate 7 в:
deployment/RUNBOOK.ru.md
8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов.
Важно: членство в группе docker начнет действовать после нового входа в систему.
Продолжайте только по deployment/RUNBOOK.production.ru.md.
Скрипт не запускал Compose и прикладные сервисы.
EOF
}
@@ -570,7 +539,7 @@ main() {
check_os
update_system
configure_time
create_deploy_user
create_host_roles
configure_account_passwords
configure_layout
configure_swap
@@ -581,9 +550,8 @@ main() {
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
configure_application_security
install_secret_loader_if_possible
install_ssl_timer_if_possible
install_deploy_sudoers
install_release_helpers_if_possible
verify
summary
}
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
COMPOSE=/usr/local/sbin/han-vm1-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"
cert_public=$(
openssl x509 -in "$staging/fullchain.pem" -pubkey -noout |
openssl pkey -pubin -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
)
key_public=$(
openssl pkey -in "$staging/privkey.pem" -passin pass: -pubout -outform DER 2>/dev/null |
sha256sum | awk '{print $1}'
)
[ "$cert_public" = "$key_public" ] || {
echo "renewed certificate and private key do not match" >&2
exit 1
}
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 VM1 nginx is not running" >&2
exit 1
}
if ! output=$("$COMPOSE" exec -T nginx nginx -t -c /tmp/nginx.conf 2>&1); then
printf '%s\n' "$output" >&2
exit 1
fi
/usr/bin/docker kill --signal HUP "$container" >/dev/null