#!/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_DOMAIN_OVERRIDE="${TURN_DOMAIN_OVERRIDE:-}" TURN_RELAY_START="${TURN_RELAY_START:-30000}" TURN_RELAY_END="${TURN_RELAY_END:-40000}" TURN_CERT_SRC="${TURN_CERT_SRC:-}" TURN_CERT_SRC_KEY="${TURN_CERT_SRC_KEY:-}" TURN_RELAY_HOST="${TURN_RELAY_HOST:-}" TURN_RELAY_PORT="${TURN_RELAY_PORT:-3478}" TURN_RELAY_TLS_PORT="${TURN_RELAY_TLS_PORT:-5349}" TLS_BY_PROXY="${TLS_BY_PROXY:-false}" APP_BIND_ADDR="${APP_BIND_ADDR:-127.0.0.1}" LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}" TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}" TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}" TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}" TURN_CERT_SSH_KEY="${TURN_CERT_SSH_KEY:-}" TURN_CERT_SSH_PORT="${TURN_CERT_SSH_PORT:-22}" 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 домен инстанса (обязателен) --email email для Let's Encrypt (обязателен, если TLS включён) --admin логин инстанс-администратора --password пароль инстанс-администратора (иначе — интерактивный ввод) --instance-name название инстанса (по умолчанию glchat) Сеть: --ssh-port SSH-порт (по умолчанию берётся из sshd_config) --http-port HTTP (по умолчанию 80) --https-port HTTPS (по умолчанию 443) --app-port внутренний порт приложения (127.0.0.1, по умолчанию 8080) --livekit-tcp LiveKit TCP (по умолчанию 7881) --livekit-udp LiveKit UDP (по умолчанию 7882) --turn-udp TURN UDP (по умолчанию 3478) --turn-domain домен TURN/TLS (по умолчанию turn.) --turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN) --turn-key <файл> приватный ключ сертификата TURN/TLS --turn-relay-host внешний TURN-релей с публичным IP (например, VPS): клиенты получают его в списке turn_servers --turn-relay-port UDP-порт внешнего релея (по умолчанию 3478) --turn-relay-tls-port TLS-порт внешнего релея (по умолчанию 5349) --tls-terminated-by-proxy TLS терминирует внешний прокси: свои 80/443 не занимаем, но ссылки в API отдаются как https/wss --app-bind-addr адрес app (по умолчанию 127.0.0.1); для прокси по WireGuard укажите адрес в туннеле, например 10.77.0.13 --livekit-bind-addr адрес LiveKit (по умолчанию 127.0.0.1) --turn-cert-remote тянуть сертификат TURN/TLS с внешнего узла по SSH --turn-cert-remote-crt <путь> путь к сертификату на внешнем узле --turn-cert-remote-key <путь> путь к ключу на внешнем узле --turn-cert-ssh-key <файл> приватный SSH-ключ для загрузки сертификата --turn-cert-ssh-port <порт> SSH-порт внешнего узла (по умолчанию 22) --turn-tls TURN TLS (по умолчанию 5349) --external-proxy не занимать 80/443: отдать маршруты внешнему прокси (AGENT.md 10.13) --no-files-subdomain не использовать files. (fallback /files/*) --no-turn отключить встроенный TURN (UDP 7882 + TCP 7881 остаются) Ресурсы и безопасность: --profile auto|lite|standard|plus|pro|custom|none (по умолчанию auto) --max-upload переопределить лимит загрузки --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 использовать готовый образ вместо сборки из репозитория -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-domain) TURN_DOMAIN_OVERRIDE="${2:?}"; shift 2 ;; --turn-cert) TURN_CERT_SRC="${2:?}"; shift 2 ;; --turn-key) TURN_CERT_SRC_KEY="${2:?}"; shift 2 ;; --turn-relay-host) TURN_RELAY_HOST="${2:?}"; shift 2 ;; --turn-relay-port) TURN_RELAY_PORT="${2:?}"; shift 2 ;; --turn-relay-tls-port) TURN_RELAY_TLS_PORT="${2:?}"; shift 2 ;; --tls-terminated-by-proxy) TLS_BY_PROXY="true"; shift ;; --app-bind-addr) APP_BIND_ADDR="${2:?}"; shift 2 ;; --livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;; --turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;; --turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;; --turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;; --turn-cert-ssh-key) TURN_CERT_SSH_KEY="${2:?}"; shift 2 ;; --turn-cert-ssh-port) TURN_CERT_SSH_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 " 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" TURN_DOMAIN_RESOLVED="${TURN_DOMAIN_OVERRIDE:-turn.${DOMAIN}}" TURN_CERT_DIR="${TARGET_DIR}/certs" TURN_RELAY_HOST_RESOLVED="${TURN_RELAY_HOST}" TURN_RELAY_USER="${TURN_RELAY_USER:-glchat}" tun_relay_password_from_env="" if [ -f "$GLCHAT_ENV_FILE" ]; then tun_relay_password_from_env="$(env_get TURN_RELAY_PASSWORD || true)" fi TURN_RELAY_PASSWORD="${TURN_RELAY_PASSWORD:-$tun_relay_password_from_env}" [ -n "$TURN_RELAY_PASSWORD" ] || TURN_RELAY_PASSWORD="$(rand_base64 24)" 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}" GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}" # TLS считается включённым и когда сертификаты обслуживает внешний прокси # (--external-proxy --tls-terminated-by-proxy): ссылки в API должны быть https/wss. if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then TLS_ENABLED="false" else TLS_ENABLED="true" fi GLCHAT_APP_BIND="${APP_BIND_ADDR}" GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}" 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 GLCHAT_TURN_CERT_DIR \ GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \ TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED \ TURN_RELAY_USER TURN_RELAY_PASSWORD } 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 # LiveKit требует сертификат для TURN/TLS (проверено на v1.9.7: без него # сервер не стартует). Либо передаём файлы, либо отключаем TURN. if [ "$TURN_ENABLED" = "true" ] && [ "$DRY_RUN" != "1" ] && [ -z "$TURN_CERT_SRC" ] && [ -z "$TURN_CERT_REMOTE" ] && [ ! -f "${TARGET_DIR}/certs/turn.crt" ]; then die "TURN включён, но сертификат не задан: используйте --turn-cert/--turn-key, --turn-cert-remote или --no-turn" 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 elif [ "$RECONFIGURE" != "1" ]; then warn "порт ${p} занят — проверьте, не запущен ли уже glchat (для переустановки используйте --reconfigure)" 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)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется" printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${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_age_key создаёт файл ключа age (X25519) без вызова age-keygen -o: # age-keygen без терминала читает stdin и при запуске через `sudo -S` # высасывает пайп с паролем, из-за чего установщик зависает. generate_age_key() { local dst="$1" local raw pub raw="$(openssl rand 32 | od -An -tx1 | tr -d '[:space:]')" if ! have age-keygen; then warn "age-keygen не найден: бэкапы будут без шифрования" return 0 fi pub="$(printf 'AGE-SECRET-KEY-1%s\n' "$(bech32_encode "$raw" | tr 'a-z' 'A-Z')" >"$dst.tmp" && age-keygen -y "$dst.tmp" /dev/null || true)" if [ -z "$pub" ]; then rm -f "$dst.tmp" warn "не удалось сгенерировать age-ключ" return 0 fi { printf '# created: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '# public key: %s\n' "$pub" cat "$dst.tmp" } >"$dst" rm -f "$dst.tmp" } # bech32_encode кодирует 32-байтовый ключ (hex) в data-часть bech32 (BIP-173). bech32_encode() { local hex="$1" python3 - "$hex" <<'PYEOF' import sys hexs = sys.argv[1] data = bytes.fromhex(hexs) # convertbits(8 -> 5) bits = 0 value = 0 out = [] for byte in data: value = (value << 8) | byte bits += 8 while bits >= 5: bits -= 5 out.append((value >> bits) & 31) if bits: out.append((value << (5 - bits)) & 31) charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" print("".join(charset[d] for d in out)) PYEOF } 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" generate_age_key "$AGE_KEY_FILE" 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" /dev/null || true)" fi if [ -z "$AGE_RECIPIENT" ]; then warn "не удалось определить публичный age-ключ: бэкапы будут без шифрования" 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 # Значения подставляются через sed, а не через расширение heredoc: иначе # shell выполняет условия и подстановки прямо в тексте файла (уже ломало .env). local tmp tmp="$(mktemp)" cat >"$tmp" <<'ENVEOF' # glchat — окружение инстанса. Сгенерировано deploy/install.sh. # Секреты: не коммитить, не публиковать, не пересылать. Права: 600. # --- инстанс --- DOMAIN=@DOMAIN@ FILES_DOMAIN=@FILES_DOMAIN@ INSTANCE_NAME=@INSTANCE_NAME@ MAIN_GUILD_NAME=@MAIN_GUILD_NAME@ PROFILE=@PROFILE@ 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@ LOG_LEVEL=@LOG_LEVEL@ # --- TLS --- TLS_ENABLED=@TLS_ENABLED@ SKIP_TLS=@SKIP_TLS@ TLS_BY_PROXY=@TLS_BY_PROXY@ # --- интерполяция compose (пути, адреса привязки, профиль) --- GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@ GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@ GLCHAT_APP_BIND=@GLCHAT_APP_BIND@ GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@ # --- порты --- APP_PORT=@APP_PORT@ LIVEKIT_WS_PORT=@LIVEKIT_WS_PORT@ LIVEKIT_TCP_PORT=@LIVEKIT_TCP_PORT@ LIVEKIT_UDP_PORT=@LIVEKIT_UDP_PORT@ HTTP_PORT=@HTTP_PORT@ HTTPS_PORT=@HTTPS_PORT@ SSH_PORT=@SSH_PORT@ # --- TURN --- 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@ TURN_RELAY_START=@TURN_RELAY_START@ TURN_RELAY_END=@TURN_RELAY_END@ TURN_RELAY_HOST=@TURN_RELAY_HOST@ TURN_RELAY_PORT=@TURN_RELAY_PORT@ TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_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@ TURN_RELAY_USER=@TURN_RELAY_USER@ TURN_RELAY_PASSWORD=@TURN_RELAY_PASSWORD@ AGE_RECIPIENT=@AGE_RECIPIENT@ # --- профиль производительности --- SQLITE_READ_POOL=@SQLITE_READ_POOL@ SQLITE_CACHE_SIZE=@SQLITE_CACHE_SIZE@ SQLITE_MMAP_SIZE=@SQLITE_MMAP_SIZE@ GOMAXPROCS=@GOMAXPROCS@ LRU_CACHE_ENTRIES=@LRU_CACHE_ENTRIES@ MAX_UPLOAD_SIZE=@MAX_UPLOAD_SIZE@ PROFILE_UPLOAD_MB=@PROFILE_UPLOAD_MB@ APP_CPUS=@APP_CPUS@ APP_MEMORY_LIMIT=@APP_MEMORY_LIMIT@ LIVEKIT_CPUS=@LIVEKIT_CPUS@ LIVEKIT_MEMORY_LIMIT=@LIVEKIT_MEMORY_LIMIT@ CADDY_CPUS=1.0 CADDY_MEMORY_LIMIT=256m LIVEKIT_MAX_PARTICIPANTS=@LIVEKIT_MAX_PARTICIPANTS@ LIVEKIT_MAX_VIDEO=@LIVEKIT_MAX_VIDEO@ LIVEKIT_DISABLE_SIMULCAST=@LIVEKIT_DISABLE_SIMULCAST@ LIVEKIT_USE_EXTERNAL_IP=@LIVEKIT_USE_EXTERNAL_IP@ SHARE_SOFT_LIMIT=@SHARE_SOFT@ SHARE_MAX_LIMIT=@SHARE_MAX@ BACKUP_KEEP_DAYS=@BACKUP_KEEP@ SWAP_SIZE_GB=@SWAP_SIZE@ # --- функции --- REGISTRATION_ENABLED=@REGISTRATION_ENABLED@ ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@ FAIL2BAN_ENABLED=@FAIL2BAN_ENABLED@ UFW_ENABLED=@UFW_ENABLED@ NFTABLES_ENABLED=@NFTABLES_ENABLED@ SSH_HARDENING=@SSH_HARDENING@ # --- первичный администратор (используется один раз при bootstrap) --- ADMIN_EMAIL=@ADMIN_EMAIL@ ADMIN_PASSWORD=@ADMIN_PASSWORD@ ENVEOF # Подстановка через python3: значения содержат @, /, пробелы и кавычки, # поэтому sed с произвольным разделителем ненадёжен. command -v python3 >/dev/null 2>&1 || die "для генерации .env нужен python3" local pairs_file pairs_file="$(mktemp)" cat >"$pairs_file" </etc/sysctl.d/99-glchat.conf </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 active_file want_mb=$((want_gb * 1024)) current_mb="$(awk '/SwapTotal/ {printf "%d", $2/1024}' /proc/meminfo)" # Достаточно активного swap (в том числе созданного провайдером или # cloud-init — файл может называться иначе, чем /swapfile). if [ "$current_mb" -ge $((want_mb - 256)) ]; then ok "swap уже есть: ${current_mb} MB" return 0 fi # Файл /swapfile может уже использоваться как swap под другим именем в /proc/swaps. active_file="$(awk 'NR > 1 {print $1}' /proc/swaps 2>/dev/null | grep -E '^/swapfile$' || true)" if [ -n "$active_file" ]; then ok "swap уже активен: ${active_file} (${current_mb} MB)" return 0 fi if ! have fallocate && ! have dd; then warn "не удалось создать swap: нет fallocate/dd" return 0 fi info "создаём swap ${want_gb} GB" rm -f /swapfile 2>/dev/null || true if have fallocate; then fallocate -l "${want_gb}G" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$want_mb" status=none else dd if=/dev/zero of=/swapfile bs=1M count="$want_mb" status=none fi chmod 600 /swapfile mkswap /swapfile >/dev/null swapon /swapfile || { warn "не удалось активировать swap" return 0 } if ! grep -qE '^/swapfile[[:space:]]' /etc/fstab; then printf '/swapfile none swap sw 0 0\n' >>/etc/fstab fi ok "swap ${want_gb} GB активирован" } # render_turn_relay_servers формирует YAML-список turn_servers для LiveKit: # UDP и TLS-эндпоинты внешнего релея (VPS), у которого есть публичный IP. # Используются статические креденшелы coturn (username/credential). render_turn_relay_servers() { [ -n "$TURN_RELAY_HOST" ] || return 0 printf ' turn_servers:\n' printf ' - host: %s\n port: %s\n protocol: udp\n username: %s\n credential: %s\n' \ "$TURN_RELAY_HOST" "$TURN_RELAY_PORT" "$TURN_RELAY_USER" "$TURN_RELAY_PASSWORD" printf ' - host: %s\n port: %s\n protocol: tls\n username: %s\n credential: %s\n' \ "$TURN_RELAY_HOST" "$TURN_RELAY_TLS_PORT" "$TURN_RELAY_USER" "$TURN_RELAY_PASSWORD" } # install_turn_certificate копирует сертификат TURN/TLS туда, где его читает # контейнер LiveKit. Продление выполняет glchat-cert-refresh.timer. fetch_remote_turn_certificate() { [ -n "$TURN_CERT_REMOTE" ] || return 1 local ssh_opts=(-n -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p "$TURN_CERT_SSH_PORT") [ -n "$TURN_CERT_SSH_KEY" ] && ssh_opts+=(-i "$TURN_CERT_SSH_KEY") command -v ssh >/dev/null 2>&1 || { warn "ssh не найден: не могу забрать сертификат с ${TURN_CERT_REMOTE}" return 1 } ssh "${ssh_opts[@]}" "$TURN_CERT_REMOTE" "cat '${TURN_CERT_REMOTE_CRT}'" >"${TURN_CERT_DIR}/turn.crt" 2>/dev/null || { warn "не удалось получить сертификат с ${TURN_CERT_REMOTE}:${TURN_CERT_REMOTE_CRT}" rm -f "${TURN_CERT_DIR}/turn.crt" return 1 } ssh "${ssh_opts[@]}" "$TURN_CERT_REMOTE" "cat '${TURN_CERT_REMOTE_KEY}'" >"${TURN_CERT_DIR}/turn.key" 2>/dev/null || { warn "не удалось получить ключ с ${TURN_CERT_REMOTE}:${TURN_CERT_REMOTE_KEY}" rm -f "${TURN_CERT_DIR}/turn.key" return 1 } chmod 640 "${TURN_CERT_DIR}/turn.crt" "${TURN_CERT_DIR}/turn.key" return 0 } install_turn_certificate() { [ "$TURN_ENABLED" = "true" ] || return 0 if [ "$DRY_RUN" = "1" ]; then log_file_only "DRY-RUN: копирование сертификата TURN/TLS в ${TURN_CERT_DIR}" return 0 fi mkdir -p "$TURN_CERT_DIR" chmod 750 "$TURN_CERT_DIR" if [ -n "$TURN_CERT_REMOTE" ]; then fetch_remote_turn_certificate && ok "сертификат TURN/TLS загружен с ${TURN_CERT_REMOTE}" || warn "сертификат TURN/TLS не загружен: TURN не запустится" return 0 fi if [ -n "$TURN_CERT_SRC" ]; then install -m 640 "$TURN_CERT_SRC" "${TURN_CERT_DIR}/turn.crt" install -m 640 "${TURN_CERT_SRC_KEY:?нужен --turn-key}" "${TURN_CERT_DIR}/turn.key" ok "сертификат TURN/TLS установлен: ${TURN_CERT_DIR}/turn.crt" elif [ -f "${TURN_CERT_DIR}/turn.crt" ]; then ok "используем ранее установленный сертификат TURN/TLS" else warn "сертификат TURN/TLS не найден: TURN не запустится (см. --turn-cert)" fi } 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}" \ "GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \ "GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \ "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_RESOLVED}" \ "TURN_RELAY_START=${TURN_RELAY_START}" \ "TURN_RELAY_END=${TURN_RELAY_END}" \ "TURN_CERT_FILE=$([ "$TURN_ENABLED" = "true" ] && echo /etc/certs/turn.crt || echo '')" \ "TURN_KEY_FILE=$([ "$TURN_ENABLED" = "true" ] && echo /etc/certs/turn.key || echo '')" \ "TURN_RELAY_SERVERS=$(render_turn_relay_servers)" \ "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_cert_refresh копирует обновлённый сертификат TURN и перезапускает LiveKit. install_cert_refresh() { [ "$TURN_ENABLED" = "true" ] || return 0 [ -n "$TURN_CERT_SRC" ] || [ -n "$TURN_CERT_REMOTE" ] || return 0 [ "$DRY_RUN" = "1" ] && return 0 cat >/usr/local/sbin/glchat-cert-refresh <"\$TMP_CRT" || exit 1 # shellcheck disable=SC2086 ssh \$ssh_opts "\$REMOTE" "cat '\$REMOTE_KEY'" >"\$TMP_KEY" || exit 1 else if [ ! -r "\$SRC_CRT" ] || [ ! -r "\$SRC_KEY" ]; then echo "glchat-cert-refresh: исходный сертификат недоступен" >&2 exit 1 fi cp "\$SRC_CRT" "\$TMP_CRT" cp "\$SRC_KEY" "\$TMP_KEY" fi SRC_CRT="\$TMP_CRT"; SRC_KEY="\$TMP_KEY" before="\$(sha256sum "\$DST_DIR/turn.crt" 2>/dev/null | cut -d' ' -f1 || true)" install -m 640 "\$SRC_CRT" "\$DST_DIR/turn.crt" install -m 640 "\$SRC_KEY" "\$DST_DIR/turn.key" after="\$(sha256sum "\$DST_DIR/turn.crt" | cut -d' ' -f1)" if [ "\$before" != "\$after" ]; then docker restart glchat-livekit >/dev/null echo "glchat-cert-refresh: сертификат обновлён, LiveKit перезапущен" fi EOF chmod 0755 /usr/local/sbin/glchat-cert-refresh cat >/etc/systemd/system/glchat-cert-refresh.service <<'EOF' [Unit] Description=glchat: обновление сертификата TURN/TLS After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/glchat-cert-refresh EOF cat >/etc/systemd/system/glchat-cert-refresh.timer <<'EOF' [Unit] Description=glchat: ежедневная проверка сертификата TURN/TLS [Timer] OnCalendar=daily Persistent=true RandomizedDelaySec=1h [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now glchat-cert-refresh.timer >/dev/null 2>&1 || warn "не удалось включить glchat-cert-refresh.timer" ok "автообновление сертификата TURN настроено (systemd timer, ежедневно)" } 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)" } # --- безопасность --------------------------------------------------------- UFW_FOREIGN_FILE="/tmp/glchat-ufw-foreign.rules" # snapshot_foreign_ufw_rules сохраняет правила, которые не принадлежат glchat: # установщик делает ufw reset, и на общем сервере (VPS) правила владельца # (WireGuard, wstunnel и т.п.) обязаны остаться на месте. snapshot_foreign_ufw_rules() { : >"$UFW_FOREIGN_FILE" ufw status 2>/dev/null | awk -F'# ' 'NF == 1 && /ALLOW|DENY|LIMIT|REJECT/ {print $0}' | sed 's/[[:space:]]*$//' >>"$UFW_FOREIGN_FILE" || true } restore_foreign_ufw_rules() { [ -s "$UFW_FOREIGN_FILE" ] || return 0 local restored=0 line to from while IFS= read -r line; do [ -n "$line" ] || continue to="$(printf '%s' "$line" | awk '{print $1}')" from="$(printf '%s' "$line" | sed -E 's/^[^ ]+[[:space:]]+(ALLOW|DENY|LIMIT|REJECT)[[:space:]]+(IN[[:space:]]+)?//')" [ -n "$to" ] || continue case "$to" in Anywhere | anywhere) continue ;; esac case "$from" in *" on "* | *"("* | "") from="$from" ;; esac if ufw allow from "$from" to any port "${to%%/*}" proto "${to##*/}" comment 'glchat:preserved' >/dev/null 2>&1; then restored=$((restored + 1)) else warn "не удалось восстановить правило UFW: ${line}" fi done <"$UFW_FOREIGN_FILE" [ "$restored" -gt 0 ] && ok "восстановлено правил UFW владельца: ${restored}" return 0 } 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 ${TURN_UDP_PORT}/udp, ${TURN_TLS_PORT}/tcp, relay ${TURN_RELAY_START}-${TURN_RELAY_END}/udp" return 0 fi snapshot_foreign_ufw_rules 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 # Relay-диапазон: через эти порты TURN отдаёт медиа (AGENT.md 4.2). ufw allow "${TURN_RELAY_START}:${TURN_RELAY_END}/udp" comment 'turn-relay' >/dev/null fi ufw --force enable >/dev/null restore_foreign_ufw_rules 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 для готового образа)" 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 " 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 < | update glchat profile [lite|standard|plus|pro|custom|none] glchat security-check | fail2ban-status | unban Важно: * Приватный 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 install_turn_certificate 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 install_cert_refresh 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