Files
glchat/deploy/install.sh
T
grendervill e8e063e52d feat(turn): внешний TURN-релей и TLS через прокси
- install.sh: --tls-terminated-by-proxy (ссылки https/wss без своего 80/443),
  --turn-relay-host/--turn-relay-port/--turn-relay-tls-port, общий TURN_SECRET
  для coturn (use-auth-secret) и LiveKit
- livekit.yaml.tpl: rtc.turn_servers с внешним релеем (udp + tls)
- секрет вычисляется один раз и сохраняется в .env
2026-09-19 20:28:03 +03:00

1321 lines
58 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 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}"
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-domain <domain> домен TURN/TLS (по умолчанию turn.<domain>)
--turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN)
--turn-key <файл> приватный ключ сертификата TURN/TLS
--turn-relay-host <host> внешний TURN-релей с публичным IP (например, VPS):
клиенты получают его в списке turn_servers
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
--tls-terminated-by-proxy TLS терминирует внешний прокси: свои 80/443 не занимаем,
но ссылки в API отдаются как https/wss
--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-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 ;;
--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"
TURN_DOMAIN_RESOLVED="${TURN_DOMAIN_OVERRIDE:-turn.${DOMAIN}}"
TURN_CERT_DIR="${TARGET_DIR}/certs"
TURN_RELAY_HOST_RESOLVED="${TURN_RELAY_HOST}"
turn_shared_secret >/dev/null
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}"
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 \
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_SECRET_RESOLVED
}
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" ]; then
die "TURN включён, но не задан сертификат: передайте --turn-cert/--turn-key или запустите с --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
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}
# --- TLS ---
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
# (--external-proxy --tls-terminated-by-proxy): клиентские ссылки должны быть https/wss.
if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then
TLS_ENABLED=false
else
TLS_ENABLED=true
fi
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}
TURN_RELAY_HOST=${TURN_RELAY_HOST_RESOLVED}
TURN_RELAY_PORT=${TURN_RELAY_PORT}
TURN_RELAY_TLS_PORT=${TURN_RELAY_TLS_PORT}
TURN_SECRET=${TURN_SECRET_RESOLVED}
# --- порты ---
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 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.
# Секрет уже вычислен в resolve_derived_values (TURN_SECRET_RESOLVED).
render_turn_relay_servers() {
[ -n "$TURN_RELAY_HOST" ] || return 0
printf ' turn_servers:\n'
printf ' - host: %s\n port: %s\n protocol: udp\n secret: %s\n' \
"$TURN_RELAY_HOST" "$TURN_RELAY_PORT" "$TURN_SECRET_RESOLVED"
printf ' - host: %s\n port: %s\n protocol: tls\n secret: %s\n' \
"$TURN_RELAY_HOST" "$TURN_RELAY_TLS_PORT" "$TURN_SECRET_RESOLVED"
}
# turn_shared_secret — общий секрет для coturn (use-auth-secret) и LiveKit.
# При --reconfigure сохраняется из существующего .env, иначе генерируется.
turn_shared_secret() {
if [ -n "${TURN_SECRET_RESOLVED:-}" ]; then
printf '%s' "$TURN_SECRET_RESOLVED"
return
fi
if [ -f "$GLCHAT_ENV_FILE" ]; then
TURN_SECRET_RESOLVED="$(env_get TURN_SECRET || true)"
fi
[ -n "${TURN_SECRET_RESOLVED:-}" ] || TURN_SECRET_RESOLVED="$(rand_hex 32)"
export TURN_SECRET_RESOLVED
printf '%s' "$TURN_SECRET_RESOLVED"
}
# install_turn_certificate копирует сертификат TURN/TLS туда, где его читает
# контейнер LiveKit. Продление выполняет glchat-cert-refresh.timer.
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_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}" \
"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" ] || return 0
[ "$DRY_RUN" = "1" ] && return 0
cat >/usr/local/sbin/glchat-cert-refresh <<EOF
#!/usr/bin/env bash
# Сгенерировано deploy/install.sh: копирует свежий сертификат TURN/TLS
# из внешнего ACME-хранилища и перезапускает LiveKit при изменении.
set -eu
SRC_CRT="${TURN_CERT_SRC}"
SRC_KEY="${TURN_CERT_SRC_KEY}"
DST_DIR="${TURN_CERT_DIR}"
if [ ! -r "\$SRC_CRT" ] || [ ! -r "\$SRC_KEY" ]; then
echo "glchat-cert-refresh: исходный сертификат недоступен" >&2
exit 1
fi
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 <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
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