Files
glchat/internal/voice/token.go
T
grendervill a6d23a295e fix(voice): административный токен RoomService привязан к комнате
LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).

- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
  пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
  (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
  одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
  кнопки.
2026-09-20 19:54:21 +03:00

287 lines
10 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"
"net/url"
"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)
}
// IssueAdmin подписывает административный токен RoomService: права на
// модерацию одной комнаты (мьют дорожки, удаление участника), но не на запись
// медиа. LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room` в
// грахте RoomService отвечает 401 `permissions denied`.
func (i *TokenIssuer) IssueAdmin(room string) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
if strings.TrimSpace(room) == "" {
return "", fmt.Errorf("voice.invalid_room: пустое имя комнаты")
}
now := i.now().UTC()
claims := map[string]any{
"iss": i.apiKey,
"sub": i.apiKey,
"nbf": now.Add(-10 * time.Second).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"video": map[string]any{
"room": room,
"roomAdmin": true,
},
}
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
}
// 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 приводит публичный адрес к базовому виду, который ждёт
// LiveKit SDK: клиент сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`),
// поэтому сегмент `/rtc` из адреса убираем — иначе брандмауэр клиента стучится
// в `/rtc/rtc` и комната остаётся в состоянии «Подключение…». Префикс прокси
// сохраняется: `wss://host/livekit/rtc` → `wss://host/livekit`.
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)
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
}
path := strings.TrimSuffix(parsed.Path, "/")
if path != "/rtc" {
path = strings.TrimSuffix(path, "/rtc")
} else {
path = ""
}
parsed.Path = path
parsed.RawPath = ""
return strings.TrimSuffix(parsed.String(), "/"), nil
}
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
// клиент: пусто, если голос не настроен.
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {
if !i.Enabled() {
return ""
}
resolved, err := ResolveLiveKitURL(publicURL)
if err != nil {
return ""
}
return resolved
}