Files
glchat/deploy/install.sh
T
grendervill a48b865bc9 refactor: убрать вход через внешние провайдеры (OAuth) полностью
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного
человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего
входа только создаёт трение (регистрация приложений, redirect URI, модерация,
чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа
(WebAuthn).

Удалено:
- сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go
  и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек;
  отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta;
- клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки
  провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры;
- установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер
  чтения существующих значений переименован в existing_value — он остался нужен
  для VAPID_SUBJECT);
- зависимость golang.org/x/oauth2 (go mod tidy).

Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем —
она применена на стендах). Откат миграции возвращает структуру; тест
TestOAuthRemovalMigration проверяет накат, откат и повторный накат.

AGENT.md (локальный) помечает пункты про OAuth как отменённые.
2026-09-27 18:27:33 +03:00

1915 lines
93 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
# Путь установки задан явно? Спрашиваем до common.sh: он подставляет дефолт
# /opt/glchat, после чего отличить явное значение от дефолтного нельзя.
GLCHAT_ETC_DIR_EXPLICIT=0
if [ -n "${GLCHAT_ETC_DIR:-}" ]; then
GLCHAT_ETC_DIR_EXPLICIT=1
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}"
# 20 портов релея: этого достаточно для ~100 одновременных relay-аллокаций
# (каждая использует пару портов), а docker создаёт приемлемое число правил.
TURN_RELAY_END="${TURN_RELAY_END:-30019}"
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/TLS: сертификат Let's Encrypt выписан на turn.<домен>, и
# браузеры отклоняют TLS-соединение к IP (несовпадение имени). По умолчанию
# берём turn.<домен>; если он не резолвится, остаётся адрес релея.
TURN_RELAY_TLS_HOST="${TURN_RELAY_TLS_HOST:-}"
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}"
ALLOW_PORTS="${ALLOW_PORTS:-}"
ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}"
LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}"
# Адрес публикации медиапортов LiveKit (TCP/UDP): по умолчанию совпадает с
# адресом привязки сигналинга. Разделять нужно, когда прокси стоит на том же
# хосте: сигналинг публикуется только для прокси, а медиа обязано быть
# доступно клиентам из интернета (AGENT.md 10.4, 10.13).
LIVEKIT_MEDIA_BIND_ADDR="${LIVEKIT_MEDIA_BIND_ADDR:-}"
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:-}"
ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}"
# Явная ротация секретов: по умолчанию секреты существующего .env сохраняются.
ROTATE_SECRETS="${ROTATE_SECRETS:-0}"
# Список адресов, которые fail2ban не банит. Локальные сети и WireGuard-туннель
# добавляются всегда: иначе администратор блокирует сам себя при работе в панели.
FAIL2BAN_IGNOREIP="${FAIL2BAN_IGNOREIP:-192.168.0.0/16 10.0.0.0/8 172.16.0.0/12}"
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}"
# Версия образа: файл VERSION в корне репозитория (тот же, что читает Makefile и
# держит desktop-обёртка), с запасным значением для установки без репозитория.
SCRIPT_VERSION="$(cat "${REPO_DIR}/VERSION" 2>/dev/null || true)"
SCRIPT_VERSION="${SCRIPT_VERSION:-0.1.0}"
IMAGE_TAG="${IMAGE_TAG:-v${SCRIPT_VERSION#v}}"
# Тег + digest: реестр может перезаписать тег, digest фиксирует содержимое
# (AGENT.md 11.3). Обновление — заменить digest после проверки образа.
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7@sha256:ee33f59dc8d2fe176db7f2ef74e1b3ec754f29b50b0ae519b3fa893f7b015d07}"
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}"
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
# Каталог обновлений desktop-обёртки: манифесты и артефакты раскладывает
# scripts/desktop-release.sh, приложение отдаёт их по /updates/… (docs/client-tauri.md §4).
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR:-${TARGET_DIR}/updates}"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме и на стенде приложение слушает не localhost, а адрес туннеля, поэтому
# проверка 127.0.0.1 всегда падала бы (AGENT.md 10.13).
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
usage() {
cat <<'EOF'
install.sh — установка glchat (AGENT.md 10).
Основное:
--domain <domain> домен инстанса (обязателен)
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
--admin <email> логин инстанс-администратора
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
--ignore-ip <ip|cidr> адрес, который fail2ban никогда не банит
(можно указывать несколько раз; локальные сети
и WireGuard уже включены по умолчанию)
--rotate-secrets сменить секреты (MASTER_KEY, SESSION_PEPPER и др.):
ломает существующие данные, нужен чистый инстанс
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и
резервные коды (по умолчанию включается вручную:
glchat totp-setup --email <admin>)
--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.
На релее поднимите user-quota: одному участнику
нужно два allocation, а TURN-пользователь у
инстанса один (см. docs/DECISIONS.md)
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
--turn-relay-tls-host <host> имя хоста для TURN/TLS (по умолчанию turn.<домен>):
должно совпадать с сертификатом релея
--tls-terminated-by-proxy TLS терминирует внешний прокси: свои 80/443 не занимаем,
но ссылки в API отдаются как https/wss
--app-bind-addr <addr> адрес app (по умолчанию 127.0.0.1); для прокси по
WireGuard укажите адрес в туннеле, например 10.77.0.13
--allow-port <порт/прото> разрешить порт только из подсети --allow-from
(можно указывать несколько раз), например 8080/tcp
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
--livekit-bind-addr <addr> адрес публикации LiveKit WS (по умолчанию 127.0.0.1)
--livekit-media-bind-addr <addr> адрес публикации медиапортов LiveKit TCP/UDP
(по умолчанию — как у WS; отдельный адрес нужен,
когда прокси стоит на том же хосте: WS публикуется
только для прокси, медиа — для клиентов из интернета)
--turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH
--turn-cert-remote-crt <путь> путь к сертификату на внешнем узле
--turn-cert-remote-key <путь> путь к ключу на внешнем узле
--turn-cert-ssh-key <файл> приватный SSH-ключ для загрузки сертификата
--turn-cert-ssh-port <порт> SSH-порт внешнего узла (по умолчанию 22)
--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 ;;
--admin-2fa) ENABLE_ADMIN_2FA=1; shift ;;
--rotate-secrets) ROTATE_SECRETS=1; shift ;;
--ignore-ip) FAIL2BAN_IGNOREIP="${FAIL2BAN_IGNOREIP} ${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 ;;
--turn-relay-tls-host) TURN_RELAY_TLS_HOST="${2:?}"; shift 2 ;;
--tls-terminated-by-proxy) TLS_BY_PROXY="true"; shift ;;
--app-bind-addr) APP_BIND_ADDR="${2:?}"; shift 2 ;;
--allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;;
--allow-from) ALLOW_FROM="${2:?}"; shift 2 ;;
--livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;;
--livekit-media-bind-addr) LIVEKIT_MEDIA_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
# Dry-run обязан быть безвредным: если /opt/glchat недоступен для записи и
# путь не задан явно — пишем .env и конфиги во временный каталог.
if [ "$DRY_RUN" = "1" ] && [ "$GLCHAT_ETC_DIR_EXPLICIT" = "0" ] && [ ! -w "$TARGET_DIR" ]; then
local unwritable="$TARGET_DIR"
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_UPDATES_DIR="${TARGET_DIR}/updates"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
fi
}
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"
if [ -n "$TURN_DOMAIN_OVERRIDE" ]; then
TURN_DOMAIN_RESOLVED="$TURN_DOMAIN_OVERRIDE"
elif [ -n "$TURN_RELAY_HOST" ]; then
# Есть внешний релей: встроенный TURN должен анонсироваться по тому же
# публичному адресу, иначе клиенты получат недостижимые кандидаты.
TURN_DOMAIN_RESOLVED="$TURN_RELAY_HOST"
else
TURN_DOMAIN_RESOLVED="turn.${DOMAIN}"
fi
TURN_CERT_DIR="${TARGET_DIR}/certs"
TURN_RELAY_HOST_RESOLVED="${TURN_RELAY_HOST}"
if [ -z "$TURN_RELAY_TLS_HOST" ] && [ -n "$TURN_RELAY_HOST" ]; then
# TURN/TLS анонсируем по имени с сертификатом, если DNS уже настроен.
if getent hosts "turn.${DOMAIN}" >/dev/null 2>&1; then
TURN_RELAY_TLS_HOST="turn.${DOMAIN}"
else
TURN_RELAY_TLS_HOST="$TURN_RELAY_HOST"
fi
fi
[ -n "$TURN_RELAY_TLS_HOST" ] || TURN_RELAY_TLS_HOST="$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_UPDATES_DIR="${GLCHAT_UPDATES_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
# Публичный адрес LiveKit: клиенты ходят через тот же домен по /rtc.
# Вычисляется после TLS_ENABLED, иначе set -u роняет установщик.
if [ -z "${LIVEKIT_PUBLIC_URL:-}" ]; then
if [ "$TLS_ENABLED" = "true" ]; then
LIVEKIT_PUBLIC_URL="wss://${DOMAIN}/rtc"
else
LIVEKIT_PUBLIC_URL="ws://${DOMAIN}/rtc"
fi
fi
GLCHAT_APP_BIND="${APP_BIND_ADDR}"
GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}"
# Пустое значение — прежнее поведение: медиа публикуется там же, где сигналинг.
[ -n "$LIVEKIT_MEDIA_BIND_ADDR" ] || LIVEKIT_MEDIA_BIND_ADDR="$LIVEKIT_BIND_ADDR"
GLCHAT_LIVEKIT_MEDIA_BIND="${LIVEKIT_MEDIA_BIND_ADDR}"
# Порты TURN публикуются только при включённом TURN. С --no-turn встроенного
# TURN в стеке нет: публикация занимала бы порты хоста (3478/udp и диапазон
# релея) и ломала запуск, если их держит чужой сервис — например, отдельный
# coturn на том же VPS (найдено при установке на VPS 27.09.2026), а наружу
# открывались бы порты, за которыми ничего не слушает.
if [ "$TURN_ENABLED" = "true" ]; then
GLCHAT_TURN_PORTS=" # TURN: UDP, TURN/TLS и диапазон релея (медиа через relay).
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp\""
else
GLCHAT_TURN_PORTS=" # TURN выключен (--no-turn): порты 3478/5349 и диапазон релея не публикуются"
fi
# Блок пользовательского контента (files.<домен>) в конфиг прокси попадает
# только когда для него есть отдельный домен. Иначе в Caddyfile оказывались
# два блока с одним и тем же адресом, и Caddy отказывался стартовать
# («ambiguous site definition») — найдено при установке с --no-files-subdomain
# на VPS 27.09.2026. Адрес апстрима зависит от режима: во встроенном прокси
# это имя контейнера, во внешнем — адрес привязки приложения.
if [ "$PROXY_MODE" = "embedded" ]; then
files_upstream="app:8080"
else
files_upstream="${APP_BIND_ADDR}:${APP_PORT}"
fi
if [ "$FILES_DOMAIN_RESOLVED" != "$DOMAIN" ]; then
GLCHAT_FILES_BLOCK="$(printf '# Пользовательский контент — отдельный origin (изоляция cookies/CSP).\n%s {\n\tencode zstd gzip\n\timport glchat_security_headers\n\thandle {\n\t\treverse_proxy %s\n\t}\n}' \
"${FILES_DOMAIN_RESOLVED}" "${files_upstream}")"
if [ "$PROXY_MODE" = "embedded" ]; then
# Во встроенном Caddy сниппет заголовков называется иначе.
GLCHAT_FILES_BLOCK="${GLCHAT_FILES_BLOCK/import glchat_security_headers/import security_headers}"
fi
else
GLCHAT_FILES_BLOCK="# Отдельного домена для пользовательского контента нет
# (--no-files-subdomain): файлы отдаются с основного домена по /files/*."
fi
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
livekit_extra_codecs GLCHAT_TURN_PORTS GLCHAT_FILES_BLOCK \
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND GLCHAT_LIVEKIT_MEDIA_BIND TLS_ENABLED \
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
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' "LiveKit медиа" "${LIVEKIT_MEDIA_BIND_ADDR}:${LIVEKIT_TCP_PORT}/${LIVEKIT_UDP_PORT}" "TCP+UDP для клиентов"
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 2>/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_vapid_key создаёт приватный ключ VAPID (P-256, PKCS#8 DER в base64).
# Публичный ключ приложение выводит из приватного, поэтому в .env хранится
# только приватный. Без openssl ключ не сделать: функция честно возвращает
# ошибку, и Web Push остаётся выключенным.
generate_vapid_key() {
have openssl || return 1
local tmp key
tmp="$(mktemp -d)"
if ! openssl ecparam -name prime256v1 -genkey -noout -out "${tmp}/key.pem" 2>/dev/null; then
rm -rf "$tmp"
return 1
fi
key="$(openssl pkcs8 -topk8 -nocrypt -in "${tmp}/key.pem" -outform DER 2>/dev/null | base64 -w0)"
rm -rf "$tmp"
[ -n "$key" ] || return 1
printf '%s' "$key"
}
generate_secrets() {
info "секреты"
local env_existing=0
[ -f "$GLCHAT_ENV_FILE" ] && env_existing=1
# Секреты переиспользуются при любом повторном запуске: MASTER_KEY
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
# existing_value отдаёт значение необязательной настройки: сначала из
# окружения (оператор задал при запуске), затем из существующего .env —
# чтобы `--reconfigure` её не потерял.
existing_value() {
local key="$1"
local value="${!key:-}"
if [ -z "$value" ] && [ "$env_existing" = "1" ]; then
value="$(env_get "$key" || true)"
fi
# Кавычки из .env снимаем: иначе при перезаписи файла они удваиваются
# (`KEY=""` → `KEY=""""`) и docker compose отказывается читать .env.
printf '%s' "${value//\"/}"
}
secret_or_new() {
local key="$1" generator="${2:-rand_hex}"
local current=""
if [ "$env_existing" = "1" ] && [ "$ROTATE_SECRETS" != "1" ]; then
current="$(env_get "$key" || true)"
fi
if [ -n "$current" ]; then
printf '%s' "$current"
else
"$generator" 32
fi
}
if [ "$ROTATE_SECRETS" = "1" ] && [ "$env_existing" = "1" ]; then
warn "ВНИМАНИЕ: --rotate-secrets меняет MASTER_KEY/SESSION_PEPPER."
warn "Существующие email/PII, blind index и TOTP-секреты станут нечитаемыми:"
warn "нужен чистый инстанс (сначала сделайте бэкап и удалите базу)."
fi
# Существующая база без секретов в .env — почти всегда потерянный .env:
# молча сгенерировать новые ключи значит сделать данные нечитаемыми.
if [ "$ROTATE_SECRETS" != "1" ] && [ -f "${GLCHAT_DATA_DIR}/db/glchat.db" ] \
&& [ -z "$(env_get MASTER_KEY || true)" ]; then
die "найдена база ${GLCHAT_DATA_DIR}/db/glchat.db, но в ${GLCHAT_ENV_FILE} нет MASTER_KEY: восстановите .env из бэкапа (иначе данные не расшифровать) или запустите с --rotate-secrets на чистой базе"
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)"
# VAPID-ключ Web Push (Фаза 7, AGENT.md 7.16): приватный ключ P-256 в
# PKCS#8 DER (base64 одной строкой), публичный приложение выводит само.
# Ключ переиспользуется при повторной установке: его смена отозвала бы все
# подписки браузеров, поэтому она возможна только с --rotate-secrets.
VAPID_PRIVATE_KEY="$(secret_or_new VAPID_PRIVATE_KEY generate_vapid_key || true)"
if [ -z "$VAPID_PRIVATE_KEY" ]; then
warn "VAPID-ключ не сгенерирован: Web Push будет выключен (нужен openssl)"
fi
# Контакт администратора для push-сервисов: значение можно задать заранее,
# иначе берём email администратора или адрес домена.
VAPID_SUBJECT="$(existing_value VAPID_SUBJECT)"
if [ -z "$VAPID_SUBJECT" ]; then
if [ -n "${ADMIN_EMAIL:-}" ]; then
VAPID_SUBJECT="mailto:${ADMIN_EMAIL}"
else
VAPID_SUBJECT="mailto:admin@${DOMAIN}"
fi
fi
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, приложению нужен лишь доступ на чтение.
mkdir -p "${GLCHAT_UPDATES_DIR}"
chmod 755 "${GLCHAT_UPDATES_DIR}"
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership
# Ключ шифрования бэкапов тоже сохраняем: иначе старые бэкапы не прочитать.
# Генерация требует age-keygen, который ставится позже (install_packages),
# поэтому при отсутствии утилиты ключ создаст ensure_age_key.
AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt"
ensure_age_key
}
# ensure_age_key создаёт ключ шифрования бэкапов, если его ещё нет.
ensure_age_key() {
AGE_KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}"
local recipient=""
if [ ! -f "$AGE_KEY_FILE" ]; then
if ! have age-keygen; then
warn "age-keygen не установлен: ключ бэкапов создадим после установки пакетов"
AGE_RECIPIENT=""
return 0
fi
generate_age_key "$AGE_KEY_FILE"
fi
if [ -f "$AGE_KEY_FILE" ]; then
chmod 600 "$AGE_KEY_FILE" 2>/dev/null || true
recipient="$(grep -o 'age1[0-9a-z]*' "$AGE_KEY_FILE" | head -1 || true)"
fi
if [ -z "$recipient" ] && [ -f "$AGE_KEY_FILE" ] && have age-keygen; then
recipient="$(age-keygen -y "$AGE_KEY_FILE" </dev/null 2>/dev/null || true)"
fi
if [ -z "$recipient" ]; then
warn "не удалось определить публичный age-ключ: бэкапы будут без шифрования"
AGE_RECIPIENT=""
return 0
fi
AGE_RECIPIENT="$recipient"
# .env уже записан: обновляем получателя, иначе backup.sh останется без ключа.
if [ "$DRY_RUN" != "1" ] && [ -f "$GLCHAT_ENV_FILE" ]; then
env_set AGE_RECIPIENT "$AGE_RECIPIENT"
fi
ok "age-ключ бэкапов: ${AGE_KEY_FILE}"
}
write_env_file() {
# env_quote оформляет значение для .env: простые значения пишем как есть,
# сложные — в кавычках (docker compose разбирает .env своим парсером).
env_quote() {
local value="$1"
case "$value" in
*[!A-Za-z0-9._~:/@+=-]*) printf '"%s"' "${value//\"/\\\"}" ;;
*) printf '%s' "$value" ;;
esac
}
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_UPDATES_DIR=@GLCHAT_UPDATES_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@
# Адрес публикации медиапортов LiveKit: обычно совпадает с адресом WS, но при
# прокси на том же хосте отличается (WS — только для прокси, медиа — в интернет).
GLCHAT_LIVEKIT_MEDIA_BIND=@GLCHAT_LIVEKIT_MEDIA_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@
# Публичный адрес LiveKit для клиентов (через прокси: /rtc).
LIVEKIT_PUBLIC_URL=@LIVEKIT_PUBLIC_URL@
TURN_RELAY_PORT=@TURN_RELAY_PORT@
TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_PORT@
TURN_RELAY_TLS_HOST=@TURN_RELAY_TLS_HOST@
# --- секреты (генерируются один раз) ---
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@
# --- профиль производительности ---
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@
# --- бэкапы: offsite-выгрузка (опция, AGENT.md 10.8) ---
# Цель: rsync (user@host:/path) или rclone:remote:path; пусто — только локально.
BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
# Ротация на приёмнике (дни, только rclone); пусто — копим без ротации.
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
AGE_RECIPIENT=@AGE_RECIPIENT@
# --- Web Push (Фаза 7) ---
# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен.
VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@
# Контакт администратора для push-сервисов: mailto:… или https://…
VAPID_SUBJECT=@VAPID_SUBJECT@
# --- функции ---
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" <<PAIREOF
DOMAIN=${DOMAIN}
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
# Значения с пробелами — в кавычках: .env подключается через `set -a && . .env`,
# иначе оболочка попытается выполнить имя главного сервера как команду.
INSTANCE_NAME="${INSTANCE_NAME}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME}"
PROFILE=${PROFILE_RESOLVED}
PROXY_MODE=${PROXY_MODE}
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_ENABLED=${TLS_ENABLED}
SKIP_TLS=${SKIP_TLS}
TLS_BY_PROXY=${TLS_BY_PROXY}
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}
CADDY_PROFILE=${CADDY_PROFILE}
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
GLCHAT_APP_BIND=${APP_BIND_ADDR}
GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}
GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}
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_ENABLED=${TURN_ENABLED}
TURN_DOMAIN=${TURN_DOMAIN_RESOLVED}
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_RESOLVED}
TURN_RELAY_PORT=${TURN_RELAY_PORT}
TURN_RELAY_TLS_PORT=${TURN_RELAY_TLS_PORT}
TURN_RELAY_TLS_HOST=${TURN_RELAY_TLS_HOST}
TURN_RELAY_USER=${TURN_RELAY_USER}
TURN_RELAY_PASSWORD=${TURN_RELAY_PASSWORD}
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}
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
# Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке.
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT")
# Offsite-бэкапы: цель и срок хранения задаются вручную после установки.
BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-}
BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-}
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}
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=${PROFILE_SHARE_SOFT}
SHARE_MAX=${PROFILE_SHARE_MAX}
BACKUP_KEEP=${PROFILE_BACKUP_KEEP}
SWAP_SIZE=${PROFILE_SWAP_GB}
REGISTRATION_ENABLED=${REGISTRATION_ENABLED}
ANTI_BOT_ENABLED=${ANTI_BOT_ENABLED:-false}
FAIL2BAN_ENABLED=${ENABLE_FAIL2BAN}
UFW_ENABLED=${ENABLE_UFW}
NFTABLES_ENABLED=${ENABLE_NFTABLES}
SSH_HARDENING=${SSH_HARDENING}
ADMIN_EMAIL=${ADMIN_EMAIL}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
PAIREOF
python3 - "$tmp" "$pairs_file" <<'PYEOF'
import sys
template_path, pairs_path = sys.argv[1], sys.argv[2]
template = open(template_path, encoding="utf-8").read()
values = {}
for line in open(pairs_path, encoding="utf-8"):
line = line.rstrip("\n")
if "=" not in line:
continue
key, value = line.split("=", 1)
values[key] = value
for key, value in values.items():
template = template.replace("@" + key + "@", value)
# В именах плейсхолдеров есть цифры (FAIL2BAN_ENABLED) — их тоже ловим.
leftovers = [t for t in __import__("re").findall(r"@[A-Z0-9_]{2,}@", template)]
open(template_path, "w", encoding="utf-8").write(template)
if leftovers:
print("незаполненные плейсхолдеры: " + ", ".join(sorted(set(leftovers))), file=sys.stderr)
sys.exit(1)
PYEOF
rm -f "$pairs_file"
install -m 600 "$tmp" "$GLCHAT_ENV_FILE"
rm -f "$tmp" "$tmp.bak"
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.
# Используются статические креденшелы 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"
# TURN/TLS — по имени хоста: сертификат выписан на домен, не на IP.
printf ' - host: %s\n port: %s\n protocol: tls\n username: %s\n credential: %s\n' \
"$TURN_RELAY_TLS_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}" "GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_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}" \
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
"LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}" \
"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}" \
"GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}" \
"GLCHAT_TURN_PORTS=${GLCHAT_TURN_PORTS}" \
"TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \
"TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \
"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}" \
"APP_BIND=${APP_BIND_ADDR}" "LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
"GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
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}" "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
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_API_KEY=${LIVEKIT_API_KEY}" \
"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}" "FAIL2BAN_IGNOREIP=${FAIL2BAN_IGNOREIP}"
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() {
if [ "$TURN_ENABLED" != "true" ]; then
# Встроенный TURN выключен (внешний релей): старый таймер обновления
# сертификата больше не нужен и только путает диагностику.
if [ "$DRY_RUN" != "1" ] && systemctl list-unit-files glchat-cert-refresh.timer >/dev/null 2>&1; then
systemctl disable --now glchat-cert-refresh.timer >/dev/null 2>&1 || true
rm -f /etc/systemd/system/glchat-cert-refresh.timer /etc/systemd/system/glchat-cert-refresh.service /usr/local/sbin/glchat-cert-refresh
systemctl daemon-reload >/dev/null 2>&1 || true
info "таймер обновления сертификата TURN отключён (внешний релей)"
fi
return 0
fi
[ -n "$TURN_CERT_SRC" ] || [ -n "$TURN_CERT_REMOTE" ] || 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-хранилища (локально или по SSH) и перезапускает LiveKit
# при изменении.
set -eu
SRC_CRT="${TURN_CERT_SRC}"
SRC_KEY="${TURN_CERT_SRC_KEY}"
REMOTE="${TURN_CERT_REMOTE}"
REMOTE_CRT="${TURN_CERT_REMOTE_CRT}"
REMOTE_KEY="${TURN_CERT_REMOTE_KEY}"
SSH_KEY="${TURN_CERT_SSH_KEY}"
SSH_PORT="${TURN_CERT_SSH_PORT}"
DST_DIR="${TURN_CERT_DIR}"
TMP_CRT="\$(mktemp)"; TMP_KEY="\$(mktemp)"
trap 'rm -f "\$TMP_CRT" "\$TMP_KEY"' EXIT
if [ -n "\$REMOTE" ]; then
ssh_opts="-n -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p \$SSH_PORT"
[ -n "\$SSH_KEY" ] && ssh_opts="\$ssh_opts -i \$SSH_KEY"
# shellcheck disable=SC2086
ssh \$ssh_opts "\$REMOTE" "cat '\$REMOTE_CRT'" >"\$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
# Правила для доступа из доверенной подсети (например, прокси по WireGuard):
# внутренний порт приложения не публикуется, но прокси должен до него дойти.
local spec port proto
for spec in $ALLOW_PORTS; do
port="${spec%%/*}"
proto="${spec##*/}"
[ "$proto" = "$port" ] && proto="tcp"
if [ "$port" = "8080" ] && [ "$APP_BIND_ADDR" = "127.0.0.1" ]; then
continue
fi
ufw allow from "$ALLOW_FROM" to any port "$port" proto "$proto" comment 'glchat-proxy' >/dev/null 2>&1 ||
warn "не удалось добавить правило UFW для ${spec}"
ok "UFW: ${spec} разрешён из ${ALLOW_FROM}"
done
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
GLCHAT_ALLOW_FROM="${ALLOW_FROM}" /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
Environment=GLCHAT_ALLOW_FROM=${ALLOW_FROM}
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)"
}
# restart_livekit_if_config_changed сравнивает хэш livekit.yaml с сохранённым и
# перезапускает контейнер: иначе новые настройки (например вебхуки) не применятся.
restart_livekit_if_config_changed() {
local config="${TARGET_DIR}/livekit/livekit.yaml"
local marker="${TARGET_DIR}/.livekit-config.sha256"
[ -f "$config" ] || return 0
local hash
hash="$(sha256sum "$config" | awk '{print $1}')"
if [ -f "$marker" ] && [ "$(cat "$marker")" = "$hash" ]; then
return 0
fi
info "конфиг LiveKit изменился — перезапускаем сервис"
if (cd "$TARGET_DIR" && docker compose -f compose.yaml restart livekit >/dev/null 2>&1); then
printf '%s' "$hash" >"$marker"
ok "LiveKit перезапущен с новым конфигом"
else
warn "не удалось перезапустить LiveKit: проверьте вручную (glchat restart livekit)"
fi
}
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 "не удалось запустить стек"
# LiveKit читает конфиг только при старте, а compose не видит изменений в
# примонтированном файле: перезапускаем сервис, если конфиг изменился.
restart_livekit_if_config_changed
info "ожидаем готовности приложения"
local ready_url waited=0
ready_url="$(app_check_url "$APP_PORT" "$APP_BIND_ADDR")"
until curl -fsS "$ready_url" >/dev/null 2>&1; do
waited=$((waited + 2))
if [ "$waited" -ge 60 ]; then
warn "приложение не ответило за 60 с ($ready_url) — смотрите: glchat logs app"
break
fi
sleep 2
done
curl -fsS "$ready_url" >/dev/null 2>&1 && ok "приложение готово ($ready_url)" || warn "готовность не подтверждена ($ready_url)"
}
bootstrap_admin() {
info "bootstrap инстанса (админ, главный сервер, роли)"
if [ -z "$ADMIN_EMAIL" ]; then
warn "админ не задан (--admin): bootstrap пропущен — создайте администратора командой glchat bootstrap-admin --email <email>"
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 не выполнился: посмотрите 'docker logs glchat-app' и повторите вручную — glchat bootstrap-admin --email <email>; данные сохранены в .env"
fi
# 2FA обязательна для инстанс-админов (AGENT.md 7.19): секрет и резервные
# коды печатает команда totp-setup. По умолчанию не выполняем её, чтобы
# секреты не оседали в логах установки.
if [ "${ENABLE_ADMIN_2FA:-0}" = "1" ]; then
info "включение 2FA для инстанс-админа"
docker exec glchat-app /usr/local/bin/glchat totp-setup --email "$ADMIN_EMAIL" \
|| warn "не удалось включить 2FA автоматически: выполните glchat totp-setup --email $ADMIN_EMAIL"
else
info "2FA инстанс-админа включается вручную: glchat totp-setup --email $ADMIN_EMAIL"
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