2026-09-19 18:21:02 +03:00
#!/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 } "
2026-09-19 20:22:57 +03:00
TURN_DOMAIN_OVERRIDE = " ${ TURN_DOMAIN_OVERRIDE :- } "
TURN_RELAY_START = " ${ TURN_RELAY_START :- 30000 } "
2026-09-19 20:44:25 +03:00
# 20 портов релея: этого достаточно для ~100 одновременных relay-аллокаций
# (каждая использует пару портов), а docker создаёт приемлемое число правил.
TURN_RELAY_END = " ${ TURN_RELAY_END :- 30019 } "
2026-09-19 20:22:57 +03:00
TURN_CERT_SRC = " ${ TURN_CERT_SRC :- } "
TURN_CERT_SRC_KEY = " ${ TURN_CERT_SRC_KEY :- } "
2026-09-19 20:28:03 +03:00
TURN_RELAY_HOST = " ${ TURN_RELAY_HOST :- } "
TURN_RELAY_PORT = " ${ TURN_RELAY_PORT :- 3478 } "
2026-09-20 20:48:32 +03:00
# Имя хоста для TURN/TLS: сертификат Let's Encrypt выписан на turn.<домен>, и
# браузеры отклоняют TLS-соединение к IP (несовпадение имени). По умолчанию
# берём turn.<домен>; если он не резолвится, остаётся адрес релея.
TURN_RELAY_TLS_HOST = " ${ TURN_RELAY_TLS_HOST :- } "
2026-09-19 20:28:03 +03:00
TURN_RELAY_TLS_PORT = " ${ TURN_RELAY_TLS_PORT :- 5349 } "
TLS_BY_PROXY = " ${ TLS_BY_PROXY :- false } "
2026-09-19 20:28:29 +03:00
APP_BIND_ADDR = " ${ APP_BIND_ADDR :- 127 .0.0.1 } "
2026-09-19 20:48:59 +03:00
ALLOW_PORTS = " ${ ALLOW_PORTS :- } "
ALLOW_FROM = " ${ ALLOW_FROM :- 10 .77.0.0/24 } "
2026-09-19 20:28:29 +03:00
LIVEKIT_BIND_ADDR = " ${ LIVEKIT_BIND_ADDR :- 127 .0.0.1 } "
TURN_CERT_REMOTE = " ${ TURN_CERT_REMOTE :- } "
TURN_CERT_REMOTE_CRT = " ${ TURN_CERT_REMOTE_CRT :- } "
TURN_CERT_REMOTE_KEY = " ${ TURN_CERT_REMOTE_KEY :- } "
TURN_CERT_SSH_KEY = " ${ TURN_CERT_SSH_KEY :- } "
TURN_CERT_SSH_PORT = " ${ TURN_CERT_SSH_PORT :- 22 } "
2026-09-19 18:21:02 +03:00
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 :- } "
2026-09-19 21:53:35 +03:00
ENABLE_ADMIN_2FA = " ${ ENABLE_ADMIN_2FA :- 0 } "
2026-09-19 22:08:47 +03:00
# Явная ротация секретов: по умолчанию секреты существующего .env сохраняются.
ROTATE_SECRETS = " ${ ROTATE_SECRETS :- 0 } "
2026-09-19 23:27:26 +03:00
# Список адресов, которые fail2ban не банит. Локальные сети и WireGuard-туннель
# добавляются всегда: иначе администратор блокирует сам себя при работе в панели.
FAIL2BAN_IGNOREIP = " ${ FAIL2BAN_IGNOREIP :- 192 .168.0.0/16 10.0.0.0/8 172.16.0.0/12 } "
2026-09-19 18:21:02 +03:00
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 } "
2026-09-19 19:34:05 +03:00
SKIP_IMAGE = " ${ SKIP_IMAGE :- 0 } "
2026-09-19 18:21:02 +03:00
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> пароль инстанс-администратора (иначе — интерактивный ввод)
2026-09-19 23:27:26 +03:00
--ignore-ip <ip|cidr> адрес, который fail2ban никогда не банит
(можно указывать несколько раз; локальные сети
и WireGuard уже включены по умолчанию)
2026-09-19 22:08:47 +03:00
--rotate-secrets сменить секреты (MASTER_KEY, SESSION_PEPPER и др.):
ломает существующие данные, нужен чистый инстанс
2026-09-19 21:53:35 +03:00
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и
резервные коды (по умолчанию включается вручную:
glchat totp-setup --email <admin>)
2026-09-19 18:21:02 +03:00
--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)
2026-09-19 20:22:57 +03:00
--turn-domain <domain> домен TURN/TLS (по умолчанию turn.<domain>)
--turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN)
--turn-key <файл> приватный ключ сертификата TURN/TLS
2026-09-19 20:28:03 +03:00
--turn-relay-host <host> внешний TURN-релей с публичным IP (например, VPS):
клиенты получают его в списке turn_servers
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
2026-09-20 20:48:32 +03:00
--turn-relay-tls-host <host> имя хоста для TURN/TLS (по умолчанию turn.<домен>):
должно совпадать с сертификатом релея
2026-09-19 20:28:03 +03:00
--tls-terminated-by-proxy TLS терминирует внешний прокси: свои 80/443 не занимаем,
но ссылки в API отдаются как https/wss
2026-09-19 20:28:29 +03:00
--app-bind-addr <addr> адрес app (по умолчанию 127.0.0.1); для прокси по
WireGuard укажите адрес в туннеле, например 10.77.0.13
2026-09-19 20:48:59 +03:00
--allow-port <порт/прото> разрешить порт только из подсети --allow-from
(можно указывать несколько раз), например 8080/tcp
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
2026-09-19 20:28:29 +03:00
--livekit-bind-addr <addr> адрес LiveKit (по умолчанию 127.0.0.1)
--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)
2026-09-19 18:21:02 +03:00
--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 не выпускать сертификат (для тестов в контейнере без домена)
2026-09-19 19:34:05 +03:00
--skip-image не собирать/не запускать контейнеры (тест установщика в DinD)
2026-09-19 18:21:02 +03:00
--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 ; ;
2026-09-19 21:53:35 +03:00
--admin-2fa) ENABLE_ADMIN_2FA = 1; shift ; ;
2026-09-19 22:08:47 +03:00
--rotate-secrets) ROTATE_SECRETS = 1; shift ; ;
2026-09-19 23:27:26 +03:00
--ignore-ip) FAIL2BAN_IGNOREIP = " ${ FAIL2BAN_IGNOREIP } ${ 2 : ? } " ; shift 2 ; ;
2026-09-19 18:21:02 +03:00
--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 ; ;
2026-09-19 20:22:57 +03:00
--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 ; ;
2026-09-19 20:28:03 +03:00
--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 ; ;
2026-09-20 20:48:32 +03:00
--turn-relay-tls-host) TURN_RELAY_TLS_HOST = " ${ 2 : ? } " ; shift 2 ; ;
2026-09-19 20:28:03 +03:00
--tls-terminated-by-proxy) TLS_BY_PROXY = "true" ; shift ; ;
2026-09-19 20:28:29 +03:00
--app-bind-addr) APP_BIND_ADDR = " ${ 2 : ? } " ; shift 2 ; ;
2026-09-19 20:48:59 +03:00
--allow-port) ALLOW_PORTS = " ${ ALLOW_PORTS } ${ 2 : ? } " ; shift 2 ; ;
--allow-from) ALLOW_FROM = " ${ 2 : ? } " ; shift 2 ; ;
2026-09-19 20:28:29 +03:00
--livekit-bind-addr) LIVEKIT_BIND_ADDR = " ${ 2 : ? } " ; shift 2 ; ;
--turn-cert-remote) TURN_CERT_REMOTE = " ${ 2 : ? } " ; shift 2 ; ;
--turn-cert-remote-crt) TURN_CERT_REMOTE_CRT = " ${ 2 : ? } " ; shift 2 ; ;
--turn-cert-remote-key) TURN_CERT_REMOTE_KEY = " ${ 2 : ? } " ; shift 2 ; ;
--turn-cert-ssh-key) TURN_CERT_SSH_KEY = " ${ 2 : ? } " ; shift 2 ; ;
--turn-cert-ssh-port) TURN_CERT_SSH_PORT = " ${ 2 : ? } " ; shift 2 ; ;
2026-09-19 18:21:02 +03:00
--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 ; ;
2026-09-19 19:34:05 +03:00
--skip-image) SKIP_IMAGE = "1" ; shift ; ;
2026-09-19 18:21:02 +03:00
-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"
2026-09-19 19:26:26 +03:00
if [ ! -r " $conf " ] && [ ! -d /etc/ssh ] ; then
printf '%s' " $port "
return 0
fi
2026-09-19 18:21:02 +03:00
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 "
}
# --- подготовка -----------------------------------------------------------
2026-09-19 19:29:18 +03:00
# 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
2026-09-19 19:30:43 +03:00
# В режиме внешнего прокси собственный 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 "
2026-09-19 21:12:11 +03:00
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
2026-09-19 20:22:57 +03:00
TURN_CERT_DIR = " ${ TARGET_DIR } /certs "
2026-09-19 20:28:03 +03:00
TURN_RELAY_HOST_RESOLVED = " ${ TURN_RELAY_HOST } "
2026-09-20 20:48:32 +03:00
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 "
2026-09-19 20:38:24 +03:00
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) "
2026-09-19 20:22:57 +03:00
2026-09-19 19:30:43 +03:00
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 "
2026-09-19 19:55:31 +03:00
# Набор кодеков зависит от профиля: на 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
2026-09-19 19:35:38 +03:00
GLCHAT_DATA_DIR = " ${ GLCHAT_DATA_DIR } "
GLCHAT_CADDY_PROFILE = " ${ CADDY_PROFILE } "
2026-09-19 20:22:57 +03:00
GLCHAT_TURN_CERT_DIR = " ${ TURN_CERT_DIR } "
2026-09-19 20:32:18 +03:00
# 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
2026-09-20 18:13:04 +03:00
# Публичный адрес 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
2026-09-19 20:28:29 +03:00
GLCHAT_APP_BIND = " ${ APP_BIND_ADDR } "
GLCHAT_LIVEKIT_BIND = " ${ LIVEKIT_BIND_ADDR } "
2026-09-19 19:30:43 +03:00
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
2026-09-19 19:55:31 +03:00
livekit_extra_codecs \
2026-09-19 19:35:38 +03:00
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
2026-09-19 20:22:57 +03:00
GLCHAT_DATA_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
2026-09-19 20:32:18 +03:00
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
2026-09-20 20:48:32 +03:00
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
2026-09-19 20:38:24 +03:00
TURN_RELAY_USER TURN_RELAY_PASSWORD
2026-09-19 19:29:18 +03:00
}
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:22:57 +03:00
# LiveKit требует сертификат для TURN/TLS (проверено на v1.9.7: без него
# сервер не стартует). Либо передаём файлы, либо отключаем TURN.
2026-09-19 20:28:29 +03:00
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"
2026-09-19 20:22:57 +03:00
fi
2026-09-19 18:21:02 +03:00
}
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
2026-09-19 20:29:18 +03:00
elif [ " $RECONFIGURE " != "1" ] ; then
warn " порт ${ p } занят — проверьте, не запущен ли уже glchat (для переустановки используйте --reconfigure) "
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:29:18 +03:00
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 } " "внутренний"
2026-09-19 18:21:02 +03:00
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'
}
2026-09-19 19:39:47 +03:00
# 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
}
2026-09-19 18:21:02 +03:00
# --- установка пакетов ----------------------------------------------------
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) "
}
# --- секреты и конфиги ----------------------------------------------------
2026-09-19 20:30:23 +03:00
# generate_age_key создаёт файл ключа age (X25519) без вызова age-keygen -o:
# age-keygen без терминала читает stdin и при запуске через `sudo -S`
# высасывает пайп с паролем, из-за чего установщик зависает.
generate_age_key( ) {
local dst = " $1 "
local raw pub
2026-09-19 20:38:46 +03:00
raw = " $( openssl rand 32 | od -An -tx1 | tr -d '[:space:]' ) "
2026-09-19 20:30:23 +03:00
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
}
2026-09-19 18:21:02 +03:00
generate_secrets( ) {
info "секреты"
local env_existing = 0
[ -f " $GLCHAT_ENV_FILE " ] && env_existing = 1
2026-09-19 22:08:47 +03:00
# Секреты переиспользуются при любом повторном запуске: MASTER_KEY
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
2026-09-19 18:21:02 +03:00
secret_or_new( ) {
local key = " $1 " generator = " ${ 2 :- rand_hex } "
local current = ""
2026-09-19 22:08:47 +03:00
if [ " $env_existing " = "1" ] && [ " $ROTATE_SECRETS " != "1" ] ; then
2026-09-19 18:21:02 +03:00
current = " $( env_get " $key " || true ) "
fi
if [ -n " $current " ] ; then
printf '%s' " $current "
else
" $generator " 32
fi
}
2026-09-19 22:08:47 +03:00
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
2026-09-19 18:21:02 +03:00
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 "
2026-09-19 19:39:47 +03:00
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership
2026-09-19 18:21:02 +03:00
2026-09-19 22:08:47 +03:00
# Ключ шифрования бэкапов тоже сохраняем: иначе старые бэкапы не прочитать.
2026-09-19 22:11:31 +03:00
# Генерация требует age-keygen, который ставится позже (install_packages),
# поэтому при отсутствии утилиты ключ создаст ensure_age_key.
2026-09-19 22:08:47 +03:00
AGE_KEY_FILE = " ${ GLCHAT_DATA_DIR } /backups/age-key.txt "
2026-09-19 22:11:31 +03:00
ensure_age_key
}
# ensure_age_key создаёт ключ шифрования бэкапов, если его ещё нет.
ensure_age_key( ) {
AGE_KEY_FILE = " ${ AGE_KEY_FILE :- ${ GLCHAT_DATA_DIR } /backups/age-key.txt } "
local recipient = ""
2026-09-19 22:08:47 +03:00
if [ ! -f " $AGE_KEY_FILE " ] ; then
2026-09-19 22:11:31 +03:00
if ! have age-keygen; then
warn "age-keygen не установлен: ключ бэкапов создадим после установки пакетов"
AGE_RECIPIENT = ""
return 0
fi
2026-09-19 20:30:23 +03:00
generate_age_key " $AGE_KEY_FILE "
2026-09-19 18:21:02 +03:00
fi
2026-09-19 22:11:31 +03:00
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 ) "
2026-09-19 20:30:23 +03:00
fi
2026-09-19 22:11:31 +03:00
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
2026-09-19 20:30:23 +03:00
warn "не удалось определить публичный age-ключ: бэкапы будут без шифрования"
2026-09-19 22:11:31 +03:00
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 "
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:31:54 +03:00
# Значения подставляются через sed, а не через расширение heredoc: иначе
# shell выполняет условия и подстановки прямо в тексте файла (уже ломало .env).
2026-09-19 18:21:02 +03:00
local tmp
tmp = " $( mktemp) "
2026-09-19 20:31:54 +03:00
cat >" $tmp " <<'ENVEOF'
2026-09-19 18:21:02 +03:00
# glchat — окружение инстанса. Сгенерировано deploy/install.sh.
# Секреты: не коммитить, не публиковать, не пересылать. Права: 600.
# --- инстанс ---
2026-09-19 20:31:54 +03:00
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@
2026-09-19 18:21:02 +03:00
2026-09-19 19:58:32 +03:00
# --- TLS ---
2026-09-19 20:31:54 +03:00
TLS_ENABLED=@TLS_ENABLED@
SKIP_TLS=@SKIP_TLS@
TLS_BY_PROXY=@TLS_BY_PROXY@
# --- интерполяция compose (пути, адреса привязки, профиль) ---
GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@
GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@
2026-09-19 19:35:38 +03:00
2026-09-19 18:21:02 +03:00
# --- порты ---
2026-09-19 20:31:54 +03:00
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@
2026-09-20 18:04:59 +03:00
# Публичный адрес LiveKit для клиентов (через прокси: /rtc).
LIVEKIT_PUBLIC_URL=@LIVEKIT_PUBLIC_URL@
2026-09-19 20:31:54 +03:00
TURN_RELAY_PORT=@TURN_RELAY_PORT@
TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_PORT@
2026-09-20 20:48:32 +03:00
TURN_RELAY_TLS_HOST=@TURN_RELAY_TLS_HOST@
2026-09-19 18:21:02 +03:00
# --- секреты (генерируются один раз) ---
2026-09-19 20:31:54 +03:00
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@
2026-09-19 20:38:24 +03:00
TURN_RELAY_USER=@TURN_RELAY_USER@
TURN_RELAY_PASSWORD=@TURN_RELAY_PASSWORD@
2026-09-19 20:31:54 +03:00
AGE_RECIPIENT=@AGE_RECIPIENT@
2026-09-19 18:21:02 +03:00
# --- профиль производительности ---
2026-09-19 20:31:54 +03:00
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@
2026-09-19 18:21:02 +03:00
CADDY_CPUS=1.0
CADDY_MEMORY_LIMIT=256m
2026-09-19 20:31:54 +03:00
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@
2026-09-19 18:21:02 +03:00
# --- функции ---
2026-09-19 20:31:54 +03:00
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@
2026-09-19 18:21:02 +03:00
# --- первичный администратор (используется один раз при bootstrap) ---
2026-09-19 20:31:54 +03:00
ADMIN_EMAIL=@ADMIN_EMAIL@
ADMIN_PASSWORD=@ADMIN_PASSWORD@
ENVEOF
2026-09-19 20:32:37 +03:00
# Подстановка через 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}
2026-09-21 23:33:04 +03:00
# Значения с пробелами — в кавычках: .env подключается через `set -a && . .env`,
# иначе оболочка попытается выполнить имя главного сервера как команду.
INSTANCE_NAME="${INSTANCE_NAME}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME}"
2026-09-19 20:32:37 +03:00
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}
CADDY_PROFILE=${CADDY_PROFILE}
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
GLCHAT_APP_BIND=${APP_BIND_ADDR}
GLCHAT_LIVEKIT_BIND=${LIVEKIT_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}
2026-09-20 20:48:32 +03:00
TURN_RELAY_TLS_HOST=${TURN_RELAY_TLS_HOST}
2026-09-19 20:39:08 +03:00
TURN_RELAY_USER=${TURN_RELAY_USER}
TURN_RELAY_PASSWORD=${TURN_RELAY_PASSWORD}
2026-09-19 20:32:37 +03:00
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}
2026-09-20 18:04:59 +03:00
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
2026-09-19 20:38:46 +03:00
2026-09-19 20:32:37 +03:00
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"):
2026-09-19 20:32:55 +03:00
line = line.rstrip("\n")
2026-09-19 20:32:37 +03:00
if "=" not in line:
continue
key, value = line.split("=", 1)
values[key] = value
for key, value in values.items():
template = template.replace("@" + key + "@", value)
leftovers = [t for t in __import__("re").findall(r"@[A-Z_]{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 "
2026-09-19 20:31:54 +03:00
2026-09-19 18:21:02 +03:00
install -m 600 " $tmp " " $GLCHAT_ENV_FILE "
2026-09-19 20:31:54 +03:00
rm -f " $tmp " " $tmp .bak "
2026-09-19 18:21:02 +03:00
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( ) {
2026-09-19 20:23:43 +03:00
local want_gb = " $1 " current_mb active_file
want_mb = $(( want_gb * 1024 ))
2026-09-19 18:21:02 +03:00
current_mb = " $( awk '/SwapTotal/ {printf "%d", $2/1024}' /proc/meminfo) "
2026-09-19 20:23:43 +03:00
# Достаточно активного swap (в том числе созданного провайдером или
# cloud-init — файл может называться иначе, чем /swapfile).
if [ " $current_mb " -ge $(( want_mb - 256 )) ] ; then
2026-09-19 18:21:02 +03:00
ok " swap уже есть: ${ current_mb } MB "
return 0
fi
2026-09-19 20:23:43 +03:00
# Файл /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
2026-09-19 18:21:02 +03:00
if ! have fallocate && ! have dd; then
warn "не удалось создать swap: нет fallocate/dd"
return 0
fi
info " создаём swap ${ want_gb } GB "
2026-09-19 20:23:43 +03:00
rm -f /swapfile 2>/dev/null || true
2026-09-19 18:21:02 +03:00
if have fallocate; then
2026-09-19 20:23:43 +03:00
fallocate -l " ${ want_gb } G " /swapfile 2>/dev/null ||
dd if = /dev/zero of = /swapfile bs = 1M count = " $want_mb " status = none
2026-09-19 18:21:02 +03:00
else
2026-09-19 20:23:43 +03:00
dd if = /dev/zero of = /swapfile bs = 1M count = " $want_mb " status = none
2026-09-19 18:21:02 +03:00
fi
chmod 600 /swapfile
mkswap /swapfile >/dev/null
2026-09-19 20:23:43 +03:00
swapon /swapfile || {
warn "не удалось активировать swap"
return 0
}
if ! grep -qE '^/swapfile[[:space:]]' /etc/fstab; then
2026-09-19 18:21:02 +03:00
printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
fi
ok " swap ${ want_gb } GB активирован "
}
2026-09-19 19:26:26 +03:00
2026-09-19 20:28:03 +03:00
# render_turn_relay_servers формирует YAML-список turn_servers для LiveKit:
# UDP и TLS-эндпоинты внешнего релея (VPS), у которого есть публичный IP.
2026-09-19 20:38:46 +03:00
# Используются статические креденшелы coturn (username/credential).
2026-09-19 20:28:03 +03:00
render_turn_relay_servers( ) {
[ -n " $TURN_RELAY_HOST " ] || return 0
printf ' turn_servers:\n'
2026-09-19 20:38:24 +03:00
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 "
2026-09-20 20:48:32 +03:00
# TURN/TLS — по имени хоста: сертификат выписан на домен, не на IP.
2026-09-19 20:38:24 +03:00
printf ' - host: %s\n port: %s\n protocol: tls\n username: %s\n credential: %s\n' \
2026-09-20 20:48:32 +03:00
" $TURN_RELAY_TLS_HOST " " $TURN_RELAY_TLS_PORT " " $TURN_RELAY_USER " " $TURN_RELAY_PASSWORD "
2026-09-19 20:28:03 +03:00
}
2026-09-19 20:22:57 +03:00
# install_turn_certificate копирует сертификат TURN/TLS туда, где его читает
# контейнер LiveKit. Продление выполняет glchat-cert-refresh.timer.
2026-09-19 20:28:29 +03:00
fetch_remote_turn_certificate( ) {
[ -n " $TURN_CERT_REMOTE " ] || return 1
2026-09-19 20:29:39 +03:00
local ssh_opts = ( -n -o BatchMode = yes -o StrictHostKeyChecking = accept-new -p " $TURN_CERT_SSH_PORT " )
2026-09-19 20:28:29 +03:00
[ -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
}
2026-09-19 20:22:57 +03:00
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 "
2026-09-19 20:28:29 +03:00
if [ -n " $TURN_CERT_REMOTE " ] ; then
fetch_remote_turn_certificate && ok " сертификат TURN/TLS загружен с ${ TURN_CERT_REMOTE } " ||
warn "сертификат TURN/TLS не загружен: TURN не запустится"
return 0
fi
2026-09-19 20:22:57 +03:00
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
}
2026-09-19 18:21:02 +03:00
render_all_templates( ) {
info " генерируем конфиги в ${ TARGET_DIR } "
2026-09-19 19:26:26 +03:00
mkdir -p " ${ TARGET_DIR } " /{ caddy/logs,caddy/data,caddy/config,livekit,fail2ban,nftables}
2026-09-19 18:21:02 +03:00
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
2026-09-19 19:26:26 +03:00
# Вариант compose выбирается по режиму: в тестовом прогоне (--skip-tls)
# порты LiveKit не публикуются, чтобы не занимать порты хост-машины.
2026-09-19 19:29:18 +03:00
compose_template = " ${ DEPLOY_DIR } /compose.yaml.tpl "
2026-09-19 19:26:26 +03:00
if [ " $LIVEKIT_PUBLISH " != "true" ] ; then
compose_template = " ${ DEPLOY_DIR } /compose.test.yaml.tpl "
2026-09-19 18:21:02 +03:00
fi
2026-09-19 19:26:26 +03:00
render_template " ${ compose_template } " " ${ TARGET_DIR } /compose.yaml " \
2026-09-19 18:21:02 +03:00
" DOMAIN= ${ DOMAIN } " " FILES_DOMAIN= ${ FILES_DOMAIN_RESOLVED } " " INSTANCE_NAME= ${ INSTANCE_NAME } " \
2026-09-19 19:29:18 +03:00
" PROFILE= ${ PROFILE_RESOLVED } " " APP_IMAGE= ${ APP_IMAGE } " \
2026-09-19 18:21:02 +03:00
" APP_PORT= ${ APP_PORT } " " LIVEKIT_WS_PORT= ${ LIVEKIT_WS_PORT } " \
" LIVEKIT_TCP_PORT= ${ LIVEKIT_TCP_PORT } " " LIVEKIT_UDP_PORT= ${ LIVEKIT_UDP_PORT } " \
2026-09-19 19:35:38 +03:00
" GLCHAT_DATA_DIR= ${ GLCHAT_DATA_DIR } " " LOG_LEVEL= ${ LOG_LEVEL } " \
2026-09-19 18:21:02 +03:00
" 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 } " \
2026-09-20 18:19:19 +03:00
" LIVEKIT_API_KEY= ${ LIVEKIT_API_KEY } " " LIVEKIT_API_SECRET= ${ LIVEKIT_API_SECRET } " \
" LIVEKIT_PUBLIC_URL= ${ LIVEKIT_PUBLIC_URL } " \
2026-09-19 18:21:02 +03:00
"CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \
" CADDY_IMAGE_TAG= ${ CADDY_IMAGE_TAG } " " LIVEKIT_IMAGE_TAG= ${ LIVEKIT_IMAGE_TAG } " \
2026-09-19 19:35:38 +03:00
" GLCHAT_CADDY_PROFILE= ${ CADDY_PROFILE } " \
2026-09-19 20:22:57 +03:00
" GLCHAT_TURN_CERT_DIR= ${ TURN_CERT_DIR } " \
2026-09-19 20:28:29 +03:00
" GLCHAT_APP_BIND= ${ APP_BIND_ADDR } " " GLCHAT_LIVEKIT_BIND= ${ LIVEKIT_BIND_ADDR } " \
2026-09-19 20:41:37 +03:00
" TURN_UDP_PORT= ${ TURN_UDP_PORT } " " TURN_TLS_PORT= ${ TURN_TLS_PORT } " \
" TURN_RELAY_START= ${ TURN_RELAY_START } " " TURN_RELAY_END= ${ TURN_RELAY_END } " \
2026-09-19 18:21:02 +03:00
" 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 " \
2026-09-19 19:26:26 +03:00
" LIVEKIT_WS_PORT= ${ LIVEKIT_WS_PORT } " \
" LIVEKIT_TCP_PORT= ${ LIVEKIT_TCP_PORT } " \
" LIVEKIT_UDP_PORT= ${ LIVEKIT_UDP_PORT } " \
2026-09-19 18:21:02 +03:00
" LIVEKIT_USE_EXTERNAL_IP= ${ use_external_ip } " \
2026-09-19 19:26:26 +03:00
" TURN_ENABLED= ${ TURN_ENABLED } " \
2026-09-19 20:22:57 +03:00
" 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 '' ) " \
2026-09-19 20:28:03 +03:00
" TURN_RELAY_SERVERS= $( render_turn_relay_servers) " \
2026-09-19 19:26:26 +03:00
" TURN_UDP_PORT= ${ TURN_UDP_PORT } " \
" TURN_TLS_PORT= ${ TURN_TLS_PORT } " \
" TURN_EXTERNAL_TLS= ${ turn_external_tls } " \
2026-09-19 18:21:02 +03:00
" LIVEKIT_MAX_PARTICIPANTS= ${ PROFILE_LIVEKIT_MAX_PARTICIPANTS } " \
2026-09-19 19:55:31 +03:00
" LIVEKIT_EXTRA_CODECS= ${ livekit_extra_codecs } " \
2026-09-20 18:32:18 +03:00
" LIVEKIT_API_KEY= ${ LIVEKIT_API_KEY } " \
2026-09-19 18:21:02 +03:00
" LIVEKIT_DISABLE_SIMULCAST= ${ disable_simulcast } "
if [ " $ENABLE_FAIL2BAN " = "true" ] ; then
render_template " ${ DEPLOY_DIR } /fail2ban/jail.local.tpl " " ${ TARGET_DIR } /fail2ban/jail.local " \
2026-09-19 23:27:26 +03:00
" SSH_PORT= ${ SSH_PORT } " " FAIL2BAN_IGNOREIP= ${ FAIL2BAN_IGNOREIP } "
2026-09-19 18:21:02 +03:00
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
}
2026-09-19 20:22:57 +03:00
# install_cert_refresh копирует обновлённый сертификат TURN и перезапускает LiveKit.
install_cert_refresh( ) {
2026-09-20 20:52:41 +03:00
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
2026-09-19 20:28:29 +03:00
[ -n " $TURN_CERT_SRC " ] || [ -n " $TURN_CERT_REMOTE " ] || return 0
2026-09-19 20:22:57 +03:00
[ " $DRY_RUN " = "1" ] && return 0
cat >/usr/local/sbin/glchat-cert-refresh <<EOF
#!/usr/bin/env bash
# Сгенерировано deploy/install.sh: копирует свежий сертификат TURN/TLS
2026-09-19 20:28:29 +03:00
# из внешнего ACME-хранилища (локально или по SSH) и перезапускает LiveKit
# при изменении.
2026-09-19 20:22:57 +03:00
set -eu
SRC_CRT="${TURN_CERT_SRC}"
SRC_KEY="${TURN_CERT_SRC_KEY}"
2026-09-19 20:28:29 +03:00
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}"
2026-09-19 20:22:57 +03:00
DST_DIR="${TURN_CERT_DIR}"
2026-09-19 20:28:29 +03:00
TMP_CRT="\$(mktemp)"; TMP_KEY="\$(mktemp)"
trap 'rm -f "\$TMP_CRT" "\$TMP_KEY"' EXIT
if [ -n "\$REMOTE" ]; then
2026-09-19 20:29:39 +03:00
ssh_opts="-n -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p \$SSH_PORT"
2026-09-19 20:28:29 +03:00
[ -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"
2026-09-19 20:22:57 +03:00
fi
2026-09-19 20:28:29 +03:00
SRC_CRT="\$TMP_CRT"; SRC_KEY="\$TMP_KEY"
2026-09-19 20:22:57 +03:00
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, ежедневно)"
}
2026-09-19 18:21:02 +03:00
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)"
}
# --- безопасность ---------------------------------------------------------
2026-09-19 20:23:18 +03:00
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
}
2026-09-19 18:21:02 +03:00
configure_ufw( ) {
[ " $ENABLE_UFW " = "true" ] || {
warn "UFW не настраиваем (--no-ufw)"
return 0
}
info "настраиваем UFW"
if [ " $DRY_RUN " = "1" ] ; then
2026-09-19 20:22:57 +03:00
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 "
2026-09-19 18:21:02 +03:00
return 0
fi
2026-09-19 20:23:18 +03:00
snapshot_foreign_ufw_rules
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:22:57 +03:00
# Relay-диапазон: через эти порты TURN отдаёт медиа (AGENT.md 4.2).
ufw allow " ${ TURN_RELAY_START } : ${ TURN_RELAY_END } /udp " comment 'turn-relay' >/dev/null
2026-09-19 18:21:02 +03:00
fi
2026-09-19 20:48:59 +03:00
# Правила для доступа из доверенной подсети (например, прокси по 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
2026-09-19 18:21:02 +03:00
ufw --force enable >/dev/null
2026-09-19 20:23:18 +03:00
restore_foreign_ufw_rules
2026-09-19 18:21:02 +03:00
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
2026-09-19 19:32:53 +03:00
# Правила применяет отдельный скрипт: его же вызывает systemd-юнит
# при загрузке, поэтому логика не дублируется в установщике.
2026-09-19 18:21:02 +03:00
install -m 0755 " ${ TARGET_DIR } /ufw-docker.sh " /usr/local/sbin/glchat-docker-user
2026-09-19 20:51:50 +03:00
GLCHAT_ALLOW_FROM = " ${ ALLOW_FROM } " /usr/local/sbin/glchat-docker-user ||
warn "не удалось применить часть правил DOCKER-USER"
cat >/etc/systemd/system/glchat-docker-user.service <<EOF
2026-09-19 18:21:02 +03:00
[Unit]
Description=glchat: правила DOCKER-USER (не публиковать внутренние порты)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
2026-09-19 20:51:50 +03:00
Environment=GLCHAT_ALLOW_FROM=${ALLOW_FROM}
2026-09-19 18:21:02 +03:00
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( ) {
2026-09-19 19:34:05 +03:00
if [ " $SKIP_IMAGE " = "1" ] ; then
warn "сборка и запуск контейнеров пропущены (--skip-image)"
return 0
fi
2026-09-19 18:21:02 +03:00
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) " \
2026-09-19 19:26:26 +03:00
--build-arg " GOPROXY= ${ GOPROXY :- https : //proxy.golang.org,direct } " \
--build-arg " GOSUMDB= ${ GOSUMDB :- sum .golang.org } " \
2026-09-19 18:21:02 +03:00
" $REPO_DIR " || die "сборка образа не удалась"
ok " образ собран: $( docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' " $APP_IMAGE " | head -1) "
}
2026-09-20 18:38:54 +03:00
# 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
}
2026-09-19 18:21:02 +03:00
start_stack( ) {
2026-09-19 19:34:05 +03:00
if [ " $SKIP_IMAGE " = "1" ] ; then
return 0
fi
2026-09-19 18:21:02 +03:00
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 "не удалось запустить стек"
2026-09-20 18:38:54 +03:00
# LiveKit читает конфиг только при старте, а compose не видит изменений в
# примонтированном файле: перезапускаем сервис, если конфиг изменился.
restart_livekit_if_config_changed
2026-09-19 18:21:02 +03:00
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
2026-09-19 21:53:35 +03:00
# 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
2026-09-19 18:21:02 +03:00
}
run_smoke( ) {
2026-09-19 19:34:05 +03:00
if [ " $SKIP_IMAGE " = "1" ] ; then
info "smoke приложения пропущен (--skip-image): проверка конфигов выполнена"
return 0
fi
2026-09-19 18:21:02 +03:00
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
2026-09-19 19:26:26 +03:00
printf " * Сертификат Let's Encrypt выпускается автоматически при первом запросе.\n"
2026-09-19 18:21:02 +03:00
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
2026-09-19 19:26:26 +03:00
PROFILE_APP_CPUS = " $( detect_cpus) "
2026-09-19 18:21:02 +03:00
PROFILE_LIVEKIT_CPUS = "1.0"
PROFILE_SWAPPINESS = 10
2026-09-19 19:30:43 +03:00
# Производные значения зависят от профиля (simulcast) и режима прокси,
# поэтому считаются после profile_values и до записи .env и шаблонов.
resolve_derived_values
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:22:57 +03:00
install_turn_certificate
2026-09-19 18:21:02 +03:00
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
2026-09-19 20:22:57 +03:00
install_cert_refresh
2026-09-19 18:21:02 +03:00
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