Установщик ждал /readyz на 127.0.0.1:8080, хотя во внешнем режиме и на
стенде приложение слушает адрес туннеля (--app-bind-addr): установка
всегда сообщала «приложение не ответило за 60 с» и «готовность не
подтверждена», хотя инстанс был жив.
Логика та же, что в deploy/update.sh, restore.sh и glchat-cli.sh
(0.0.0.0/пусто → 127.0.0.1); адрес печатается в сообщении, чтобы причина
была видна сразу.
Установщик писал значения OAuth в кавычках, а при повторной установке
снимал их из .env вместе с кавычками и добавлял новые: получалось
`OAUTH_GITHUB_CLIENT_ID=""""`, и docker compose отказывался читать .env
(«unexpected character "\"" in variable name»), из-за чего стек не
поднимался после --reconfigure.
Теперь oauth_value снимает кавычки при чтении, а env_quote ставит их
только значениям со спецсимволами. Заодно уже испорченный .env лечится
сам: кавычки снимаются, значение записывается пустым.
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.
Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.
Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.
Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.
Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
Обёртка с первого инкремента Фазы 6 запрашивает
`GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не
было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал
в лог «error decoding response body».
Что сделано:
- `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR`
(в контейнере `/app/updates:ro`), раскладка `<target>/<arch>/<version>.json` +
артефакт и `.sig`; версия без расширения отображается на `<version>.json`,
а если его нет — на `latest.json` (обёртка приходит со своей установленной
версией, «новее ли релиз» решает клиент сравнением semver). Для имён
с расширением артефакта запасного варианта нет: иначе вместо архива клиент
скачал бы манифест и подпись не сошлась бы. Сегменты пути проверяются по
символам, выход за каталог невозможен; `/updates/` добавлен в reservedPrefixes,
поэтому отсутствующий манифест — честный 404, а не HTML-шелл;
- `scripts/desktop-release.sh` и цели `make desktop-release` /
`make desktop-release-upload HOST=…`: берут собранный `glchat.app.tar.gz` + `.sig`,
определяют target/arch по имени артефакта, копируют их в каталог инстанса и
собирают манифест (версия из `tauri.conf.json`), объединяя платформы одной
версии; есть `--dry-run` и загрузка на инстанс по SSH;
- `deploy/install.sh` и шаблоны compose: каталог `${GLCHAT_UPDATES_DIR}`
(`/opt/glchat/updates`) создаётся установщиком и монтируется в контейнер
только для чтения; в `.env` добавлен `GLCHAT_UPDATES_DIR`.
Владельцу внешнего прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения, как статика клиента.
Проверено на стенде: `curl https://gl.mhspx.su/updates/darwin/aarch64/0.1.0`
→ 200 `application/json; charset=utf-8`, артефакт → 200 `application/gzip`,
отсутствующие платформа и версия → 404 (не HTML), `readyz` → 200, обёртка
в логе пишет «обновлений нет» вместо ошибки разбора. Тесты: 6 новых в
`internal/server/updates_test.go`, манифест разбирается типом самого плагина
обновлений (`updates::tests::манифест_разбирается_плагином_обновлений`).
AGENT.md 11.3 требует фиксировать образы по digest: тег в реестре может быть
перезаписан. Закреплены базовые образы Dockerfile (node, golang, alpine) и
образы установщика (caddy, livekit-server). Digest'ы проверены на стенде:
сборка образа и пересоздание LiveKit проходят, caddy тянется по digest.
- 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 после установки.
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check,
схема и состав данных, сверка с манифестом; живые данные не трогает
- чистка удаляет файлы на диске, у которых не осталось записи в базе (так
оставались вложения удалённых серверов), и пустые подкаталоги; работает
через os.Root, чтобы символическая ссылка не вывела за каталог файлов
- удаление сервера теперь чистит и содержимое файлов, а не только записи
- install.sh пишет значения .env с пробелами в кавычках: файл подключается
через `set -a && . .env`, иначе имя главного сервера выполнялось как команда
- тесты: Go на уборку файлов без записи (свежие не трогаются, пустые
каталоги удаляются)
Если встроенный TURN выключен (`--no-turn`), сертификат LiveKit больше не
нужен: coturn на внешнем узле обновляет свой сертификат сам (D-036).
Повторная установка теперь снимает старый `glchat-cert-refresh.timer`,
юниты и скрипт, чтобы не оставлять в системе неработающую автоматику.
Заодно в .gitignore добавлены артефакты Playwright (`web/test-results/`,
`web/playwright-report/`) — они локальные и не должны попадать в коммиты.
Браузеры отклоняют 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-имя релея.
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в
примонтированном файле — из-за этого вебхуки не включались после обновления.
Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
В конфиг LiveKit добавлен блок `webhook`: адрес
`http://app:8080/api/v1/livekit/webhook` внутри compose-сети и ключ подписи.
Установщик передаёт ключ в рендер livekit.yaml. Так голосовые состояния
синхронизируются с SFU без опроса (AGENT.md 7.14).
Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не
был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался
выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose.
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
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 и подпись, выключенный голос, имя комнаты, вход
и флаги, запрет мьюта без прав, перемещение и выход.
- установщик: `[DEFAULT] ignoreip` в jail.local — локальные сети и WireGuard
исключены всегда, свой адрес добавляется флагом `--ignore-ip <ip|cidr>`;
админ, часто заходящий в панель, больше не банит сам себя;
- участник может менять свой никнейм на сервере без MANAGE_NICKNAMES и без
проверки иерархии (тайм-аут на себя по-прежнему запрещён);
- тест на смену своего ника и запрет self-тайм-аута.
На чистой машине age-keygen появляется только на шаге install_packages,
поэтому генерация ключа в generate_secrets падала, а chmod на несуществующий
файл валил установку. Теперь ensure_age_key вызывается после установки
пакетов, безопасно пропускает chmod и обновляет AGE_RECIPIENT в .env.
Регрессия найдена прогоном scripts/test-install.sh в контейнере.
Найдено на стенде: повторный `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` сравнивает секреты до и после повторной установки
и падает при их изменении.
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 на шестой попытке входа, секретов в логах
нет.
Если задан --turn-relay-host, turn.domain указывает на релей, а не на
turn.<domain>: иначе клиенты получали недостижимый TURN-кандидат
(порт 3478 на VPS обслуживает coturn, а не домашний LiveKit).
Также добавлена smoke-проверка обновления сертификата TURN.
Правила блокировали 8080/7880 для всех, кроме docker-сетей, поэтому внешний
прокси не мог дойти до приложения. Теперь разрешена подсеть --allow-from,
правила снимаются перед повторным применением (не дублируются).
Условия и подстановки внутри heredoc выполнялись shell при генерации файла
и попадали в .env как код — docker compose не мог его прочитать.
Теперь шаблон пишется с quoted heredoc, значения подставляются sed.
- 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 умеет тянуть его с внешнего узла
- 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
- проверяем /proc/swaps, а не только имя /swapfile
- rm -f перед созданием и мягкая обработка ошибки swapon
- на VPS с /swap.img установщик больше не падает с 'Text file busy'
На общем сервере (VPS владельца) ufw reset удалял правила WireGuard и
wstunnel. Теперь перед сбросом снимается снимок правил без комментария
glchat, а после настройки они восстанавливаются.
- при установке без TLS (--skip-tls, стенд без домена) клиент получает
http:// и ws:// вместо неработающих https:// и wss://
- установщик пишет TLS_ENABLED в .env; тесты фиксируют обе схемы
- убраны недопустимые поля (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 больше не помечается как незашифрованный
- 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 (владение каталогами данных)
- пути томов и профиль Caddy задаются как GLCHAT_DATA_DIR/GLCHAT_CADDY_PROFILE
(внутренний DATA_DIR=/app/data остаётся в environment сервиса app)
- значения записываются в .env, поэтому docker compose config проходит без
предупреждений "variable is not set"
- 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)
resolve_derived_values() вычисляет CADDY_PROFILE, LIVEKIT_PUBLISH,
TURN_EXTERNAL_TLS, PROFILE_LIVEKIT_DISABLE_SIMULCAST и
PROFILE_LIVEKIT_USE_EXTERNAL_IP после выбора профиля и до write_env_file:
устраняет падение set -u на PROFILE_LIVEKIT_SIMULCAST_DISABLED.