Commit Graph

56 Commits

Author SHA1 Message Date
grendervill 3151327f6e feat(ops): offsite-бэкапы и проверки профилей
- deploy/backup.sh: BACKUP_OFFSITE_TARGET (rsync или rclone:remote:path)
  выгружает готовый архив после локальной ротации; неудача выгрузки только
  предупреждает. Ротация на приёмнике — опционально
  (BACKUP_OFFSITE_KEEP_DAYS, rclone delete --min-age). Имя архива получает
  суффикс, если бэкап запущен дважды в одну секунду.
- deploy/install.sh: offsite-настройки в .env (BACKUP_OFFSITE_TARGET,
  BACKUP_OFFSITE_KEEP_DAYS) отдельным разделом; детектор незаполненных
  плейсхолдеров учитывает цифры в именах (FAIL2BAN_ENABLED); dry-run больше не
  требует прав на /opt/glchat — явность GLCHAT_ETC_DIR фиксируется до
  подключения common.sh, иначе ветка временного каталога была недостижима.
- scripts/test-profiles.sh: сверка таблицы профилей 10.10 и одинакового набора
  PROFILE_* (46 проверок), make profiles-test / ops-test.
- scripts/test-backup.sh: бэкап и offsite на синтетическом инстансе — локальный
  rsync-приёмник, шим rclone, недоступный хост, нечисловой срок (12 проверок),
  make backup-test.
- scripts/test-install.sh: проверка offsite-ключей в .env после установки.
2026-09-22 18:44:50 +03:00
grendervill 1cb949a7a4 feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check,
  схема и состав данных, сверка с манифестом; живые данные не трогает
- чистка удаляет файлы на диске, у которых не осталось записи в базе (так
  оставались вложения удалённых серверов), и пустые подкаталоги; работает
  через os.Root, чтобы символическая ссылка не вывела за каталог файлов
- удаление сервера теперь чистит и содержимое файлов, а не только записи
- install.sh пишет значения .env с пробелами в кавычках: файл подключается
  через `set -a && . .env`, иначе имя главного сервера выполнялось как команда
- тесты: Go на уборку файлов без записи (свежие не трогаются, пустые
  каталоги удаляются)
