#!/usr/bin/env bash # glchat: smoke-тест после установки (AGENT.md 10.9). # Фаза 0 проверяет фундамент: HTTPS-заглушка, meta, healthy, порты, fail2ban. set -uo pipefail GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" ENV_FILE="${GLCHAT_ETC_DIR}/.env" PASSED=0 FAILED=0 SKIPPED=0 pass() { printf ' \033[32m✓\033[0m %s\n' "$*" PASSED=$((PASSED + 1)) } fail() { printf ' \033[31m✗\033[0m %s\n' "$*" FAILED=$((FAILED + 1)) } skip() { printf ' \033[33m–\033[0m %s\n' "$*" SKIPPED=$((SKIPPED + 1)) } if [ -f "$ENV_FILE" ]; then # shellcheck disable=SC1090 set -a && . "$ENV_FILE" && set +a fi DOMAIN="${DOMAIN:-}" APP_PORT="${APP_PORT:-8080}" APP_BIND="${GLCHAT_APP_BIND:-127.0.0.1}" case "$APP_BIND" in 0.0.0.0 | "") APP_BIND="127.0.0.1" ;; esac APP_URL="http://${APP_BIND}:${APP_PORT}" PROXY_MODE="${PROXY_MODE:-embedded}" LIVEKIT_TCP_PORT="${LIVEKIT_TCP_PORT:-7881}" LIVEKIT_UDP_PORT="${LIVEKIT_UDP_PORT:-7882}" TURN_ENABLED="${TURN_ENABLED:-true}" TURN_UDP_PORT="${TURN_UDP_PORT:-3478}" TURN_TLS_PORT="${TURN_TLS_PORT:-5349}" SKIP_TLS="${SKIP_TLS:-0}" LIVEKIT_PUBLISH="${LIVEKIT_PUBLISH:-true}" ENABLE_FAIL2BAN="${FAIL2BAN_ENABLED:-true}" printf '\n=== glchat smoke-тест ===\n' printf '\n[1] Приложение\n' if curl -fsS "${APP_URL}/healthz" >/dev/null; then pass "/healthz отвечает 200 (${APP_URL})" else fail "/healthz недоступен на ${APP_URL}" fi ready="$(curl -fsS "${APP_URL}/readyz" 2>/dev/null || true)" if printf '%s' "$ready" | grep -q '"status":"ready"'; then pass "/readyz: база данных готова" else fail "/readyz: ${ready:-нет ответа}" fi meta="$(curl -fsS "${APP_URL}/api/v1/meta" 2>/dev/null || true)" if printf '%s' "$meta" | grep -q '"api_version":"v1"'; then version="$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')" pass "/api/v1/meta отвечает, версия ${version}" else fail "/api/v1/meta недоступен" fi printf '\n[2] Веб-клиент\n' index="$(curl -fsS "${APP_URL}/" 2>/dev/null || true)" if printf '%s' "$index" | grep -qi '
/dev/null || true)" if printf '%s' "$instance" | grep -q '"registration_enabled"'; then if printf '%s' "$instance" | grep -q '"main_guild_id":"[0-9]'; then pass "/api/v1/instance отвечает, главный сервер создан" else fail "/api/v1/instance: главный сервер не создан (выполните bootstrap-admin)" fi else fail "/api/v1/instance недоступен" fi openapi="$(curl -fsS "${APP_URL}/api/v1/openapi.json" 2>/dev/null || true)" if printf '%s' "$openapi" | grep -q '"/guilds"'; then pass "/api/v1/openapi.json содержит ручки серверов" else fail "/api/v1/openapi.json без ручек Фазы 1" fi me_status="$(curl -s -o /dev/null -w '%{http_code}' "${APP_URL}/api/v1/users/@me" 2>/dev/null || echo 000)" if [ "$me_status" = "401" ]; then pass "/api/v1/users/@me без сессии отдаёт 401" else fail "/api/v1/users/@me без сессии вернул ${me_status}, ожидался 401" fi register_status="$(curl -s -o /dev/null -w '%{http_code}' -X POST "${APP_URL}/api/v1/auth/register" \ -H 'Content-Type: application/json' -d '{}' 2>/dev/null || echo 000)" case "$register_status" in 4*) pass "регистрация защищена от пустого запроса (${register_status})" ;; *) fail "некорректный ответ регистрации на пустое тело: ${register_status}" ;; esac gateway_status="$(curl -s -o /dev/null -w '%{http_code}' "${APP_URL}/gateway" 2>/dev/null || echo 000)" if [ "$gateway_status" = "200" ]; then fail "/gateway отвечает 200 на обычный GET: апгрейд обязателен" else pass "/gateway без апгрейда отклонён (${gateway_status})" fi printf '\n[3] Публичный доступ\n' if [ "$SKIP_TLS" = "1" ]; then skip "TLS отключён (--skip-tls): публичная проверка пропущена" elif [ "$PROXY_MODE" = "external" ]; then if curl -fsS --max-time 10 "https://${DOMAIN}/api/v1/meta" >/dev/null 2>&1; then pass "https://${DOMAIN}/api/v1/meta отвечает через внешний прокси" else fail "https://${DOMAIN} недоступен: добавьте блок из ${GLCHAT_ETC_DIR}/external-proxy.conf во внешний прокси" fi else if curl -fsS --max-time 15 "https://${DOMAIN}/api/v1/meta" >/dev/null 2>&1; then pass "https://${DOMAIN}/api/v1/meta отвечает (сертификат валиден)" else fail "https://${DOMAIN} недоступен (проверьте DNS и логи Caddy)" fi fi printf '\n[4] Внутренние порты закрыты снаружи\n' public_ip="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" if [ -z "$public_ip" ]; then skip "внешний IP не определён: пропускаем проверку публикации портов" else for port in "$APP_PORT" 7880; do if timeout 4 bash -c "/dev/null; then fail "порт ${port} доступен снаружи (${public_ip})" else pass "порт ${port} недоступен снаружи" fi done fi printf '\n[5] Открытые медиапорты\n' if [ "$LIVEKIT_PUBLISH" = "false" ] || [ "${SKIP_TLS}" = "1" ]; then skip "порты LiveKit не публиковались в этом режиме" else for spec in "${LIVEKIT_TCP_PORT}/tcp" "${LIVEKIT_UDP_PORT}/udp"; do port="${spec%%/*}" if ss -tulnH 2>/dev/null | grep -q ":${port} "; then pass "слушает ${spec}" else fail "не слушает ${spec}" fi done if [ "$TURN_ENABLED" = "true" ]; then for spec in "${TURN_UDP_PORT}/udp" "${TURN_TLS_PORT}/tcp"; do port="${spec%%/*}" if ss -tulnH 2>/dev/null | grep -q ":${port} "; then pass "слушает ${spec}" else fail "не слушает ${spec} (TURN не запустился: проверьте cert_file в livekit.yaml)" fi done # TURN/TLS должен отдавать валидный сертификат на своём домене. turn_domain="$(grep -A3 '^turn:' "${GLCHAT_ETC_DIR}/livekit/livekit.yaml" 2>/dev/null | awk '/domain:/ {print $2; exit}')" if [ -n "$turn_domain" ] && command -v openssl >/dev/null 2>&1; then handshake="$(echo | timeout 10 openssl s_client -connect "${APP_BIND}:${TURN_TLS_PORT}" \ -servername "$turn_domain" 2>&1 || true)" if printf '%s' "$handshake" | grep -q 'Verify return code: 0 (ok)'; then pass "TURN/TLS: сертификат ${turn_domain} принят (verify ok)" elif printf '%s' "$handshake" | grep -q 'BEGIN CERTIFICATE'; then # Сертификат есть, но самоподписанный/недоверенный: для тестов это допустимо. skip "TURN/TLS: сертификат не проходит проверку доверия (${turn_domain})" else fail "TURN/TLS: TLS-хендшейк на ${APP_BIND}:${TURN_TLS_PORT} не прошёл" fi fi fi fi printf '\n[6] UFW и DOCKER-USER\n' if command -v ufw >/dev/null 2>&1; then if ufw status 2>/dev/null | grep -q 'Status: active'; then pass "UFW активен" if [ "$PROXY_MODE" = "external" ]; then ufw status | grep -q "${LIVEKIT_TCP_PORT}/tcp" && pass "UFW: ${LIVEKIT_TCP_PORT}/tcp разрешён" || fail "UFW: нет правила для ${LIVEKIT_TCP_PORT}/tcp" ufw status | grep -q "${LIVEKIT_UDP_PORT}/udp" && pass "UFW: ${LIVEKIT_UDP_PORT}/udp разрешён" || fail "UFW: нет правила для ${LIVEKIT_UDP_PORT}/udp" fi else fail "UFW не активен" fi else skip "ufw не установлен" fi if command -v iptables >/dev/null 2>&1 && iptables -nL DOCKER-USER >/dev/null 2>&1; then if iptables -nL DOCKER-USER 2>/dev/null | grep -q 'glchat-block-internal'; then pass "DOCKER-USER: внутренние порты заблокированы" else fail "DOCKER-USER: нет правил glchat" fi else skip "цепочка DOCKER-USER недоступна" fi printf '\n[7] fail2ban\n' if [ "$ENABLE_FAIL2BAN" = "true" ] && command -v fail2ban-client >/dev/null 2>&1; then jails="$(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:\s*//p')" for jail in sshd glchat-auth recidive; do if printf '%s' "$jails" | grep -q "$jail"; then pass "jail ${jail} активен" else fail "jail ${jail} не активен" fi done else skip "fail2ban не включён" fi printf '\n[8] Автозапуск\n' if systemctl is-enabled glchat.service >/dev/null 2>&1; then pass "glchat.service включён" else fail "glchat.service не включён в автозапуск" fi if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^glchat-app$'; then restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' glchat-app 2>/dev/null)" if [ "$restart_policy" = "unless-stopped" ]; then pass "политика перезапуска контейнера: unless-stopped" else fail "политика перезапуска: ${restart_policy:-unknown}" fi else fail "контейнер glchat-app не запущен" fi printf '\n[9] Обновление сертификата TURN\n' if [ "$TURN_ENABLED" = "true" ] && systemctl cat glchat-cert-refresh.service >/dev/null 2>&1; then if systemctl start glchat-cert-refresh.service 2>/dev/null; then pass "glchat-cert-refresh отработал (сертификат TURN актуален)" else fail "glchat-cert-refresh не запускается" fi if docker ps --format '{{.Names}}' 2>/dev/null | grep -q glchat-livekit; then pass "LiveKit работает после проверки сертификата" else fail "LiveKit не запущен после проверки сертификата" fi else skip "обновление сертификата TURN не настроено" fi printf '\n[10] Бэкап\n' if GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/backup.sh" >/tmp/glchat-smoke-backup.log 2>&1; then last_backup="$(ls -1t "${GLCHAT_ETC_DIR}/data/backups"/glchat-*.tar.gz* 2>/dev/null | head -1)" pass "бэкап создан: $(basename "${last_backup:-unknown}")" # Шифрование проверяем по содержимому, а не по имени файла. case "$(head -c 18 "${last_backup}" 2>/dev/null)" in age-encryption.org*) pass "бэкап зашифрован age" ;; *) skip "бэкап без age-шифрования: задайте AGE_RECIPIENT (установщик генерирует его сам)" ;; esac else fail "бэкап не создан (см. /tmp/glchat-smoke-backup.log)" fi printf '\n=== Итог: %d ok, %d ошибок, %d пропущено ===\n\n' "$PASSED" "$FAILED" "$SKIPPED" if [ "$FAILED" -gt 0 ]; then exit 1 fi exit 0