8814fe7cf4
Версия — в одном месте: файл VERSION в корне, из него её берут Makefile, deploy/install.sh (IMAGE_TAG) и desktop-обёртка (tauri.conf.json, Cargo.toml). Публикация обновлений (scripts/desktop-release.sh): - манифесты прошлых релизов удаляются: оставшийся <старая версия>.json перекрывал новый релиз — обёртка спрашивает манифест своей версии и видела «обновлений нет»; - mktemp-каталог раскладки получает 755, каталоги на инстансе нормализуются: rsync -a переносил режим 0700 на корень каталога обновлений, и приложение не могло читать манифесты (404 при живых файлах); - --remote работает без TTY и без sudo, если каталог обновлений записываем; опции ssh — в GLCHAT_SSH_OPTS; - предупреждение при публикации версии старше уже опубликованной. Сервер: HEAD /updates/... (chi не выводит его из GET) — размер и тип артефакта без скачивания; тест TestUpdatesAnswersHead. Приёмка на живом канале: 0.1.0 → 0.1.1 на стенде (скачивание, проверка подписи, установка, перезапуск), /Applications/glchat.app = 0.1.1.
1890 lines
90 KiB
Bash
Executable File
1890 lines
90 KiB
Bash
Executable File
#!/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}"
|
|
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 (по умолчанию 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)
|
|
--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 ;;
|
|
--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}"
|
|
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
|
|
livekit_extra_codecs \
|
|
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
|
|
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
|
|
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
|
|
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_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' "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.
|
|
# oauth_value отдаёт значение OAuth-настройки: сначала из окружения
|
|
# (оператор задал при запуске), затем из существующего .env.
|
|
oauth_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="$(oauth_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
|
|
|
|
# OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер.
|
|
# Значение берём из окружения, иначе сохраняем прежнее из .env — иначе
|
|
# `--reconfigure` молча выключил бы вход через провайдеров.
|
|
OAUTH_GITHUB_CLIENT_ID="$(oauth_value OAUTH_GITHUB_CLIENT_ID)"
|
|
OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)"
|
|
OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)"
|
|
OAUTH_GOOGLE_CLIENT_SECRET="$(oauth_value OAUTH_GOOGLE_CLIENT_SECRET)"
|
|
OAUTH_DISCORD_CLIENT_ID="$(oauth_value OAUTH_DISCORD_CLIENT_ID)"
|
|
OAUTH_DISCORD_CLIENT_SECRET="$(oauth_value OAUTH_DISCORD_CLIENT_SECRET)"
|
|
OAUTH_REDIRECT_BASE="$(oauth_value OAUTH_REDIRECT_BASE)"
|
|
|
|
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@
|
|
|
|
# --- порты ---
|
|
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@
|
|
|
|
# --- OAuth-провайдеры (Фаза 7) ---
|
|
# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth/<id>/callback.
|
|
OAUTH_GITHUB_CLIENT_ID=@OAUTH_GITHUB_CLIENT_ID@
|
|
OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@
|
|
OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@
|
|
OAUTH_GOOGLE_CLIENT_SECRET=@OAUTH_GOOGLE_CLIENT_SECRET@
|
|
OAUTH_DISCORD_CLIENT_ID=@OAUTH_DISCORD_CLIENT_ID@
|
|
OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@
|
|
# Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена).
|
|
OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@
|
|
|
|
# --- 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}
|
|
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}
|
|
|
|
# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки добавляет
|
|
# установщик только для значений со спецсимволами (docker compose разбирает
|
|
# .env своим парсером и пустые кавычки ему не нравятся).
|
|
OAUTH_GITHUB_CLIENT_ID=$(env_quote "$OAUTH_GITHUB_CLIENT_ID")
|
|
OAUTH_GITHUB_CLIENT_SECRET=$(env_quote "$OAUTH_GITHUB_CLIENT_SECRET")
|
|
OAUTH_GOOGLE_CLIENT_ID=$(env_quote "$OAUTH_GOOGLE_CLIENT_ID")
|
|
OAUTH_GOOGLE_CLIENT_SECRET=$(env_quote "$OAUTH_GOOGLE_CLIENT_SECRET")
|
|
OAUTH_DISCORD_CLIENT_ID=$(env_quote "$OAUTH_DISCORD_CLIENT_ID")
|
|
OAUTH_DISCORD_CLIENT_SECRET=$(env_quote "$OAUTH_DISCORD_CLIENT_SECRET")
|
|
OAUTH_REDIRECT_BASE=$(env_quote "$OAUTH_REDIRECT_BASE")
|
|
|
|
# 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}" \
|
|
"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}"
|
|
else
|
|
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
|
|
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
|
|
"PROXY_MODE=${PROXY_MODE}"
|
|
fi
|
|
|
|
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
|
|
"LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
|
|
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" \
|
|
"LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \
|
|
"LIVEKIT_USE_EXTERNAL_IP=${use_external_ip}" \
|
|
"TURN_ENABLED=${TURN_ENABLED}" \
|
|
"TURN_DOMAIN=${TURN_DOMAIN_RESOLVED}" \
|
|
"TURN_RELAY_START=${TURN_RELAY_START}" \
|
|
"TURN_RELAY_END=${TURN_RELAY_END}" \
|
|
"TURN_CERT_FILE=$([ "$TURN_ENABLED" = "true" ] && echo /etc/certs/turn.crt || echo '')" \
|
|
"TURN_KEY_FILE=$([ "$TURN_ENABLED" = "true" ] && echo /etc/certs/turn.key || echo '')" \
|
|
"TURN_RELAY_SERVERS=$(render_turn_relay_servers)" \
|
|
"TURN_UDP_PORT=${TURN_UDP_PORT}" \
|
|
"TURN_TLS_PORT=${TURN_TLS_PORT}" \
|
|
"TURN_EXTERNAL_TLS=${turn_external_tls}" \
|
|
"LIVEKIT_MAX_PARTICIPANTS=${PROFILE_LIVEKIT_MAX_PARTICIPANTS}" \
|
|
"LIVEKIT_EXTRA_CODECS=${livekit_extra_codecs}" \
|
|
"LIVEKIT_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
|