diff --git a/fix.txt b/fix.txt index 79bd3d3..f1fe7f8 100644 --- a/fix.txt +++ b/fix.txt @@ -168,7 +168,7 @@ Priority 2: protocol and data correctness dereferencing and return errInvalidProtobuf for malformed server packets. 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 Tests are mostly local encrypt/decrypt round trips. Add captured/reference vectors from current Mumble for CryptSetup, encrypted audio, ping, packet diff --git a/gumble/gumble/client.go b/gumble/gumble/client.go index 43054b6..fe8291a 100644 --- a/gumble/gumble/client.go +++ b/gumble/gumble/client.go @@ -367,6 +367,7 @@ func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, dat c.udpMu.RLock() udpConn := c.udpConn udpCryptoOut := c.udpCryptoOut + udpProtobuf := c.udpProtobuf c.udpMu.RUnlock() if !c.udpFallbackLogged.Swap(true) { 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") } } + 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) } diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go index 720f0bc..234b395 100644 --- a/gumble/gumble/handlers.go +++ b/gumble/gumble/handlers.go @@ -93,6 +93,17 @@ func (c *Client) handleUDPTunnel(buffer []byte) error { log.Warn("handleUDPTunnel: empty buffer") 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 audioTarget := buffer[0] & 0x1F diff --git a/gumble/gumble/integration_audio_test.go b/gumble/gumble/integration_audio_test.go new file mode 100644 index 0000000..78b8f97 --- /dev/null +++ b/gumble/gumble/integration_audio_test.go @@ -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") + } +} diff --git a/gumble/gumble/tcp_audio_protobuf_test.go b/gumble/gumble/tcp_audio_protobuf_test.go new file mode 100644 index 0000000..b17ea2c --- /dev/null +++ b/gumble/gumble/tcp_audio_protobuf_test.go @@ -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) + } +}