- ROLE_CREATE/ROLE_UPDATE/ROLE_DELETE вместо GUILD_ROLE_*;
- выдача/снятие роли приходит как MEMBER_UPDATE с полным списком role_ids:
клиент пересчитывает доступные действия сразу после события;
- промах RESUME отдаёт INVALID_SESSION с {"resumable": true} и следом полный
READY — сессия сохраняется, повторный вход не нужен;
- тесты обновлены под новый контракт.
Добавлен блок [2b]: `/api/v1/instance` с созданным главным сервером,
наличие ручек серверов в OpenAPI, 401 на `/users/@me` без сессии, отказ
регистрации на пустое тело и отклонение обычного GET на `/gateway`
(апгрейд обязателен).
GET /users/@me/security-events отдаёт последние входы, неудачные попытки,
смены пароля и изменения 2FA (AGENT.md 7.1: уведомления о новых входах —
основа для настроек безопасности в клиенте).
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 на шестой попытке входа, секретов в логах
нет.
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и
подменяемым источником времени;
- login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на
пользователя (по хэшу токена сессии) или на IP для анонимных запросов;
- превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки;
- тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке
входа.
- администратор инстанса вне сервера видит и меняет чужой сервер: комнаты,
роли, участники, аудит; его действия помечены actor_instance_admin;
- администратора инстанса нельзя исключить или замутить с сервера
(instance.admin_protected) — права глобальны и не отзываются владельцем;
- realtime-проверка: два WS-клиента (владелец и участник) получают события
CHANNEL_CREATE, GUILD_UPDATE, GUILD_ROLE_CREATE, MEMBER_ROLES_UPDATE от
REST-ручек, а USER_UPDATE приходит только владельцу сессии.
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
Реализован Gateway по AGENT.md §8.3:
- оп-коды 0/1/2/3/4/7/10/11, единый конверт {op,t,d,s};
- HELLO с интервалом сердцебиения (45 с) и идентификатором сессии;
- IDENTIFY/RESUME с проверкой токена через auth.ResolveSession;
- READY: пользователь, серверы, видимые комнаты, роли, участники и права,
собранные через движок прав (VIEW_CHANNEL-фильтрация);
- HEARTBEAT → HEARTBEAT_ACK, лимит частоты IDENTIFY (5 с);
- RESUME: буфер на 1000 событий с TTL 5 минут, догон пропущенных событий и
подтверждение RESUMED, полный READY при расхождении состояния;
- рассылка Dispatch/SendToUser с буферизацией для отключённых сессий;
- проверка Origin по allowlist доменов инстанса (AGENT.md §9.7);
- httpx.statusRecorder пробрасывает Hijack, иначе апгрейд ломается.
Тесты: 6 сценариев Gateway (READY, скрытые комнаты, неверный токен,
heartbeat, dispatch, RESUME) и 2 теста маршрута /gateway через полный стек
middleware, включая отклонение чужого Origin.
- internal/bootstrap: создание инстанс-админа, главного сервера, ролей
«Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3),
общей текстовой комнаты; повторный запуск идемпотентен
- админ получает флаг is_instance_admin и системный бейдж instance_admin
- CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin
(работают внутри контейнера через DATA_DIR)
- auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход
флага регистрации — bootstrap выполняет установщик)
- тесты: создание админа и главного сервера, идемпотентность, автовступление
нового пользователя с ролью @user
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и
генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую)
- единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required,
perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5)
- cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS;
альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1)
- ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup,
2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст
- /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth)
- тесты: регистрация через API с cookie, ошибки входа, обязательная сессия,
валидация, наличие всех путей в OpenAPI
- регистрация: политика пароля (≥10 + локальный словарь 10k утечек, SecLists MIT),
username и email без учёта регистра, шифрование email с привязкой к аккаунту,
blind index для поиска, автовступление в главный сервер с ролью @user
- вход: Argon2id+pepper, TOTP при включённом 2FA, блокировка после серии неудач,
события безопасности; единый ответ на неверный пароль и неизвестный аккаунт
- сессии: opaque-токены (в БД только SHA-256), ротация токена, logout/logout-all,
отзыв остальных сессий при смене пароля, step-up с окном 10 минут
- 2FA: настройка секрета с QR-URL, подтверждение кодом, 8 резервных кодов
(хранятся хэшами), обязательность для инстанс-админов, запрет отключения
- тесты на реальной SQLite: регистрация/вход/дубликаты, слабые и утёкшие пароли,
ротация и logout-all, TOTP и резервные коды, step-up, смена пароля,
выключенная регистрация, отсутствие секретов в логах
- internal/crypto/password.go: Argon2id с параметрами под 1 vCPU (OWASP),
обязательный pepper (HMAC-SHA-256 до Argon2id), разбор формата хэша,
NeedsRehash для калибровки параметров
- internal/crypto/secretbox.go: AES-256-GCM с версионированным ключом (v1:),
разделение мастер-ключа на подключи шифрования и blind index (HMAC-SHA-256),
blind index для поиска по email без расшифровки (§9.2)
- internal/crypto/tokens.go: opaque-токены сессий (хранится только SHA-256),
резервные коды 2FA, нормализация email, политика пароля и username
- тесты: соль, pepper, версии ключа, чужие шифротексты, стабильность blind index
- go.mod: добавлены chi v5, huma v2, pquerna/otp (подключаются следующими шагами)
Если задан --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 (владение каталогами данных)
- рантайм не тянет 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 клиент
- пути томов и профиль 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)