Files
glchat/deploy/install.sh
T

1098 lines
48 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# glchat installer — доводит чистый Ubuntu 24.04/26.04 LTS до работающего инстанса.
# Спецификация: AGENT.md §10. Общие функции: deploy/lib/common.sh.
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]:-}"
if [ -n "$SCRIPT_PATH" ] && [ -f "$SCRIPT_PATH" ]; then
DEPLOY_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)"
else
DEPLOY_DIR=""
REPO_DIR=""
fi
# Запуск через `curl | bash`: репозиторий распаковывается во временный каталог
# и установщик перезапускается уже из него.
if [ -z "$DEPLOY_DIR" ] || [ ! -f "${DEPLOY_DIR}/lib/common.sh" ]; then
if [ "${GLCHAT_BOOTSTRAPPED:-0}" = "1" ]; then
echo "install.sh: не удалось загрузить репозиторий glchat" >&2
exit 1
fi
REPO_URL="${GLCHAT_REPO_URL:-https://gt.mhspx.su/grendervill/glchat.git}"
TMP_REPO="$(mktemp -d /tmp/glchat-src.XXXXXX)"
echo "==> загружаем glchat из ${REPO_URL}"
if command -v git >/dev/null 2>&1; then
git clone --depth 1 "$REPO_URL" "$TMP_REPO" >/dev/null 2>&1 || {
echo "install.sh: не удалось клонировать ${REPO_URL}" >&2
exit 1
}
else
curl -fsSL "${REPO_URL%.git}/archive/main.tar.gz" | tar -xz -C "$TMP_REPO" --strip-components=1 || {
echo "install.sh: не удалось скачать архив репозитория" >&2
exit 1
}
fi
export GLCHAT_BOOTSTRAPPED=1
exec bash "${TMP_REPO}/deploy/install.sh" "$@"
fi
# shellcheck source=lib/common.sh
. "${DEPLOY_DIR}/lib/common.sh"
# --- значения по умолчанию ------------------------------------------------
DOMAIN="${DOMAIN:-}"
ACME_EMAIL="${ACME_EMAIL:-}"
SSH_PORT="${SSH_PORT:-}"
HTTP_PORT="${HTTP_PORT:-80}"
HTTPS_PORT="${HTTPS_PORT:-443}"
APP_PORT="${APP_PORT:-8080}"
LIVEKIT_WS_PORT="${LIVEKIT_WS_PORT:-7880}"
LIVEKIT_TCP_PORT="${LIVEKIT_TCP_PORT:-7881}"
LIVEKIT_UDP_PORT="${LIVEKIT_UDP_PORT:-7882}"
TURN_UDP_PORT="${TURN_UDP_PORT:-3478}"
TURN_TLS_PORT="${TURN_TLS_PORT:-5349}"
PROXY_MODE="${PROXY_MODE:-embedded}"
PROFILE="${PROFILE:-auto}"
INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
TURN_ENABLED="${TURN_ENABLED:-true}"
SSH_HARDENING="${SSH_HARDENING:-true}"
FILES_SUBDOMAIN="${FILES_SUBDOMAIN:-true}"
ENABLE_FAIL2BAN="${ENABLE_FAIL2BAN:-true}"
ENABLE_UFW="${ENABLE_UFW:-true}"
ENABLE_NFTABLES="${ENABLE_NFTABLES:-true}"
ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}"
APP_IMAGE="${APP_IMAGE:-glchat:local}"
IMAGE_TAG="${IMAGE_TAG:-v0.1.0}"
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7}"
MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-}"
LOG_LEVEL="${LOG_LEVEL:-info}"
SKIP_TLS="${SKIP_TLS:-0}"
SKIP_IMAGE="${SKIP_IMAGE:-0}"
DRY_RUN="${DRY_RUN:-0}"
RECONFIGURE="${RECONFIGURE:-0}"
GLCHAT_FORCE_OS="${GLCHAT_FORCE_OS:-0}"
GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
if [ "$DRY_RUN" = "1" ] && [ -z "${GLCHAT_ETC_DIR:-}" ]; then
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
fi
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
usage() {
cat <<'EOF'
install.sh — установка glchat (AGENT.md 10).
Основное:
--domain <domain> домен инстанса (обязателен)
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
--admin <email> логин инстанс-администратора
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
--instance-name <name> название инстанса (по умолчанию glchat)
Сеть:
--ssh-port <port> SSH-порт (по умолчанию берётся из sshd_config)
--http-port <port> HTTP (по умолчанию 80)
--https-port <port> HTTPS (по умолчанию 443)
--app-port <port> внутренний порт приложения (127.0.0.1, по умолчанию 8080)
--livekit-tcp <port> LiveKit TCP (по умолчанию 7881)
--livekit-udp <port> LiveKit UDP (по умолчанию 7882)
--turn-udp <port> TURN UDP (по умолчанию 3478)
--turn-tls <port> TURN TLS (по умолчанию 5349)
--external-proxy не занимать 80/443: отдать маршруты внешнему прокси (AGENT.md 10.13)
--no-files-subdomain не использовать files.<domain> (fallback /files/*)
--no-turn отключить встроенный TURN (UDP 7882 + TCP 7881 остаются)
Ресурсы и безопасность:
--profile <tier> auto|lite|standard|plus|pro|custom|none (по умолчанию auto)
--max-upload <mb> переопределить лимит загрузки
--no-fail2ban не устанавливать/не настраивать fail2ban
--no-ufw не менять правила UFW/DOCKER-USER
--no-nftables не применять nftables-лимиты
--no-ssh-hardening не трогать sshd_config
--anti-bot включить PoW-анти-бот (фаза 5)
Режимы запуска:
--dry-run показать план, ничего не менять в системе
--reconfigure перегенерировать конфиги, сохранив существующие секреты
--yes не задавать вопросов (для автоматизации)
--force-os продолжить на непроверенной версии ОС
--skip-tls не выпускать сертификат (для тестов в контейнере без домена)
--skip-image не собирать/не запускать контейнеры (тест установщика в DinD)
--image <ref> использовать готовый образ вместо сборки из репозитория
-h, --help эта справка
EOF
}
# --- разбор аргументов ----------------------------------------------------
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--domain) DOMAIN="${2:?}"; shift 2 ;;
--email) ACME_EMAIL="${2:?}"; shift 2 ;;
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--https-port) HTTPS_PORT="${2:?}"; shift 2 ;;
--app-port) APP_PORT="${2:?}"; shift 2 ;;
--livekit-tcp) LIVEKIT_TCP_PORT="${2:?}"; shift 2 ;;
--livekit-udp) LIVEKIT_UDP_PORT="${2:?}"; shift 2 ;;
--turn-udp) TURN_UDP_PORT="${2:?}"; shift 2 ;;
--turn-tls) TURN_TLS_PORT="${2:?}"; shift 2 ;;
--profile) PROFILE="${2:?}"; shift 2 ;;
--max-upload) MAX_UPLOAD_MB="${2:?}"; shift 2 ;;
--image) APP_IMAGE="${2:?}"; shift 2 ;;
--tag) IMAGE_TAG="${2:?}"; shift 2 ;;
--external-proxy) PROXY_MODE="external"; shift ;;
--no-files-subdomain) FILES_SUBDOMAIN="false"; shift ;;
--no-turn) TURN_ENABLED="false"; shift ;;
--no-fail2ban) ENABLE_FAIL2BAN="false"; shift ;;
--no-ufw) ENABLE_UFW="false"; shift ;;
--no-nftables) ENABLE_NFTABLES="false"; shift ;;
--no-ssh-hardening) ENABLE_SSH_HARDENING="false"; SSH_HARDENING="false"; shift ;;
--anti-bot) ANTI_BOT_ENABLED="true"; shift ;;
--registration-off) REGISTRATION_ENABLED="false"; shift ;;
--dry-run) DRY_RUN="1"; shift ;;
--reconfigure) RECONFIGURE="1"; shift ;;
--yes | -y) GLCHAT_ASSUME_YES="1"; shift ;;
--force-os) GLCHAT_FORCE_OS="1"; shift ;;
--skip-tls) SKIP_TLS="1"; shift ;;
--skip-image) SKIP_IMAGE="1"; shift ;;
-h | --help) usage; exit 0 ;;
*) die "неизвестный аргумент: $1 (см. --help)" ;;
esac
done
}
interactive_defaults() {
if [ -z "$DOMAIN" ]; then
DOMAIN="$(prompt_value 'Домен инстанса (например, chat.example.com)')"
fi
[ -n "$DOMAIN" ] || die "домен обязателен: --domain <domain>"
if [ -z "$ACME_EMAIL" ] && [ "$SKIP_TLS" != "1" ]; then
ACME_EMAIL="$(prompt_value 'Email для Let'"'"'s Encrypt' "admin@${DOMAIN}")"
fi
[ -n "$ACME_EMAIL" ] || ACME_EMAIL="admin@${DOMAIN}"
if [ -z "$SSH_PORT" ]; then
SSH_PORT="$(detect_ssh_port)"
fi
}
detect_ssh_port() {
local port="22" conf="/etc/ssh/sshd_config"
if [ ! -r "$conf" ] && [ ! -d /etc/ssh ]; then
printf '%s' "$port"
return 0
fi
if [ -r "$conf" ]; then
local found
found="$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/ {print $2; exit}' "$conf")"
[ -n "$found" ] && port="$found"
fi
if have ss; then
local listen
listen="$(ss -tlnp 2>/dev/null | awk '/sshd/ {print $4}' | head -1 | sed 's/.*://')"
if [ -n "$listen" ] && [ "$listen" != "$port" ]; then
warn "sshd слушает порт ${listen}, в конфиге указан ${port}; используем ${listen}"
port="$listen"
fi
fi
printf '%s' "$port"
}
# --- подготовка -----------------------------------------------------------
# resolve_derived_values вычисляет значения, которые нужны и в .env, и в шаблонах.
# Раньше FILES_DOMAIN_RESOLVED считался внутри render_all_templates, из-за чего
# write_env_file обращался к ещё не заданной переменной (set -u → падение).
resolve_derived_values() {
if [ "$FILES_SUBDOMAIN" = "true" ]; then
FILES_DOMAIN_RESOLVED="files.${DOMAIN}"
else
FILES_DOMAIN_RESOLVED="${DOMAIN}"
fi
# В режиме внешнего прокси собственный Caddy не нужен вовсе: сервис выносится
# в несуществующий профиль, чтобы `docker compose up` его не поднимал.
CADDY_PROFILE="embedded-proxy"
if [ "$PROXY_MODE" = "external" ] || [ "$SKIP_TLS" = "1" ]; then
CADDY_PROFILE="disabled"
fi
# В тестовом режиме (--skip-tls) порты LiveKit не публикуются.
LIVEKIT_PUBLISH="true"
[ "$SKIP_TLS" = "1" ] && LIVEKIT_PUBLISH="false"
turn_external_tls="false"
[ "$PROXY_MODE" = "external" ] && turn_external_tls="true"
TURN_EXTERNAL_TLS="$turn_external_tls"
disable_simulcast="true"
if [ "${PROFILE_LIVEKIT_SIMULCAST:-0}" -gt 0 ] 2>/dev/null; then
disable_simulcast="false"
fi
PROFILE_LIVEKIT_DISABLE_SIMULCAST="$disable_simulcast"
use_external_ip="true"
PROFILE_LIVEKIT_USE_EXTERNAL_IP="$use_external_ip"
# Набор кодеков зависит от профиля: на lite/standard оставляем VP8
# (минимум CPU у отправителя), на plus/pro добавляем VP9/H.264/AV1.
livekit_extra_codecs=""
if [ "${PROFILE_LIVEKIT_SIMULCAST:-0}" -gt 0 ] 2>/dev/null; then
livekit_extra_codecs=' - mime: video/vp9
- mime: video/h264
- mime: video/av1'
fi
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
livekit_extra_codecs \
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
GLCHAT_DATA_DIR GLCHAT_CADDY_PROFILE
}
validate_inputs() {
case "$PROFILE" in
auto | lite | standard | plus | pro | custom | none) ;;
*) die "неизвестный профиль: $PROFILE" ;;
esac
case "$PROXY_MODE" in
embedded | external) ;;
*) die "неизвестный режим прокси: $PROXY_MODE" ;;
esac
if [ "$PROXY_MODE" = "external" ] && [ "$SKIP_TLS" = "0" ]; then
warn "режим --external-proxy: TLS и сертификаты обслуживает внешний прокси"
fi
if [ -n "$ADMIN_PASSWORD" ] && [ "${#ADMIN_PASSWORD}" -lt 8 ]; then
die "пароль администратора короче 8 символов"
fi
}
check_ports_free() {
local ports=()
if [ "$PROXY_MODE" = "embedded" ]; then
ports+=("$HTTP_PORT" "$HTTPS_PORT")
else
ports+=("$APP_PORT" "$LIVEKIT_WS_PORT")
fi
have ss || return 0
local busy=0
for p in "${ports[@]}"; do
if ss -tlnH "sport = :${p}" 2>/dev/null | grep -q .; then
if [ "$PROXY_MODE" = "embedded" ]; then
warn "порт ${p} занят: для встроенного прокси это блокирует установку (используйте --external-proxy)"
busy=1
else
warn "порт ${p} занят на 127.0.0.1 — проверьте, не запущен ли уже glchat"
fi
fi
done
[ "$busy" = "0" ] || die "освободите порты или используйте --external-proxy"
}
check_dns() {
local domain="$1"
have getent || return 0
local resolved
resolved="$(getent hosts "$domain" | awk '{print $1}' | head -1 || true)"
if [ -z "$resolved" ]; then
warn "A-запись для ${domain} не найдена: Let's Encrypt не сможет выпустить сертификат"
return 0
fi
local public_ip
public_ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
if [ -n "$public_ip" ] && [ "$resolved" != "$public_ip" ]; then
warn "${domain} указывает на ${resolved}, внешний IP этого сервера ${public_ip}"
else
ok "DNS: ${domain} → ${resolved}"
fi
}
print_plan() {
local tier_detected="$1"
printf '\n%s=== План установки ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET"
printf ' домен: %s\n' "$DOMAIN"
printf ' поддомен файлов: %s\n' "$( [ "$FILES_SUBDOMAIN" = "true" ] && echo "files.${DOMAIN}" || echo "нет (fallback /files/*)")"
printf ' режим прокси: %s\n' "$( [ "$PROXY_MODE" = "external" ] && echo 'внешний (80/443 не занимаем)' || echo 'встроенный Caddy')"
printf ' профиль: %s (авто-детект: %s)\n' "$PROFILE" "$tier_detected"
printf ' каталог: %s\n' "$TARGET_DIR"
printf ' образ: %s (%s)\n' "$APP_IMAGE" "$( [ "$APP_IMAGE" = "glchat:local" ] && echo 'сборка из репозитория' || echo 'готовый образ')"
printf ' TURN: %s\n' "$( [ "$TURN_ENABLED" = "true" ] && echo "включён (turn.${DOMAIN}, ${TURN_UDP_PORT}/udp, ${TURN_TLS_PORT}/tcp)" || echo выключен)"
printf ' fail2ban: %s\n' "$( [ "$ENABLE_FAIL2BAN" = "true" ] && echo 'sshd + glchat-auth + recidive' || echo выключен)"
printf ' UFW/DOCKER-USER: %s\n' "$( [ "$ENABLE_UFW" = "true" ] && echo 'настраиваются' || echo 'не трогаем')"
printf ' nftables-лимиты: %s\n' "$( [ "$ENABLE_NFTABLES" = "true" ] && echo 'применяются' || echo 'не трогаем')"
printf ' SSH hardening: %s\n' "$( [ "$ENABLE_SSH_HARDENING" = "true" ] && echo 'при наличии ключей' || echo выключен)"
printf '\n%s=== Порты ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET"
if [ "$PROXY_MODE" = "external" ]; then
printf ' %-18s %-10s %s\n' "app (API+web)" "127.0.0.1:${APP_PORT}" "внутренний, наружу не публикуется"
printf ' %-18s %-10s %s\n' "LiveKit WS" "127.0.0.1:${LIVEKIT_WS_PORT}" "внутренний"
printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)"
else
printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS"
printf ' %-18s %-10s %s\n' "HTTPS" "${HTTPS_PORT}/tcp" "всё приложение"
fi
printf ' %-18s %-10s %s\n' "LiveKit RTC" "${LIVEKIT_TCP_PORT}/tcp" "fallback-транспорт WebRTC"
printf ' %-18s %-10s %s\n' "LiveKit RTC" "${LIVEKIT_UDP_PORT}/udp" "медиатрафик"
if [ "$TURN_ENABLED" = "true" ]; then
printf ' %-18s %-10s %s\n' "TURN" "${TURN_UDP_PORT}/udp" "NAT traversal"
printf ' %-18s %-10s %s\n' "TURN/TLS" "${TURN_TLS_PORT}/tcp" "TURN через TLS"
fi
printf ' %-18s %-10s %s\n' "SSH" "${SSH_PORT}/tcp" "администрирование (сохраняем)"
printf '\n'
}
# set_data_ownership приводит владельца каталогов данных к UID/GID контейнера.
set_data_ownership() {
local uid="${GLCHAT_CONTAINER_UID:-10001}" gid="${GLCHAT_CONTAINER_GID:-10001}"
for dir in db files backups; do
chown -R "${uid}:${gid}" "${GLCHAT_DATA_DIR}/${dir}" 2>/dev/null ||
warn "не удалось сменить владельца ${GLCHAT_DATA_DIR}/${dir} на ${uid}:${gid}"
done
chmod 750 "${GLCHAT_DATA_DIR}"/{db,files,backups} 2>/dev/null || true
}
# --- установка пакетов ----------------------------------------------------
install_packages() {
info "устанавливаем системные пакеты"
export DEBIAN_FRONTEND=noninteractive
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN apt-get install: ca-certificates curl gnupg jq openssl sqlite3 age nftables fail2ban ufw chrony docker-ce docker-compose-plugin"
return 0
fi
apt-get update -qq
apt-get install -y -qq \
ca-certificates curl gnupg jq openssl sqlite3 age nftables ufw chrony \
rsync gzip zstd tar >/dev/null
if [ "$ENABLE_FAIL2BAN" = "true" ]; then
apt-get install -y -qq fail2ban >/dev/null
fi
ok "пакеты установлены"
}
install_docker() {
if have docker && docker compose version >/dev/null 2>&1; then
ok "Docker уже установлен: $(docker --version)"
return 0
fi
info "устанавливаем Docker Engine из официального репозитория"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: docker apt repo + install docker-ce docker-compose-plugin"
return 0
fi
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.gpg ]; then
curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" |
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
# shellcheck disable=SC1091
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME:-$VERSION_CODENAME} stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null
systemctl enable --now docker
ok "Docker установлен: $(docker --version)"
}
# --- секреты и конфиги ----------------------------------------------------
generate_secrets() {
info "секреты"
local env_existing=0
[ -f "$GLCHAT_ENV_FILE" ] && env_existing=1
secret_or_new() {
local key="$1" generator="${2:-rand_hex}"
local current=""
if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ]; then
current="$(env_get "$key" || true)"
fi
if [ -n "$current" ]; then
printf '%s' "$current"
else
"$generator" 32
fi
}
SESSION_PEPPER="$(secret_or_new SESSION_PEPPER rand_hex)"
MASTER_KEY="$(secret_or_new MASTER_KEY rand_hex)"
TOTP_ENCRYPTION_KEY="$(secret_or_new TOTP_ENCRYPTION_KEY rand_hex)"
WEBHOOK_SECRET="$(secret_or_new WEBHOOK_SECRET rand_hex)"
LIVEKIT_API_KEY="$(secret_or_new LIVEKIT_API_KEY rand_hex)"
LIVEKIT_API_SECRET="$(secret_or_new LIVEKIT_API_SECRET rand_hex)"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)"
ok "секреты подготовлены (dry-run)"
return 0
fi
mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups}
chmod 700 "$GLCHAT_DATA_DIR"
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership
if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ] && [ -f "${GLCHAT_DATA_DIR}/backups/age-key.txt" ]; then
AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt"
else
AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt"
if have age-keygen; then
age-keygen -o "$AGE_KEY_FILE" >/dev/null 2>&1
else
warn "age-keygen не найден: создаём ed25519-ключ через openssl"
openssl genpkey -algorithm ED25519 -out "$AGE_KEY_FILE" 2>/dev/null
fi
fi
chmod 600 "$AGE_KEY_FILE"
AGE_RECIPIENT="$(grep -o 'age1[0-9a-z]*' "$AGE_KEY_FILE" | head -1 || true)"
if [ -z "$AGE_RECIPIENT" ] && have age-keygen; then
AGE_RECIPIENT="$(age-keygen -y "$AGE_KEY_FILE" 2>/dev/null || true)"
fi
ok "age-ключ бэкапов: ${AGE_KEY_FILE}"
}
write_env_file() {
info "пишем ${GLCHAT_ENV_FILE}"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: .env не записывается"
return 0
fi
local tmp
tmp="$(mktemp)"
cat >"$tmp" <<EOF
# glchat — окружение инстанса. Сгенерировано deploy/install.sh.
# Секреты: не коммитить, не публиковать, не пересылать. Права: 600.
# --- инстанс ---
DOMAIN=${DOMAIN}
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
INSTANCE_NAME=${INSTANCE_NAME}
MAIN_GUILD_NAME=${MAIN_GUILD_NAME}
PROFILE=${PROFILE_RESOLVED}
PROXY_MODE=${PROXY_MODE}
APP_VERSION=${IMAGE_TAG}
IMAGE_TAG=${IMAGE_TAG}
APP_IMAGE=${APP_IMAGE}
CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}
LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}
# --- интерполяция compose (пути и профиль) ---
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}
# --- порты ---
APP_PORT=${APP_PORT}
LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}
LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}
LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}
TURN_UDP_PORT=${TURN_UDP_PORT}
TURN_TLS_PORT=${TURN_TLS_PORT}
HTTP_PORT=${HTTP_PORT}
HTTPS_PORT=${HTTPS_PORT}
SSH_PORT=${SSH_PORT}
# --- секреты (генерируются один раз) ---
SESSION_PEPPER=${SESSION_PEPPER}
MASTER_KEY=${MASTER_KEY}
TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}
WEBHOOK_SECRET=${WEBHOOK_SECRET}
LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
AGE_RECIPIENT=${AGE_RECIPIENT:-}
# --- профиль производительности ---
SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}
SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000
SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))
GOMAXPROCS=${PROFILE_GOMAXPROCS}
LRU_CACHE_ENTRIES=${PROFILE_LRU_ENTRIES}
MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))
PROFILE_UPLOAD_MB=${EFFECTIVE_UPLOAD_MB}
APP_CPUS=${PROFILE_APP_CPUS}
APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}
LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}
LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}
CADDY_CPUS=1.0
CADDY_MEMORY_LIMIT=256m
LIVEKIT_MAX_PARTICIPANTS=${PROFILE_LIVEKIT_MAX_PARTICIPANTS}
LIVEKIT_MAX_VIDEO=${PROFILE_LIVEKIT_MAX_VIDEO}
LIVEKIT_DISABLE_SIMULCAST=${PROFILE_LIVEKIT_DISABLE_SIMULCAST}
LIVEKIT_USE_EXTERNAL_IP=${PROFILE_LIVEKIT_USE_EXTERNAL_IP}
SHARE_SOFT_LIMIT=${PROFILE_SHARE_SOFT}
SHARE_MAX_LIMIT=${PROFILE_SHARE_MAX}
BACKUP_KEEP_DAYS=${PROFILE_BACKUP_KEEP}
SWAP_SIZE_GB=${PROFILE_SWAP_GB}
# --- функции ---
REGISTRATION_ENABLED=${REGISTRATION_ENABLED}
ANTI_BOT_ENABLED=${ANTI_BOT_ENABLED:-false}
TURN_ENABLED=${TURN_ENABLED}
TURN_EXTERNAL_TLS=${TURN_EXTERNAL_TLS}
FAIL2BAN_ENABLED=${ENABLE_FAIL2BAN}
UFW_ENABLED=${ENABLE_UFW}
NFTABLES_ENABLED=${ENABLE_NFTABLES}
SSH_HARDENING=${SSH_HARDENING}
LOG_LEVEL=${LOG_LEVEL}
# --- первичный администратор (используется один раз при bootstrap) ---
ADMIN_EMAIL=${ADMIN_EMAIL}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
install -m 600 "$tmp" "$GLCHAT_ENV_FILE"
rm -f "$tmp"
ok ".env записан (права 600)"
}
apply_profile() {
info "профиль производительности: ${PROFILE_RESOLVED}"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: sysctl/swap не меняются (профиль ${PROFILE_RESOLVED})"
return 0
fi
if [ "$PROFILE_RESOLVED" = "none" ]; then
warn "профиль none: sysctl, swap и системные лимиты не трогаем"
return 0
fi
cat >/etc/sysctl.d/99-glchat.conf <<EOF
# glchat: сетевой тюнинг (AGENT.md 9.1)
net.core.somaxconn = 1024
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syncookies = 1
fs.file-max = 65535
vm.swappiness = ${PROFILE_SWAPPINESS:-10}
EOF
sysctl --system >/dev/null 2>&1 || warn "не удалось применить часть sysctl (проверьте вручную)"
ulimit_line="* soft nofile 65535
* hard nofile 65535"
printf '%s\n' "$ulimit_line" >/etc/security/limits.d/99-glchat.conf
if [ "${PROFILE_SWAP_GB:-0}" -gt 0 ]; then
ensure_swap "$PROFILE_SWAP_GB"
fi
ok "профиль ${PROFILE_RESOLVED} применён"
}
ensure_swap() {
local want_gb="$1" current_mb
current_mb="$(awk '/SwapTotal/ {printf "%d", $2/1024}' /proc/meminfo)"
if [ "$current_mb" -ge $((want_gb * 1024 - 256)) ]; then
ok "swap уже есть: ${current_mb} MB"
return 0
fi
if ! have fallocate && ! have dd; then
warn "не удалось создать swap: нет fallocate/dd"
return 0
fi
info "создаём swap ${want_gb} GB"
if have fallocate; then
fallocate -l "${want_gb}G" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=$((want_gb * 1024)) status=none
else
dd if=/dev/zero of=/swapfile bs=1M count=$((want_gb * 1024)) status=none
fi
chmod 600 /swapfile
mkswap /swapfile >/dev/null
swapon /swapfile
if ! grep -q '^/swapfile' /etc/fstab; then
printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
fi
ok "swap ${want_gb} GB активирован"
}
render_all_templates() {
info "генерируем конфиги в ${TARGET_DIR}"
mkdir -p "${TARGET_DIR}"/{caddy/logs,caddy/data,caddy/config,livekit,fail2ban,nftables}
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: рендер шаблонов compose/Caddy/LiveKit/fail2ban/nftables/systemd"
else
cp "${DEPLOY_DIR}/lib/common.sh" "${TARGET_DIR}/lib-common.sh" 2>/dev/null || true
cp "${DEPLOY_DIR}/"*.sh "${TARGET_DIR}/" 2>/dev/null || true
cp "${DEPLOY_DIR}/fail2ban/filter-glchat-auth.conf" "${TARGET_DIR}/fail2ban/"
fi
# Вариант compose выбирается по режиму: в тестовом прогоне (--skip-tls)
# порты LiveKit не публикуются, чтобы не занимать порты хост-машины.
compose_template="${DEPLOY_DIR}/compose.yaml.tpl"
if [ "$LIVEKIT_PUBLISH" != "true" ]; then
compose_template="${DEPLOY_DIR}/compose.test.yaml.tpl"
fi
render_template "${compose_template}" "${TARGET_DIR}/compose.yaml" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "INSTANCE_NAME=${INSTANCE_NAME}" \
"PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \
"GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "LOG_LEVEL=${LOG_LEVEL}" \
"SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \
"SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \
"SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \
"GOMAXPROCS=${PROFILE_GOMAXPROCS}" \
"MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))" \
"APP_CPUS=${PROFILE_APP_CPUS}" "APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}" \
"LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}" "LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}" \
"CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \
"CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
"HTTP_PORT=${HTTP_PORT}" "HTTPS_PORT=${HTTPS_PORT}"
if [ "$PROXY_MODE" = "external" ]; then
render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}"
else
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"PROXY_MODE=${PROXY_MODE}"
fi
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
"LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" \
"LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \
"LIVEKIT_USE_EXTERNAL_IP=${use_external_ip}" \
"TURN_ENABLED=${TURN_ENABLED}" \
"TURN_DOMAIN=turn.${DOMAIN}" \
"TURN_UDP_PORT=${TURN_UDP_PORT}" \
"TURN_TLS_PORT=${TURN_TLS_PORT}" \
"TURN_EXTERNAL_TLS=${turn_external_tls}" \
"LIVEKIT_MAX_PARTICIPANTS=${PROFILE_LIVEKIT_MAX_PARTICIPANTS}" \
"LIVEKIT_EXTRA_CODECS=${livekit_extra_codecs}" \
"LIVEKIT_DISABLE_SIMULCAST=${disable_simulcast}"
if [ "$ENABLE_FAIL2BAN" = "true" ]; then
render_template "${DEPLOY_DIR}/fail2ban/jail.local.tpl" "${TARGET_DIR}/fail2ban/jail.local" \
"SSH_PORT=${SSH_PORT}"
fi
if [ "$ENABLE_NFTABLES" = "true" ]; then
local web_ports="80, 443"
[ "$PROXY_MODE" = "external" ] && web_ports="80, 443"
render_template "${DEPLOY_DIR}/nftables/glchat.nft.tpl" "${TARGET_DIR}/nftables/glchat.nft" \
"WEB_PORTS=${web_ports}" "CONNLIMIT=200" "CONN_RATE=50" "SYN_RATE=100"
fi
render_template "${DEPLOY_DIR}/systemd/glchat.service.tpl" "${TARGET_DIR}/glchat.service"
if [ "$DRY_RUN" = "1" ]; then
ok "шаблоны отрендерены в ${TARGET_DIR} (dry-run)"
ls -la "$TARGET_DIR" 2>/dev/null | sed -n '1,20p' | log_file_only
else
ok "конфиги сгенерированы"
fi
}
install_cli_and_units() {
info "устанавливаем glchat CLI и systemd-юнит"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: install -m 0755 deploy/glchat-cli.sh /usr/local/bin/glchat; systemctl enable glchat.service"
return 0
fi
install -m 0755 "${TARGET_DIR}/glchat-cli.sh" /usr/local/bin/glchat
install -m 0644 "${TARGET_DIR}/glchat.service" /etc/systemd/system/glchat.service
systemctl daemon-reload
systemctl enable glchat.service >/dev/null 2>&1 || warn "не удалось включить glchat.service"
ok "glchat CLI установлен (/usr/local/bin/glchat)"
}
# --- безопасность ---------------------------------------------------------
configure_ufw() {
[ "$ENABLE_UFW" = "true" ] || {
warn "UFW не настраиваем (--no-ufw)"
return 0
}
info "настраиваем UFW"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: ufw default deny incoming; allow ${SSH_PORT}, ${LIVEKIT_TCP_PORT}/tcp, ${LIVEKIT_UDP_PORT}/udp, TURN"
return 0
fi
ufw --force reset >/dev/null 2>&1 || true
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw allow "${SSH_PORT}/tcp" comment 'ssh' >/dev/null
if [ "$PROXY_MODE" = "embedded" ]; then
ufw allow "${HTTP_PORT}/tcp" comment 'http' >/dev/null
ufw allow "${HTTPS_PORT}/tcp" comment 'https' >/dev/null
fi
ufw allow "${LIVEKIT_TCP_PORT}/tcp" comment 'livekit-rtc-tcp' >/dev/null
ufw allow "${LIVEKIT_UDP_PORT}/udp" comment 'livekit-rtc-udp' >/dev/null
if [ "$TURN_ENABLED" = "true" ]; then
ufw allow "${TURN_UDP_PORT}/udp" comment 'turn' >/dev/null
ufw allow "${TURN_TLS_PORT}/tcp" comment 'turn-tls' >/dev/null
fi
ufw --force enable >/dev/null
ok "UFW активен: $(ufw status | head -1)"
}
configure_docker_user_chain() {
[ "$ENABLE_UFW" = "true" ] || return 0
info "правила DOCKER-USER (AGENT.md 10.4)"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: DOCKER-USER: разрешаем трафик к app/livekit только c docker-подсетей и localhost"
return 0
fi
if ! have iptables; then
apt-get install -y -qq iptables >/dev/null 2>&1 || true
fi
if ! iptables -nL DOCKER-USER >/dev/null 2>&1; then
warn "цепочка DOCKER-USER недоступна (Docker не запущен?) — правила будут применены службой glchat-docker-user"
return 0
fi
# Правила применяет отдельный скрипт: его же вызывает systemd-юнит
# при загрузке, поэтому логика не дублируется в установщике.
install -m 0755 "${TARGET_DIR}/ufw-docker.sh" /usr/local/sbin/glchat-docker-user
/usr/local/sbin/glchat-docker-user || warn "не удалось применить часть правил DOCKER-USER"
cat >/etc/systemd/system/glchat-docker-user.service <<'EOF'
[Unit]
Description=glchat: правила DOCKER-USER (не публиковать внутренние порты)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/glchat-docker-user
ExecReload=/usr/local/sbin/glchat-docker-user
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now glchat-docker-user.service >/dev/null 2>&1 || warn "не удалось включить glchat-docker-user.service"
ok "DOCKER-USER настроен"
}
configure_nftables() {
[ "$ENABLE_NFTABLES" = "true" ] || {
warn "nftables-лимиты не применяем (--no-nftables)"
return 0
}
info "nftables-лимиты (connlimit/hashlimit)"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: nft -f ${TARGET_DIR}/nftables/glchat.nft"
return 0
fi
if ! have nft; then
warn "nft не найден — пропускаем"
return 0
fi
mkdir -p /etc/nftables.d
install -m 0644 "${TARGET_DIR}/nftables/glchat.nft" /etc/nftables.d/glchat.nft
if nft -f /etc/nftables.d/glchat.nft 2>/dev/null; then
ok "правила nftables применены"
else
warn "не удалось применить nftables-правила (проверьте поддержку меток и цепочек)"
fi
cat >/etc/systemd/system/glchat-nftables.service <<'EOF'
[Unit]
Description=glchat: nftables-лимиты (connlimit/hashlimit/syn-flood)
DefaultDependencies=no
Before=network-pre.target
Wants=network-pre.target
After=systemd-modules-load.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.d/glchat.nft
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable glchat-nftables.service >/dev/null 2>&1 || warn "не удалось включить glchat-nftables.service"
}
configure_fail2ban() {
[ "$ENABLE_FAIL2BAN" = "true" ] || {
warn "fail2ban не настраиваем (--no-fail2ban)"
return 0
}
info "настраиваем fail2ban (AGENT.md 9.6)"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: fail2ban jails sshd, glchat-auth, recidive"
return 0
fi
if ! have fail2ban-client; then
apt-get install -y -qq fail2ban >/dev/null || {
warn "fail2ban не установлен"
return 0
}
fi
install -d /etc/fail2ban/filter.d /etc/fail2ban/jail.d
install -m 0644 "${DEPLOY_DIR}/fail2ban/filter-glchat-auth.conf" /etc/fail2ban/filter.d/glchat-auth.conf
install -m 0644 "${TARGET_DIR}/fail2ban/jail.local" /etc/fail2ban/jail.d/glchat.local
touch "${TARGET_DIR}/caddy/logs/access.log"
chmod 0640 "${TARGET_DIR}/caddy/logs/access.log"
chown root:adm "${TARGET_DIR}/caddy/logs/access.log" 2>/dev/null || true
mkdir -p /var/lib/fail2ban-glchat
if [ -f /etc/fail2ban/fail2ban.conf ]; then
sed -i 's#^\(dbpurgeage.*\)#\1#' /etc/fail2ban/fail2ban.conf
fi
systemctl enable fail2ban >/dev/null 2>&1 || true
systemctl restart fail2ban >/dev/null 2>&1 || systemctl start fail2ban >/dev/null 2>&1 || true
sleep 1
ok "fail2ban: $(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:\s*//p' || echo 'статус недоступен')"
}
configure_ssh_hardening() {
[ "$ENABLE_SSH_HARDENING" = "true" ] || {
warn "SSH hardening выключен"
return 0
}
info "SSH hardening"
local auth_keys=""
for home in /root /home/*; do
if [ -s "${home}/.ssh/authorized_keys" ]; then
auth_keys="${home}/.ssh/authorized_keys"
break
fi
done
if [ -z "$auth_keys" ]; then
warn "authorized_keys не найдены: SSH hardening НЕ применяется (защита от локаута, AGENT.md 9.2)"
log_file_only "SSH hardening пропущен: нет authorized_keys"
return 0
fi
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: sshd_config: PasswordAuthentication no, PermitRootLogin prohibit-password, MaxAuthTries 3"
return 0
fi
local dropin="/etc/ssh/sshd_config.d/99-glchat.conf"
mkdir -p /etc/ssh/sshd_config.d
cat >"$dropin" <<'EOF'
# glchat: вход только по ключам (AGENT.md 9.2)
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
EOF
if sshd -t 2>/dev/null; then
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || warn "не удалось перезагрузить sshd"
ok "SSH hardening применён (ключи найдены: ${auth_keys})"
else
rm -f "$dropin"
warn "конфиг sshd не прошёл проверку: изменения отменены"
fi
}
# --- запуск стека ---------------------------------------------------------
build_or_pull_image() {
if [ "$SKIP_IMAGE" = "1" ]; then
warn "сборка и запуск контейнеров пропущены (--skip-image)"
return 0
fi
info "готовим образ приложения: ${APP_IMAGE}"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: docker build -t ${APP_IMAGE} (или pull готового образа)"
return 0
fi
if [ "$APP_IMAGE" != "glchat:local" ]; then
docker pull "$APP_IMAGE" || die "не удалось скачать образ ${APP_IMAGE}"
ok "образ скачан"
return 0
fi
[ -n "$REPO_DIR" ] || die "для сборки образа нужен репозиторий (--image <ref> для готового образа)"
info "собираем образ из ${REPO_DIR} (может занять несколько минут)"
docker build -t "$APP_IMAGE" \
--build-arg "VERSION=${IMAGE_TAG}" \
--build-arg "COMMIT=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo none)" \
--build-arg "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg "GOPROXY=${GOPROXY:-https://proxy.golang.org,direct}" \
--build-arg "GOSUMDB=${GOSUMDB:-sum.golang.org}" \
"$REPO_DIR" || die "сборка образа не удалась"
ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)"
}
start_stack() {
if [ "$SKIP_IMAGE" = "1" ]; then
return 0
fi
info "запускаем стек"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: docker compose up -d --remove-orphans"
return 0
fi
local compose_args=(-f "${TARGET_DIR}/compose.yaml")
[ "$PROXY_MODE" = "embedded" ] && compose_args+=(--profile embedded-proxy)
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" pull --quiet 2>/dev/null || true)
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось запустить стек"
info "ожидаем готовности приложения"
local waited=0
until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do
waited=$((waited + 2))
if [ "$waited" -ge 60 ]; then
warn "приложение не ответило за 60 с — смотрите: glchat logs app"
break
fi
sleep 2
done
curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1 && ok "приложение готово" || warn "готовность не подтверждена"
}
bootstrap_admin() {
info "bootstrap инстанса (админ, главный сервер, роли)"
if [ -z "$ADMIN_EMAIL" ]; then
warn "админ не задан (--admin): bootstrap будет выполнен вручную в фазе 1"
return 0
fi
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: glchat bootstrap-admin --email <admin>"
return 0
fi
if docker exec glchat-app /usr/local/bin/glchat bootstrap-admin \
--email "$ADMIN_EMAIL" --password "$ADMIN_PASSWORD" \
--instance-name "$INSTANCE_NAME" --main-guild "$MAIN_GUILD_NAME" >/dev/null 2>&1; then
ok "инстанс-админ создан"
else
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
fi
}
run_smoke() {
if [ "$SKIP_IMAGE" = "1" ]; then
info "smoke приложения пропущен (--skip-image): проверка конфигов выполнена"
return 0
fi
info "smoke-тест (AGENT.md 10.9)"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: smoke.sh"
return 0
fi
GLCHAT_ETC_DIR="$TARGET_DIR" bash "${TARGET_DIR}/smoke.sh" || warn "smoke-тест завершился с замечаниями"
}
print_summary() {
local scheme="https" base="https://${DOMAIN}"
[ "$SKIP_TLS" = "1" ] && base="http://${DOMAIN}"
printf '\n%s=== Установка завершена ===%s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET"
printf ' URL: %s\n' "$base"
printf ' админ: %s\n' "${ADMIN_EMAIL:-не задан}"
printf ' каталог: %s\n' "$TARGET_DIR"
printf ' журнал: %s\n' "$GLCHAT_INSTALL_LOG"
printf ' профиль: %s\n' "$PROFILE_RESOLVED"
if [ "$PROXY_MODE" = "external" ]; then
printf '\n%sВнешний прокси:%s добавьте блок из %s/external-proxy.conf в конфиг своего прокси\n' \
"$GLCHAT_BOLD" "$GLCHAT_RESET" "$TARGET_DIR"
printf ' и перезагрузите его: caddy reload --config <ваш Caddyfile>\n'
fi
cat <<EOF
Команды обслуживания:
glchat status | logs [service] | restart [service]
glchat backup | restore <файл> | update
glchat profile [lite|standard|plus|pro|custom|none]
glchat security-check | fail2ban-status | unban <ip>
Важно:
* Приватный age-ключ бэкапов: ${GLCHAT_DATA_DIR}/backups/age-key.txt — сохраните его ВНЕ сервера
и удалите с сервера: без него расшифровать бэкапы невозможно.
* Не забудьте про внешний firewall провайдера (если есть).
EOF
if [ "$SKIP_TLS" = "0" ] && [ "$PROXY_MODE" = "embedded" ]; then
printf " * Сертификат Let's Encrypt выпускается автоматически при первом запросе.\n"
fi
printf '\n'
}
# --- основной поток -------------------------------------------------------
main() {
parse_args "$@"
need_root
info "glchat installer (${IMAGE_TAG}) — режим: $([ "$DRY_RUN" = 1 ] && echo dry-run || echo apply)"
check_os
interactive_defaults
validate_inputs
check_resources
check_dns "$DOMAIN" || true
check_ports_free
local detected_profile
detected_profile="$(detect_profile)"
if [ "$PROFILE" = "auto" ]; then
PROFILE_RESOLVED="$detected_profile"
else
PROFILE_RESOLVED="$PROFILE"
fi
profile_values "$PROFILE_RESOLVED"
if [ -z "$MAX_UPLOAD_MB" ]; then
EFFECTIVE_UPLOAD_MB="$PROFILE_UPLOAD_MB"
else
EFFECTIVE_UPLOAD_MB="$MAX_UPLOAD_MB"
fi
PROFILE_APP_CPUS="$(detect_cpus)"
PROFILE_LIVEKIT_CPUS="1.0"
PROFILE_SWAPPINESS=10
# Производные значения зависят от профиля (simulcast) и режима прокси,
# поэтому считаются после profile_values и до записи .env и шаблонов.
resolve_derived_values
print_plan "$detected_profile"
if ! confirm "Применить план? (yes/no)" no; then
warn "установка отменена пользователем"
exit 0
fi
if [ "$DRY_RUN" != "1" ] && [ -z "$ADMIN_PASSWORD" ]; then
ADMIN_PASSWORD="$(prompt_secret 'Пароль инстанс-администратора (мин. 8 символов)')"
[ -n "$ADMIN_PASSWORD" ] || die "пароль не задан"
fi
install_packages
install_docker
generate_secrets
write_env_file
render_all_templates
apply_profile
configure_ufw
configure_docker_user_chain
configure_nftables
configure_fail2ban
configure_ssh_hardening
install_cli_and_units
build_or_pull_image
start_stack
bootstrap_admin
run_smoke
print_summary
if [ "$DRY_RUN" = "1" ]; then
printf '\n%sDry-run:%s файлы для проверки остались в %s (удалите вручную)\n\n' \
"$GLCHAT_BOLD" "$GLCHAT_RESET" "$TARGET_DIR"
fi
}
if [ "${GLCHAT_INSTALL_SH_SOURCED:-0}" != "1" ]; then
main "$@"
fi