Files
glchat/internal/voice/token.go
T
grendervill e9455ab6fd feat(voice): вебхуки LiveKit и сторож голосовых состояний
AGENT.md 7.14:

- POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным
  API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе —
  503; обработка идемпотентна по event.id (повторная доставка → duplicate);
- события: participant_joined восстанавливает состояние после рестарта
  приложения, participant_left и participant_connection_aborted убирают
  участника, room_finished очищает комнату целиком, track_published и
  track_unpublished обновляют флаги камеры и демонстрации экрана; каждое
  изменение рассылает VOICE_STATE_UPDATE участникам сервера;
- сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений
  дольше шести часов (сбои без вебхука) и уведомляет клиентов;
- тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный
  цикл событий SFU и идемпотентность повторной доставки.
2026-09-20 18:31:51 +03:00

233 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния
// (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей.
package voice
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// ErrDisabled — LiveKit не настроен: ключи не заданы.
var ErrDisabled = errors.New("voice.disabled")
// TokenIssuer подписывает токены доступа LiveKit (HS256).
type TokenIssuer struct {
apiKey string
apiSecret string
ttl time.Duration
now func() time.Time
}
// NewIssuer создаёт подписывателя токенов.
func NewIssuer(apiKey, apiSecret string, ttl time.Duration) *TokenIssuer {
if ttl <= 0 {
ttl = time.Hour
}
return &TokenIssuer{apiKey: apiKey, apiSecret: apiSecret, ttl: ttl, now: time.Now}
}
// SetClock подменяет источник времени (тесты).
func (i *TokenIssuer) SetClock(now func() time.Time) { i.now = now }
// Enabled сообщает, настроен ли LiveKit.
func (i *TokenIssuer) Enabled() bool {
return i != nil && i.apiKey != "" && i.apiSecret != ""
}
// Grants — права участника комнаты LiveKit.
type Grants struct {
RoomJoin bool `json:"roomJoin"`
CanPublish bool `json:"canPublish"`
CanSubscribe bool `json:"canSubscribe"`
CanPublishData bool `json:"canPublishData"`
CanPublishAudio bool `json:"canPublishSources,omitempty"`
}
// Claims — полезная нагрузка токена LiveKit.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Name string `json:"name,omitempty"`
Metadata string `json:"metadata,omitempty"`
Video struct {
Room string `json:"room"`
RoomJoin bool `json:"roomJoin"`
CanPublish bool `json:"canPublish"`
CanSubscribe bool `json:"canSubscribe"`
CanPublishData bool `json:"canPublishData"`
RoomAdmin bool `json:"roomAdmin,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Recorder bool `json:"recorder,omitempty"`
} `json:"video"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
}
// Issue собирает и подписывает токен доступа к комнате.
func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
now := i.now().UTC()
claims := Claims{Issuer: i.apiKey, Subject: identity, Name: displayName}
claims.Video.Room = room
claims.Video.RoomJoin = grants.RoomJoin
claims.Video.CanPublish = grants.CanPublish
claims.Video.CanSubscribe = grants.CanSubscribe
claims.Video.CanPublishData = grants.CanPublishData
claims.IssuedAt = now.Unix()
claims.NotBefore = now.Add(-10 * time.Second).Unix()
claims.ExpiresAt = now.Add(i.ttl).Unix()
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// RoomName собирает имя комнаты LiveKit: комнаты инстанса не пересекаются.
func RoomName(guildID, channelID uint64) string {
return fmt.Sprintf("guild_%d_channel_%d", guildID, channelID)
}
// ParseRoomName разбирает имя комнаты, если нужно понять, куда подключён клиент.
func ParseRoomName(room string) (guildID, channelID uint64, ok bool) {
if _, err := fmt.Sscanf(room, "guild_%d_channel_%d", &guildID, &channelID); err != nil {
return 0, 0, false
}
return guildID, channelID, true
}
func encodeSegment(payload []byte) string {
return base64.RawURLEncoding.EncodeToString(payload)
}
// VerifyWebhook проверяет подпись вебхука LiveKit: это JWT, подписанный тем же
// API-секретом, а в payload лежит base64(sha256(тело запроса)) (AGENT.md 7.14).
func (i *TokenIssuer) VerifyWebhook(authorization string, body []byte) error {
if !i.Enabled() {
return ErrDisabled
}
token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
if token == "" {
return errors.New("voice.webhook_unauthorized")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return errors.New("voice.webhook_unauthorized")
}
signingInput := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return err
}
if !hmac.Equal(signature, mac.Sum(nil)) {
return errors.New("voice.webhook_unauthorized")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
var claims struct {
ExpiresAt int64 `json:"exp"`
SHA256 string `json:"sha256"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return errors.New("voice.webhook_unauthorized")
}
if claims.ExpiresAt > 0 && i.now().UTC().Unix() > claims.ExpiresAt {
return errors.New("voice.webhook_expired")
}
if claims.SHA256 != "" {
digest := sha256.Sum256(body)
if subtleCompare(claims.SHA256, base64.StdEncoding.EncodeToString(digest[:])) {
return nil
}
// LiveKit может присылать и url-safe вариант кодирования.
if subtleCompare(claims.SHA256, base64.RawURLEncoding.EncodeToString(digest[:])) {
return nil
}
return errors.New("voice.webhook_body_mismatch")
}
return nil
}
// IssueWebhookForTest подписывает тело вебхука так же, как это делает LiveKit:
// нужен для проверки верификатора и тестов обработчика.
func (i *TokenIssuer) IssueWebhookForTest(body []byte) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
digest := sha256.Sum256(body)
claims := map[string]any{
"exp": i.now().UTC().Add(time.Minute).Unix(),
"sha256": base64.StdEncoding.EncodeToString(digest[:]),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// subtleCompare сравнивает строки за постоянное время.
func subtleCompare(a, b string) bool {
return hmac.Equal([]byte(a), []byte(b))
}
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
func ResolveLiveKitURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", ErrDisabled
}
if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") {
return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
}
return strings.TrimSuffix(trimmed, "/"), nil
}
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
// клиент: пусто, если голос не настроен.
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {
if !i.Enabled() {
return ""
}
resolved, err := ResolveLiveKitURL(publicURL)
if err != nil {
return ""
}
return resolved
}