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 и идемпотентность повторной доставки.
This commit is contained in:
@@ -119,6 +119,92 @@ 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) {
|
||||
|
||||
@@ -89,3 +89,34 @@ func TestResolveLiveKitURL(t *testing.T) {
|
||||
t.Fatal("http-адрес должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWebhookSignature(t *testing.T) {
|
||||
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
||||
body := []byte(`{"event":"participant_joined"}`)
|
||||
|
||||
// Без заголовка — отказ.
|
||||
if err := issuer.VerifyWebhook("", body); err == nil {
|
||||
t.Fatal("подпись обязательна")
|
||||
}
|
||||
// Чужой подписью — отказ.
|
||||
other := voice.NewIssuer("key123", "another-secret", time.Hour)
|
||||
token, err := other.IssueWebhookForTest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if err := issuer.VerifyWebhook(token, body); err == nil {
|
||||
t.Fatal("подпись чужим секретом должна отклоняться")
|
||||
}
|
||||
// Своей подписью, но другим телом — отказ.
|
||||
valid, err := issuer.IssueWebhookForTest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if err := issuer.VerifyWebhook(valid, []byte(`{"event":"room_finished"}`)); err == nil {
|
||||
t.Fatal("несовпадение тела должно отклоняться")
|
||||
}
|
||||
// Корректная подпись — успех.
|
||||
if err := issuer.VerifyWebhook(valid, body); err != nil {
|
||||
t.Fatalf("VerifyWebhook: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user