2026-09-19 18:21:02 +03:00
|
|
|
|
#!/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}"
|
2026-09-19 20:46:43 +03:00
|
|
|
|
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}"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
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}"
|
2026-09-19 20:00:10 +03:00
|
|
|
|
LIVEKIT_PUBLISH="${LIVEKIT_PUBLISH:-true}"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
ENABLE_FAIL2BAN="${FAIL2BAN_ENABLED:-true}"
|
|
|
|
|
|
|
|
|
|
|
|
printf '\n=== glchat smoke-тест ===\n'
|
|
|
|
|
|
|
|
|
|
|
|
printf '\n[1] Приложение\n'
|
2026-09-19 20:46:43 +03:00
|
|
|
|
if curl -fsS "${APP_URL}/healthz" >/dev/null; then
|
|
|
|
|
|
pass "/healthz отвечает 200 (${APP_URL})"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
else
|
2026-09-19 20:46:43 +03:00
|
|
|
|
fail "/healthz недоступен на ${APP_URL}"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
fi
|
|
|
|
|
|
|
2026-09-19 20:46:43 +03:00
|
|
|
|
ready="$(curl -fsS "${APP_URL}/readyz" 2>/dev/null || true)"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
if printf '%s' "$ready" | grep -q '"status":"ready"'; then
|
|
|
|
|
|
pass "/readyz: база данных готова"
|
|
|
|
|
|
else
|
|
|
|
|
|
fail "/readyz: ${ready:-нет ответа}"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-09-19 20:46:43 +03:00
|
|
|
|
meta="$(curl -fsS "${APP_URL}/api/v1/meta" 2>/dev/null || true)"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
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'
|
2026-09-19 20:46:43 +03:00
|
|
|
|
index="$(curl -fsS "${APP_URL}/" 2>/dev/null || true)"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
if printf '%s' "$index" | grep -qi '<div id="root"'; then
|
|
|
|
|
|
pass "заглушка клиента отдаётся с /"
|
|
|
|
|
|
elif printf '%s' "$index" | grep -qi 'web client bundle is not present'; then
|
|
|
|
|
|
fail "в образе нет собранного веб-клиента"
|
|
|
|
|
|
else
|
|
|
|
|
|
fail "некорректный ответ на /"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
printf '\n[3] Публичный доступ\n'
|
2026-09-19 19:55:31 +03:00
|
|
|
|
if [ "$SKIP_TLS" = "1" ]; then
|
|
|
|
|
|
skip "TLS отключён (--skip-tls): публичная проверка пропущена"
|
|
|
|
|
|
elif [ "$PROXY_MODE" = "external" ]; then
|
2026-09-19 18:21:02 +03:00
|
|
|
|
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/tcp/${public_ip}/${port}" 2>/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
|
2026-09-19 20:22:57 +03:00
|
|
|
|
fail "не слушает ${spec} (TURN не запустился: проверьте cert_file в livekit.yaml)"
|
2026-09-19 18:21:02 +03:00
|
|
|
|
fi
|
|
|
|
|
|
done
|
2026-09-19 20:22:57 +03:00
|
|
|
|
# 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
|
2026-09-19 20:46:43 +03:00
|
|
|
|
cert_cn="$(echo | timeout 8 openssl s_client -connect "${APP_BIND}:${TURN_TLS_PORT}" \
|
2026-09-19 20:22:57 +03:00
|
|
|
|
-servername "$turn_domain" 2>/dev/null |
|
|
|
|
|
|
openssl x509 -noout -subject 2>/dev/null | sed 's/.*CN *= *//')"
|
|
|
|
|
|
if [ "$cert_cn" = "$turn_domain" ]; then
|
|
|
|
|
|
pass "TURN/TLS отдаёт сертификат для ${turn_domain}"
|
|
|
|
|
|
else
|
|
|
|
|
|
fail "TURN/TLS: сертификат не для ${turn_domain} (CN=${cert_cn:-нет})"
|
|
|
|
|
|
fi
|
|
|
|
|
|
fi
|
2026-09-19 18:21:02 +03:00
|
|
|
|
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] Бэкап\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}")"
|
2026-09-19 20:00:32 +03:00
|
|
|
|
# Шифрование проверяем по содержимому, а не по имени файла.
|
|
|
|
|
|
case "$(head -c 18 "${last_backup}" 2>/dev/null)" in
|
|
|
|
|
|
age-encryption.org*) pass "бэкап зашифрован age" ;;
|
|
|
|
|
|
*) skip "бэкап без age-шифрования: задайте AGE_RECIPIENT (установщик генерирует его сам)" ;;
|
|
|
|
|
|
esac
|
2026-09-19 18:21:02 +03:00
|
|
|
|
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
|