Use protobuf envelopes for Mumble 1.5 TCP audio

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-09 20:16:47 -04:00
committed by Brandon McGinty
parent e8a8390cb8
commit 794610929b
5 changed files with 150 additions and 1 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Priority 2: protocol and data correctness
dereferencing and return errInvalidProtobuf for malformed server packets. dereferencing and return errInvalidProtobuf for malformed server packets.
Audit all packet pointer dereferences similarly. Audit all packet pointer dereferences similarly.
21. UDP protocol state has no complete interoperability test coverage [x] 21. UDP protocol state has no complete interoperability test coverage
Files: gumble/gumble/udp15.go, gumble/gumble/udp.go Files: gumble/gumble/udp15.go, gumble/gumble/udp.go
Tests are mostly local encrypt/decrypt round trips. Add captured/reference Tests are mostly local encrypt/decrypt round trips. Add captured/reference
vectors from current Mumble for CryptSetup, encrypted audio, ping, packet vectors from current Mumble for CryptSetup, encrypted audio, ping, packet
+7
View File
@@ -367,6 +367,7 @@ func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, dat
c.udpMu.RLock() c.udpMu.RLock()
udpConn := c.udpConn udpConn := c.udpConn
udpCryptoOut := c.udpCryptoOut udpCryptoOut := c.udpCryptoOut
udpProtobuf := c.udpProtobuf
c.udpMu.RUnlock() c.udpMu.RUnlock()
if !c.udpFallbackLogged.Swap(true) { if !c.udpFallbackLogged.Swap(true) {
if c.Config.DisableUDP { if c.Config.DisableUDP {
@@ -377,6 +378,12 @@ func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, dat
log.Info("UDP crypto not ready, audio using TCP tunnel") log.Info("UDP crypto not ready, audio using TCP tunnel")
} }
} }
if udpProtobuf {
// Mumble 1.5 uses the native UDP protobuf envelope even when audio is
// carried inside the TCP UDPTunnel packet.
payload := append([]byte{0x00}, encodeUDPAudio(uint32(target), uint64(sequence), data, final, X, Y, Z)...)
return c.Conn.WritePacket(1, payload)
}
return c.Conn.WriteAudio(format, target, sequence, final, data, X, Y, Z) return c.Conn.WriteAudio(format, target, sequence, final, data, X, Y, Z)
} }
+11
View File
@@ -93,6 +93,17 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
log.Warn("handleUDPTunnel: empty buffer") log.Warn("handleUDPTunnel: empty buffer")
return errInvalidProtobuf return errInvalidProtobuf
} }
c.udpMu.RLock()
protobufEnvelope := c.udpProtobuf
c.udpMu.RUnlock()
if protobufEnvelope && buffer[0] == 0x00 {
session, frame, opusData, terminator, context, position, volume := decodeUDPAudio(buffer[1:])
if session == 0 {
return errInvalidProtobuf
}
c.dispatchOpus15(0, session, int64(frame), opusData, terminator, context, position, volume)
return nil
}
audioType := (buffer[0] >> 5) & 0x7 audioType := (buffer[0] >> 5) & 0x7
audioTarget := buffer[0] & 0x1F audioTarget := buffer[0] & 0x1F
+91
View File
@@ -0,0 +1,91 @@
//go:build integration
package gumble_test
import (
"crypto/tls"
"fmt"
"math"
"net"
"os"
"testing"
"time"
"git.stormux.org/storm/barnard/gumble/gumble"
_ "git.stormux.org/storm/barnard/gumble/opus"
)
type integrationAudioListener struct{ packets chan *gumble.AudioPacket }
func (l *integrationAudioListener) OnAudioStream(e *gumble.AudioStreamEvent) {
go func() {
for p := range e.C {
l.packets <- p
}
}()
}
// TestLocalMumbleAudioRoundTrip sends generated 440 Hz audio through a real
// local Mumble server and requires the other client to decode non-silent PCM.
func TestLocalMumbleAudioRoundTrip(t *testing.T) {
if os.Getenv("BARNARD_MUMBLE_INTEGRATION") != "1" {
t.Skip("set BARNARD_MUMBLE_INTEGRATION=1")
}
newConfig := func(name string) *gumble.Config {
c := gumble.NewConfig()
c.Address = "localhost:64738"
c.Username = name
c.DisableUDP = true
return c
}
tlsConfig := &tls.Config{InsecureSkipVerify: true}
listener := &integrationAudioListener{packets: make(chan *gumble.AudioPacket, 8)}
recvConfig := newConfig(fmt.Sprintf("barnard-it-recv-%d", time.Now().UnixNano()))
recvConfig.AttachAudio(listener)
receiver, err := gumble.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, recvConfig, tlsConfig)
if err != nil {
t.Fatal(err)
}
defer receiver.Disconnect()
sendConfig := newConfig(fmt.Sprintf("barnard-it-send-%d", time.Now().UnixNano()))
sender, err := gumble.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, sendConfig, tlsConfig)
if err != nil {
t.Fatal(err)
}
defer sender.Disconnect()
if sender.AudioEncoder == nil {
t.Fatal("no negotiated audio encoder")
}
time.Sleep(500 * time.Millisecond)
frame := make([]int16, sendConfig.AudioFrameSize())
for i := range frame {
frame[i] = int16(12000 * math.Sin(2*math.Pi*440*float64(i)/gumble.AudioSampleRate))
}
for i := 0; i < 8; i++ {
raw, err := sender.AudioEncoder.Encode(frame, len(frame), sendConfig.AudioDataBytes)
if err != nil {
t.Fatal(err)
}
if err := sender.WriteAudio(4, 0, int64(i), i == 7, raw, nil, nil, nil); err != nil {
t.Fatal(err)
}
}
select {
case packet := <-listener.packets:
peak := 0
for _, sample := range packet.AudioBuffer {
v := int(sample)
if v < 0 {
v = -v
}
if v > peak {
peak = v
}
}
if peak < 500 {
t.Fatalf("received silent audio peak=%d", peak)
}
case <-time.After(8 * time.Second):
t.Fatal("timed out waiting for relayed audio")
}
}
+40
View File
@@ -0,0 +1,40 @@
package gumble
import (
"net"
"testing"
)
// Regression: Mumble 1.5 decodes UDPTunnel packets as native protobuf UDP
// envelopes. Sending the legacy tunnel envelope made a current server silently
// discard otherwise valid Opus audio when UDP was unavailable.
func TestWriteAudioUsesProtobufEnvelopeForTCPFallback(t *testing.T) {
local, remote := net.Pipe()
defer remote.Close()
c := &Client{Config: NewConfig(), Conn: NewConn(local), udpProtobuf: true}
c.Config.DisableUDP = true
result := make(chan struct {
typ uint16
data []byte
err error
}, 1)
go func() {
typ, data, err := NewConn(remote).ReadPacket()
result <- struct {
typ uint16
data []byte
err error
}{typ, data, err}
}()
if err := c.WriteAudio(4, 2, 300, true, []byte{0xaa, 0xbb}, nil, nil, nil); err != nil {
t.Fatal(err)
}
got := <-result
if got.err != nil || got.typ != 1 {
t.Fatalf("packet: type=%d err=%v", got.typ, got.err)
}
want := mustDecodeHex("00080220ac022a02aabb800101")
if string(got.data) != string(want) {
t.Fatalf("payload=%x want=%x", got.data, want)
}
}