2538e07ec3
LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).
- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
285 lines
9.8 KiB
Go
285 lines
9.8 KiB
Go
// 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: полные права на
|
||
// комнаты (модерация, удаление участников), но не на запись медиа.
|
||
func (i *TokenIssuer) IssueAdmin() (string, error) {
|
||
if !i.Enabled() {
|
||
return "", ErrDisabled
|
||
}
|
||
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{
|
||
"roomCreate": true,
|
||
"roomList": true,
|
||
"roomAdmin": true,
|
||
"roomRecord": false,
|
||
"ingressAdmin": false,
|
||
},
|
||
}
|
||
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
|
||
}
|