2026-09-21 23:33:04 +03:00
grendervill 5afcfee744 feat(ops): чистка данных, reindex и PWA с офлайн-режимом
- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
  файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
  только если на него не ссылается ничего (вложения, аватары, оформление
  сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
  команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
  (кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
  остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
2026-09-21 19:42:12 +03:00
grendervill 197c3d1a82 chore: отключать таймер сертификата TURN при внешнем релее
Если встроенный TURN выключен (`--no-turn`), сертификат LiveKit больше не
нужен: coturn на внешнем узле обновляет свой сертификат сам (D-036).
Повторная установка теперь снимает старый `glchat-cert-refresh.timer`,
юниты и скрипт, чтобы не оставлять в системе неработающую автоматику.
Заодно в .gitignore добавлены артефакты Playwright (`web/test-results/`,
`web/playwright-report/`) — они локальные и не должны попадать в коммиты.
2026-09-20 20:52:41 +03:00
grendervill 8b477ac314 feat(deploy): TURN/TLS анонсируется именем хоста с сертификатом
Браузеры отклоняют TLS-соединение к TURN по IP, если сертификат выписан на
домен: релей с публичным адресом отдавал `turns:<ip>:5349`, и этот эндпоинт был
нерабочим.

- флаг `--turn-relay-tls-host` (по умолчанию `turn.<домен>`, если он резолвится,
  иначе адрес релея) попадает в `rtc.turn_servers` как TLS-эндпоинт;
- значение сохраняется в `.env` и переиспользуется при `--reconfigure`;
- `scripts/test-install.sh` проверяет, что в `livekit.yaml` попали и UDP-адрес,
  и TLS-имя релея.
2026-09-20 20:48:32 +03:00
grendervill 1552146aa1 feat(voice): модерация через RoomService, саундборд и звуковая палитра
AGENT.md 7.13, 7.14:

- `internal/voice/admin.go` — клиент RoomService (Twirp/JSON, без SDK):
  GetParticipant, ListParticipants, MutePublishedTrack, RemoveParticipant с
  административным токеном; внутренний адрес SFU задаётся LIVEKIT_API_URL;
- серверный мьют теперь применяется и на стороне SFU (клиент не обойдёт),
  кик из голосовой (DELETE /guilds/{id}/voice-states/{user_id}) удаляет
  участника из комнаты через RemoveParticipant, перемещение — тоже;
- саундборд и звуковая палитра: таблица `guild_sounds` (миграция 00010),
  загрузка MP3/OGG/WAV/WebM/M4A до 512 КБ с MANAGE_SOUNDS, отдельные лимиты
  30 + 30, переименование, удаление, список с фильтром по виду;
- проигрывание POST /guilds/{id}/sounds/{sound_id}/play: нужно право
  USE_SOUNDBOARD и присутствие в голосовой комнате, событие SOUNDBOARD_PLAY
  уходит только участникам этой комнаты (звук играют клиенты), лимит 3 звука
  за 10 секунд; набор звуков приходит в READY и обновляется событием
  GUILD_SOUNDS_UPDATE;
- тесты: права на загрузку, требование события для звука интерфейса, запрет
  проигрывания вне комнаты, лимит частоты, переименование и удаление.
2026-09-20 19:01:14 +03:00
grendervill ecd7b3be10 fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в
примонтированном файле — из-за этого вебхуки не включались после обновления.
Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
2026-09-20 18:38:54 +03:00
grendervill 6d495f4521 feat(voice): LiveKit шлёт вебхуки приложению
В конфиг LiveKit добавлен блок `webhook`: адрес
`http://app:8080/api/v1/livekit/webhook` внутри compose-сети и ключ подписи.
Установщик передаёт ключ в рендер livekit.yaml. Так голосовые состояния
синхронизируются с SFU без опроса (AGENT.md 7.14).
2026-09-20 18:32:18 +03:00
grendervill 2749a0e831 fix(install): LIVEKIT_URL попадает в compose приложения
Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не
был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался
выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose.
2026-09-20 18:19:19 +03:00
grendervill ca34d05377 fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
2026-09-20 18:13:04 +03:00
grendervill ecd52c11d3 feat(voice): вход в голосовые комнаты, voice states и модерация (Фаза 3)
AGENT.md 7.14, начало Фазы 3:

- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
  roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
  `guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
  (микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
  выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
  лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
  PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
  PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
  аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
  в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
  `LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
  compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
  и флаги, запрет мьюта без прав, перемещение и выход.
2026-09-20 18:04:59 +03:00
grendervill 0a82dd3a02 feat(api,install): исключения fail2ban и смена своего никнейма
- установщик: `[DEFAULT] ignoreip` в jail.local — локальные сети и WireGuard
  исключены всегда, свой адрес добавляется флагом `--ignore-ip <ip|cidr>`;
  админ, часто заходящий в панель, больше не банит сам себя;
- участник может менять свой никнейм на сервере без MANAGE_NICKNAMES и без
  проверки иерархии (тайм-аут на себя по-прежнему запрещён);
- тест на смену своего ника и запрет self-тайм-аута.
2026-09-19 23:27:26 +03:00
grendervill ac2b2cb18a fix(install): ключ шифрования бэкапов создаётся после установки age
На чистой машине age-keygen появляется только на шаге install_packages,
поэтому генерация ключа в generate_secrets падала, а chmod на несуществующий
файл валил установку. Теперь ensure_age_key вызывается после установки
пакетов, безопасно пропускает chmod и обновляет AGE_RECIPIENT в .env.
Регрессия найдена прогоном scripts/test-install.sh в контейнере.
2026-09-19 22:11:31 +03:00
grendervill fb221c91bb fix(install): повторный запуск установщика больше не меняет секреты
Найдено на стенде: повторный `install.sh` без `--reconfigure` генерировал новые
MASTER_KEY/SESSION_PEPPER/TOTP_ENCRYPTION_KEY, после чего email-индекс и
TOTP-секреты существующих пользователей перестали расшифровываться (вход
возвращал `auth.invalid_credentials`).

- секреты переиспользуются при любом повторном запуске, а не только с
  `--reconfigure`;
- добавлен явный флаг `--rotate-secrets` с предупреждением о последствиях;
- age-ключ бэкапов сохраняется, старые бэкапы остаются читаемыми;
- защита от потери `.env`: если база есть, а MASTER_KEY нет — установщик
  останавливается с объяснением вместо молчаливой генерации новых ключей;
- `scripts/test-install.sh` сравнивает секреты до и после повторной установки
  и падает при их изменении.
2026-09-19 22:08:47 +03:00
grendervill 463021161a test(smoke): проверки API Фазы 1 в smoke-тесте
Добавлен блок [2b]: `/api/v1/instance` с созданным главным сервером,
наличие ручек серверов в OpenAPI, 401 на `/users/@me` без сессии, отказ
регистрации на пустое тело и отклонение обычного GET на `/gateway`
(апгрейд обязателен).
2026-09-19 21:56:47 +03:00
grendervill bf0d130ee8 feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
2026-09-19 21:53:35 +03:00
grendervill a671027461 test(smoke): проверка обновления сертификата TURN и работоспособности LiveKit после неё 2026-09-19 21:14:39 +03:00
grendervill ae289fc756 fix(turn): встроенный TURN анонсируется по публичному адресу релея
Если задан --turn-relay-host, turn.domain указывает на релей, а не на
turn.<domain>: иначе клиенты получали недостижимый TURN-кандидат
(порт 3478 на VPS обслуживает coturn, а не домашний LiveKit).
Также добавлена smoke-проверка обновления сертификата TURN.
2026-09-19 21:12:11 +03:00
grendervill d54a8fcc2d fix(smoke): проверка TURN/TLS по результату верификации, а не по разбору CN 2026-09-19 20:54:49 +03:00
grendervill 2d7d8406d2 fix(security): DOCKER-USER пропускает доверенную подсеть (прокси по WireGuard)
Правила блокировали 8080/7880 для всех, кроме docker-сетей, поэтому внешний
прокси не мог дойти до приложения. Теперь разрешена подсеть --allow-from,
правила снимаются перед повторным применением (не дублируются).
2026-09-19 20:51:50 +03:00
grendervill cb2e223a4d feat(ufw): --allow-port/--allow-from для доступа прокси к внутреннему порту 2026-09-19 20:48:59 +03:00
grendervill dcb2a8b3fa fix(smoke): проверки используют адрес привязки, а не localhost 2026-09-19 20:46:43 +03:00
grendervill 891b34be13 fix(turn): диапазон релея ограничен 20 портами (docker не тянет 10k DNAT-правил) 2026-09-19 20:44:25 +03:00
grendervill f1a5382413 fix(compose): публикация портов TURN (3478/udp, 5349/tcp, диапазон релея) 2026-09-19 20:41:37 +03:00
grendervill 2513dde5d6 fix(deploy): в файл значений .env добавлены TURN_RELAY_USER/PASSWORD 2026-09-19 20:39:08 +03:00
grendervill 928a97819a chore(deploy): удалена неиспользуемая функция turn_shared_secret 2026-09-19 20:38:55 +03:00
grendervill 028a10b8d2 fix(deploy): убраны остатки TURN_SECRET, исправлена очистка hex при генерации age-ключа 2026-09-19 20:38:46 +03:00
grendervill 653229b6f0 fix(turn): внешний релей использует статические креденшелы (в v1.9.7 нет поля secret) 2026-09-19 20:38:24 +03:00
grendervill 496797ab04 fix(livekit): поле внешнего TURN-сервера называется shared_secret (по config.go) 2026-09-19 20:35:29 +03:00
grendervill df89d452d8 fix(deploy): экранирование перевода строки в python-подстановке .env 2026-09-19 20:32:55 +03:00
grendervill c274abdfa1 fix(deploy): подстановка значений в .env через python3 (sed ломался на @ в паролях) 2026-09-19 20:32:37 +03:00
grendervill a556a86db7 fix(deploy): TLS_ENABLED вычисляется в resolve_derived_values (был unbound при записи .env) 2026-09-19 20:32:18 +03:00
grendervill 0497016c69 fix(deploy): .env генерируется через подстановку @KEY@, а не через heredoc
Условия и подстановки внутри heredoc выполнялись shell при генерации файла
и попадали в .env как код — docker compose не мог его прочитать.
Теперь шаблон пишется с quoted heredoc, значения подставляются sed.
2026-09-19 20:31:54 +03:00
grendervill 4b3a90ad7f fix(deploy): age-ключ генерируется без age-keygen -o (он читает stdin и ломал sudo -S)
Формируем AGE-SECRET-KEY в bech32 сами, публичный ключ получаем через
age-keygen -y </dev/null. Устраняет зависание установки через sudo -S.
2026-09-19 20:30:23 +03:00
grendervill 8ff7831dc7 fix(deploy): ssh при загрузке сертификата не читает stdin (ломало sudo -S) 2026-09-19 20:29:39 +03:00
grendervill aefaf36108 fix(deploy): в плане установки показываются фактические адреса привязки 2026-09-19 20:29:18 +03:00
grendervill 1359821799 feat(deploy): адрес привязки для прокси по туннелю и загрузка сертификата TURN с внешнего узла
- compose: app и LiveKit публикуются на GLCHAT_APP_BIND/GLCHAT_LIVEKIT_BIND
  (по умолчанию 127.0.0.1, для схемы «VPS-прокси + домашний сервер» — адрес в WireGuard)
- install.sh: --app-bind-addr/--livekit-bind-addr, --turn-cert-remote* для
  загрузки сертификата по SSH, glchat-cert-refresh умеет тянуть его с внешнего узла
2026-09-19 20:28:29 +03:00
grendervill e8e063e52d feat(turn): внешний TURN-релей и TLS через прокси
- install.sh: --tls-terminated-by-proxy (ссылки https/wss без своего 80/443),
  --turn-relay-host/--turn-relay-port/--turn-relay-tls-port, общий TURN_SECRET
  для coturn (use-auth-secret) и LiveKit
- livekit.yaml.tpl: rtc.turn_servers с внешним релеем (udp + tls)
- секрет вычисляется один раз и сохраняется в .env
2026-09-19 20:28:03 +03:00
grendervill 4c17080c02 fix(deploy): ensure_swap не ломается на уже активном swap провайдера
- проверяем /proc/swaps, а не только имя /swapfile
- rm -f перед созданием и мягкая обработка ошибки swapon
- на VPS с /swap.img установщик больше не падает с 'Text file busy'
2026-09-19 20:23:43 +03:00
grendervill b89a0abb44 fix(deploy): установщик сохраняет и восстанавливает чужие правила UFW
На общем сервере (VPS владельца) ufw reset удалял правила WireGuard и
wstunnel. Теперь перед сбросом снимается снимок правил без комментария
glchat, а после настройки они восстанавливаются.
2026-09-19 20:23:18 +03:00
grendervill c5392349cd feat(turn): TURN/TLS с сертификатом, порты релея и автообновление сертификата
- install.sh: --turn-domain/--turn-cert/--turn-key, проверка наличия сертификата,
  копирование в /opt/glchat/certs, UFW открывает диапазон релея
- livekit.yaml.tpl: relay_range_start/end, cert_file/key_file
- compose: сертификаты монтируются в LiveKit read-only
- systemd timer glchat-cert-refresh: ежедневно копирует обновлённый сертификат
  из внешнего ACME-хранилища и перезапускает LiveKit при изменении
- smoke.sh: TURN-порты обязательны при включённом TURN + проверка CN сертификата
2026-09-19 20:22:57 +03:00
grendervill 5353c5d5a2 fix(smoke): шифрование бэкапа проверяется по содержимому (age-encryption.org/v1) 2026-09-19 20:00:32 +03:00
grendervill cf2a5f4845 fix(deploy): SKIP_TLS сохраняется в .env, smoke.sh не падает на незаданном LIVEKIT_PUBLISH 2026-09-19 20:00:10 +03:00
grendervill 3d1816a640 feat(config): схема URL в meta зависит от TLS_ENABLED
- при установке без TLS (--skip-tls, стенд без домена) клиент получает
  http:// и ws:// вместо неработающих https:// и wss://
- установщик пишет TLS_ENABLED в .env; тесты фиксируют обе схемы
2026-09-19 19:58:32 +03:00
grendervill 6804a316cf fix(smoke): в smoke.sh нет функции warn — используем skip 2026-09-19 19:57:45 +03:00
grendervill 8f7620026d fix(livekit): конфиг по официальной схеме livekit-server
- убраны недопустимые поля (audio.min_level, audio.smooth_intervals,
  video.dynacast, video.disable_simulcast) — из-за них контейнер livekit
  циклически перезапускался
- набор кодеков задаётся через room.enabled_codecs: VP8 на lite/standard,
  VP8+VP9+H.264+AV1 на plus/pro
- keys: {} — ключи передаются через LIVEKIT_KEYS (секрет не попадает в файл)
- smoke.sh: ветка --skip-tls больше не проверяет HTTPS, бэкап с расширением
  .age больше не помечается как незашифрованный
2026-09-19 19:55:31 +03:00
grendervill d4c6cfa643 perf(container): статический musl-образ 30.2 MB, владение каталогами данных
- Dockerfile: musl-tools + полная статическая линковка, рантайм на alpine 3.20
  (debian-slim сам весит 113 MB, бюджет §9.1 <50 MB с ним недостижим)
- измерено на HomeServer: образ 30.2 MB, RSS 3.6 MB, холодный старт 0.21 с
- install.sh: set_data_ownership приводит data/{db,files,backups} к 10001:10001;
  без этого контейнер не может создать БД (проверено e2e)
- docs/DECISIONS.md: D-006 (временный stdlib-роутер до Фазы 1), D-007 (musl+alpine),
  D-008 (владение каталогами данных)
2026-09-19 19:39:47 +03:00
grendervill 35cf719d58 perf(container): образ без apt-зависимостей и встроенный healthcheck
- рантайм не тянет ca-certificates/tzdata/curl: bundle сертификатов копируется
  из официального alpine-образа, healthcheck выполняет сам бинарник
- cmd/glchat: подкоманда healthcheck (HTTP-запрос к /healthz с таймаутом)
- compose: APP_VERSION передаётся в environment (версия видна в meta без пересборки)
- бюджет §9.1: базовый слой debian-slim + 9.2 MB бинарник + 356 KB клиент
2026-09-19 19:37:06 +03:00
grendervill 741bb89bd3 fix(deploy): интерполяция compose без предупреждений о незаданных переменных
- пути томов и профиль Caddy задаются как GLCHAT_DATA_DIR/GLCHAT_CADDY_PROFILE
  (внутренний DATA_DIR=/app/data остаётся в environment сервиса app)
- значения записываются в .env, поэтому docker compose config проходит без
  предупреждений "variable is not set"
2026-09-19 19:35:38 +03:00
grendervill 37fa3e0668 test(deploy): разделение проверок установщика и сборки образа
- install.sh: флаг --skip-image для прогона в DinD, где сборка невозможна
  (overlay поверх overlay), с пропуском pull/up/smoke
- scripts/test-install.sh: полный набор проверок артефактов (compose config -q,
  Caddyfile, livekit.yaml, nftables, fail2ban, systemd-юниты, UFW, jails)
2026-09-19 19:34:05 +03:00