diff --git a/README.md b/README.md index 529cac902..a5186176f 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ A summary table of all modules and features can be found [here](internal/README. - [`kasa`](internal/kasa/README.md) - [TP-Link Kasa](https://www.kasasmart.com/) cameras. - [`multitrans`](internal/multitrans/README.md) - Two-way audio for Chinese version of [TP-Link](https://www.tp-link.com.cn/) cameras. - [`nest`](internal/nest/README.md) - [Google Nest](https://developers.google.com/nest/device-access/supported-devices) cameras through user-unfriendly and paid APIs. +- [`reolink`](internal/reolink/README.md) - Experimental native Baichuan LAN TCP/UID streaming and two-way audio for Reolink cameras. - [`ring`](internal/ring/README.md) - Ring cameras with two-way audio support. - [`roborock`](internal/roborock/README.md) - [Roborock](https://roborock.com/) vacuums with cameras with two-way audio support. - [`tapo`](internal/tapo/README.md) - [TP-Link Tapo](https://www.tapo.com/) cameras with two-way audio support. diff --git a/internal/README.md b/internal/README.md index f3e9f3b34..38f95eaa3 100644 --- a/internal/README.md +++ b/internal/README.md @@ -52,6 +52,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent | [`multitrans`] | `rtp` | `tcp` | | | | yes | | [`nest`] | `srtp` | `rtsp`, `webrtc` | yes | | | no | | [`onvif`] | `rtp` | * | yes | yes | | | +| [`reolink`] | `baichuan` | `tcp`, `udp` | yes | | | yes | | [`ring`] | `srtp` | `webrtc` | yes | | | yes | | [`roborock`] | `srtp` | `webrtc` | yes | | | yes | | [`rtmp`] | `flv` | `rtmp` | yes | yes | yes | | @@ -96,6 +97,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent [`ngrok`]: ngrok/README.md [`onvif`]: onvif/README.md [`pinggy`]: pinggy/README.md +[`reolink`]: reolink/README.md [`ring`]: ring/README.md [`roborock`]: roborock/README.md [`rtmp`]: rtmp/README.md diff --git a/internal/reolink/README.md b/internal/reolink/README.md new file mode 100644 index 000000000..97821297b --- /dev/null +++ b/internal/reolink/README.md @@ -0,0 +1,85 @@ +# Reolink + +Experimental native Baichuan source for Reolink cameras on a trusted local network. + +- H.264/H.265 video from `main`, `sub`, and `extern` profiles +- AAC audio or camera ADPCM converted to PCMA +- PCMA/PCMU/PCM/PCML two-way audio converted to camera ADPCM +- Direct LAN TCP or UID-discovered LAN-P2P over reliable UDP +- Explicit NVR and multi-lens channels + +No FFmpeg, RTSP, or HTTP-FLV input bridge is required. + +## Compatibility + +The adapter discovers the selected profile's codecs from live media and probes talkback directly. Model identity, observed channels, and capability-query status are diagnostic; they do not select a model-specific media path. + +Hardware testing covers: + +- E1 Zoom/E340 `main` and `sub` over TCP +- dual-lens TrackFlex `main` and `sub` on both channels over TCP and UID/LAN-P2P +- H.264, H.265, AAC, and talkback across those tests + +Current Reolink specifications place [E1 Outdoor Pro](https://reolink.com/us/product/e1-outdoor-pro/), [Duo 3 PoE](https://support.reolink.com/c/duo-3-poe/), and [TrackMix PoE](https://reolink.com/us/product/reolink-trackmix-poe/) inside the same codec, profile, and talkback envelope. Other likely compatibility candidates include Duo 2 WiFi, Video Doorbell, TrackMix WiFi 6, and RLC-1212A. These are compatibility candidates, not tested support claims for this adapter. + +## Configuration + +```yaml +streams: + camera_main: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/main + camera_sub: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/sub?audio=0&backchannel=0 + camera_channel: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@camera.local/main?channel=1 + camera_uid: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@${REOLINK_UID}/main?transport=uid + camera_uid_routed: reolink://${REOLINK_USER}:${REOLINK_PASSWORD}@${REOLINK_UID}/main?transport=uid&local=192.0.2.10&broadcast=198.51.100.255 +``` + +Use these URL forms: + +```text +reolink://username:password@host[:port]/profile?[options] +reolink://username:password@camera-uid/profile?transport=uid[&options] +``` + +For LAN-P2P, put the camera UID in place of the host and add `transport=uid`. The adapter discovers that camera on the local network, then carries the Baichuan stream over reliable UDP. Escape URL-reserved characters in credentials. + +Defaults are TCP port `9000`, channel `0`, the `main` profile, and enabled video, audio, and backchannel. + +| Option | Values | Purpose | +|---|---|---| +| `profile` path | `main`, `sub`, `extern` | Camera stream; `extern` is a camera-dependent third profile | +| `stream` | `main`, `sub`, `extern` | Query-form alternative to the profile path; do not set both | +| `channel` | `0`-`255` | NVR or multi-lens channel | +| `transport` | `tcp`, `uid` | Direct TCP or local UID discovery and LAN-P2P over reliable UDP | +| `local` | IPv4 address assigned to this host | Bind UID discovery and the resulting LAN-P2P socket to that address | +| `broadcast` | IPv4 address | Override the UID discovery destination; use a directed broadcast for a routed camera VLAN | +| `video`, `audio` | `0`/`1`, `false`/`true` | Enable or suppress received tracks; enabled by default | +| `backchannel` | `0`/`1` | Two-way audio; enabled by default | + +With neither selector, UID discovery sends to each active IPv4 interface's subnet broadcast. `local` binds discovery and LAN-P2P traffic to one interface address; without `broadcast`, discovery uses that interface's subnet broadcast. `broadcast` overrides the destination. Both options are valid only with `transport=uid`. Each option may appear once; unknown options are rejected. +The profile names `mainStream`, `subStream`, `ext`, and `externStream` are also accepted for compatibility; the shorter names above are preferred. + +Set both `video=0` and `audio=0` for a backchannel-only source. The source still drains its selected preview. Use a low-bandwidth profile. Dial fails if the camera does not support talkback. + +## Behavior + +- One source serves all go2rtc consumers. Duplicate sources open separate camera sessions. +- Multiple configured sources retain go2rtc's ordered codec matching. A later H.264 source can serve a consumer that does not negotiate HEVC; `video=0` prevents a source from satisfying video. +- Connections are demand-driven. When the last consumer detaches, go2rtc stops the source and the adapter synchronously releases its preview, talk, transport, and receivers. +- The adapter does not guess a camera's session capacity. The camera remains authoritative and may reject a new source when its model-, firmware-, or client-dependent limit is reached. +- Enabled audio must start within 10 seconds. A profile reconnects after 15 seconds without a valid audio frame. It also reconnects after a 60-second window below 75% of the advertised audio clock. Set `audio=0` if audio is disabled or unwanted. +- Best-effort setup queries run concurrently per configured source and add compact model, firmware, channel, and discovery status. Failed queries do not block streaming. +- Repeated talkback negotiation, writes, and teardown are tested. Audible quality, echo, and client UI behavior remain experimental. + +## Unsupported (so far) + +- WAN or cloud P2P, relay, NAT traversal, and UPnP +- Battery-camera wake and cloud lifecycles +- PTZ, presets, lights, sirens, camera settings, and event APIs; existing ONVIF or camera integrations remain separate +- Automatic RTSP, RTMP, HTTP-FLV, or FFmpeg fallback + +## Security and limits + +- Use only on a trusted network. Baichuan does not authenticate the camera before login negotiation. It may select an unencrypted session. +- A hostile broadcast-domain peer can impersonate a camera during UID discovery and attempt offline password guessing. +- UID/LAN-P2P defaults to one IPv4 broadcast domain. Routed discovery requires directed-broadcast and return routes. +- Keep credentials in environment substitutions. diff --git a/internal/reolink/reolink.go b/internal/reolink/reolink.go new file mode 100644 index 000000000..b10ad6fce --- /dev/null +++ b/internal/reolink/reolink.go @@ -0,0 +1,15 @@ +package reolink + +import ( + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/reolink" +) + +func Init() { + registry := reolink.NewRegistry(app.GetLogger("reolink")) + streams.HandleFunc("reolink", func(source string) (core.Producer, error) { + return registry.Dial(source) + }) +} diff --git a/main.go b/main.go index 00c059e3e..88d79e83c 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" "github.com/AlexxIT/go2rtc/internal/pinggy" + "github.com/AlexxIT/go2rtc/internal/reolink" "github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/rtmp" @@ -101,6 +102,7 @@ func main() { {"mpegts", mpeg.Init}, {"multitrans", multitrans.Init}, {"nest", nest.Init}, + {"reolink", reolink.Init}, {"ring", ring.Init}, {"roborock", roborock.Init}, {"tapo", tapo.Init}, diff --git a/pkg/README.md b/pkg/README.md index 89c1aa698..b34f8ae77 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -28,6 +28,7 @@ Some formats and protocols go2rtc supports exclusively. They have no equivalent | Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` | | Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` | | Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` | +| Net (priv) | baichuan | tcp, udp | | h264, hevc, aac, pcm_alaw | pcm* | `reolink:` | | Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` | | Net (priv) | doorbird | http | | | | `doorbird:` | | Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` | diff --git a/pkg/baichuan/adpcm.go b/pkg/baichuan/adpcm.go new file mode 100644 index 000000000..28419975d --- /dev/null +++ b/pkg/baichuan/adpcm.go @@ -0,0 +1,118 @@ +package baichuan + +import ( + "encoding/binary" + "fmt" +) + +var imaIndex = [...]int8{ + -1, -1, -1, -1, 2, 4, 6, 8, + -1, -1, -1, -1, 2, 4, 6, 8, +} + +var imaStep = [...]int{ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, + 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, + 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, + 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, + 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, + 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, + 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, + 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767, +} + +// ADPCMEncoder encodes the IMA ADPCM block variant used by Baichuan talkback. +type ADPCMEncoder struct { + predictor int + index int +} + +// DecodeADPCMBlock decodes the block variant produced by Baichuan cameras. +func DecodeADPCMBlock(block []byte) ([]int16, error) { + if len(block) < 5 { + return nil, fmt.Errorf("baichuan: ADPCM block too short: %d", len(block)) + } + predictor := int(int16(binary.LittleEndian.Uint16(block))) + index := int(block[2]) + if index >= len(imaStep) { + return nil, fmt.Errorf("baichuan: invalid ADPCM index %d", index) + } + samples := make([]int16, (len(block)-4)*2) + for i, value := range block[4:] { + samples[i*2] = int16(decodeADPCM(value>>4, &predictor, &index)) + samples[i*2+1] = int16(decodeADPCM(value&0x0f, &predictor, &index)) + } + return samples, nil +} + +func decodeADPCM(nibble byte, predictor, index *int) int { + step := imaStep[*index] + delta := step >> 3 + if nibble&1 != 0 { + delta += step >> 2 + } + if nibble&2 != 0 { + delta += step >> 1 + } + if nibble&4 != 0 { + delta += step + } + if nibble&8 != 0 { + *predictor -= delta + } else { + *predictor += delta + } + *predictor = clamp(*predictor, -32768, 32767) + *index = clamp(*index+int(imaIndex[nibble]), 0, len(imaStep)-1) + return *predictor +} + +func (e *ADPCMEncoder) EncodeBlock(samples []int16) ([]byte, error) { + if len(samples) < 2 || len(samples)&1 != 0 { + return nil, fmt.Errorf("baichuan: ADPCM block needs a positive even sample count, got %d", len(samples)) + } + b := make([]byte, 4+len(samples)/2) + binary.LittleEndian.PutUint16(b, uint16(int16(e.predictor))) + b[2] = byte(e.index) + for i := 0; i < len(samples); i += 2 { + b[4+i/2] = e.encode(int(samples[i]))<<4 | e.encode(int(samples[i+1])) + } + return b, nil +} + +func (e *ADPCMEncoder) encode(sample int) byte { + step := imaStep[e.index] + diff := sample - e.predictor + var nibble byte + if diff < 0 { + nibble = 8 + diff = -diff + } + delta := step >> 3 + for mask, part := byte(4), step; mask != 0; mask, part = mask>>1, part>>1 { + if diff >= part { + nibble |= mask + diff -= part + delta += part + } + } + if nibble&8 != 0 { + e.predictor -= delta + } else { + e.predictor += delta + } + e.predictor = clamp(e.predictor, -32768, 32767) + e.index = clamp(e.index+int(imaIndex[nibble]), 0, len(imaStep)-1) + return nibble +} + +func clamp(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} diff --git a/pkg/baichuan/capability.go b/pkg/baichuan/capability.go new file mode 100644 index 000000000..0e66a7a2c --- /dev/null +++ b/pkg/baichuan/capability.go @@ -0,0 +1,124 @@ +package baichuan + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "sort" +) + +const ( + capabilityTokens = "system, streaming, PTZ, IO, security, replay, disk, network, alarm, record, video, image" + maxCapabilityBody = 256 << 10 +) + +// Capabilities is the bounded channel set observed in a capability response. +type Capabilities struct { + ObservedChannels []uint8 +} + +func (c Capabilities) clone() Capabilities { + c.ObservedChannels = append([]uint8(nil), c.ObservedChannels...) + return c +} + +type capabilityExtension struct { + XMLName xml.Name `xml:"Extension"` + Version string `xml:"version,attr"` + Username string `xml:"userName"` + Token string `xml:"token"` +} + +type capabilityModule struct { + Channel *uint8 `xml:"channelId"` +} + +func (c *Client) Capabilities(ctx context.Context, channel uint8) (Capabilities, error) { + // Serialize the first query so concurrent setup cannot issue or cache duplicates. + c.capabilityMu.Lock() + defer c.capabilityMu.Unlock() + if value, ok := c.capabilities[channel]; ok { + return value.clone(), nil + } + if err := c.Login(ctx); err != nil { + return Capabilities{}, err + } + extension, err := marshalDocument(capabilityExtension{ + Version: "1.1", Username: c.cfg.username, Token: capabilityTokens, + }) + if err != nil { + return Capabilities{}, fmt.Errorf("baichuan: build capability request: %w", err) + } + response, err := c.roundTrip(ctx, request{ + command: commandAbility, channel: channel, class: classOffset, extension: extension, + }) + if err != nil { + return Capabilities{}, fmt.Errorf("baichuan: query capabilities: %w", err) + } + value, err := parseCapabilities(response.payload) + if err != nil { + return Capabilities{}, fmt.Errorf("baichuan: parse capabilities: %w", err) + } + if c.capabilities == nil { + c.capabilities = make(map[uint8]Capabilities) + } + c.capabilities[channel] = value + return value.clone(), nil +} + +func parseCapabilities(body []byte) (Capabilities, error) { + if len(body) > maxCapabilityBody { + return Capabilities{}, fmt.Errorf("response exceeds %d bytes", maxCapabilityBody) + } + decoder := xml.NewDecoder(bytes.NewReader(body)) + channels := make(map[uint8]struct{}) + found, inside := false, false + for { + token, err := decoder.Token() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return Capabilities{}, err + } + if end, ok := token.(xml.EndElement); ok { + if end.Name.Local == "AbilityInfo" { + inside = false + } + continue + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local == "AbilityInfo" { + found = true + inside = true + continue + } + if !inside || start.Name.Local != "subModule" { + continue + } + var module capabilityModule + if err = decoder.DecodeElement(&module, &start); err != nil { + return Capabilities{}, err + } + if module.Channel != nil { + channels[*module.Channel] = struct{}{} + } + } + if !found { + return Capabilities{}, fmt.Errorf("AbilityInfo missing from response") + } + var value Capabilities + for id := range channels { + value.ObservedChannels = append(value.ObservedChannels, id) + } + sort.Slice(value.ObservedChannels, func(i, j int) bool { + return value.ObservedChannels[i] < value.ObservedChannels[j] + }) + return value, nil +} diff --git a/pkg/baichuan/capability_test.go b/pkg/baichuan/capability_test.go new file mode 100644 index 000000000..f3a767c7d --- /dev/null +++ b/pkg/baichuan/capability_test.go @@ -0,0 +1,230 @@ +package baichuan + +import ( + "context" + "errors" + "fmt" + "net" + "slices" + "strings" + "testing" + "time" +) + +func TestParseCapabilities(t *testing.T) { + body := []byte(`` + + `version_ro, reboot_rw, ignored` + + `1live_ro` + + `0live_rw, audio_ro, version_rw` + + ``) + value, err := parseCapabilities(body) + if err != nil { + t.Fatal(err) + } + if got, want := value.ObservedChannels, []uint8{0, 1}; !slices.Equal(got, want) { + t.Fatalf("channels = %v, want %v", got, want) + } + clone := value.clone() + clone.ObservedChannels[0] = 2 + if value.ObservedChannels[0] == 2 { + t.Fatal("clone shares capability storage") + } +} + +func TestParseCapabilitiesRejectsLimits(t *testing.T) { + if _, err := parseCapabilities(make([]byte, maxCapabilityBody+1)); err == nil { + t.Fatal("accepted oversized response") + } + if _, err := parseCapabilities([]byte(``)); err == nil { + t.Fatal("accepted response without AbilityInfo") + } +} + +func TestClientDiscoveryCachesResponses(t *testing.T) { + clientConn, cameraConn := net.Pipe() + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + client := newClient(context.Background(), cfg, clientConn) + t.Cleanup(func() { _ = client.Close() }) + cameraErr := make(chan error, 1) + go func() { + defer cameraConn.Close() + aes, err := serveLogin(cameraConn, cfg) + if err != nil { + cameraErr <- err + return + } + requests := make(map[uint32]message, 2) + for range 2 { + frame, err := readFrame(cameraConn, cfg.Limits) + if err != nil { + cameraErr <- err + return + } + message, err := decodeFrame(frame, aes, false) + if err != nil { + cameraErr <- err + return + } + if message.header.Command != commandDeviceInfo && message.header.Command != commandAbility { + cameraErr <- fmt.Errorf("unexpected command %d", message.header.Command) + return + } + if _, ok := requests[message.header.Command]; ok { + cameraErr <- fmt.Errorf("duplicate command %d", message.header.Command) + return + } + if message.header.Command == commandAbility && + (!strings.Contains(string(message.extension), "admin") || + !strings.Contains(string(message.extension), "")) { + cameraErr <- fmt.Errorf("invalid capability extension") + return + } + requests[message.header.Command] = message + } + for _, command := range []uint32{commandAbility, commandDeviceInfo} { + message := requests[command] + var payload string + if command == commandAbility { + payload = `0version_ro` + } else { + payload = `E1 ZoomE340` + } + if err = writeTestFrame(cameraConn, header{ + Command: command, Sequence: message.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, []byte(payload), aes, false); err != nil { + cameraErr <- err + return + } + } + cameraErr <- nil + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + type deviceResult struct { + value DeviceInfo + err error + } + type capabilityResult struct { + value Capabilities + err error + } + deviceDone := make(chan deviceResult, 1) + capabilityDone := make(chan capabilityResult, 1) + go func() { + value, err := client.DeviceInfo(ctx) + deviceDone <- deviceResult{value, err} + }() + go func() { + value, err := client.Capabilities(ctx, 0) + capabilityDone <- capabilityResult{value, err} + }() + device := <-deviceDone + if device.err != nil || device.value.DisplayModel() != "E340" { + t.Fatalf("device = %+v, %v", device.value, device.err) + } + capabilities := <-capabilityDone + if capabilities.err != nil || !slices.Equal(capabilities.value.ObservedChannels, []uint8{0}) { + t.Fatalf("capabilities = %+v, %v", capabilities.value, capabilities.err) + } + if cached, err := client.DeviceInfo(ctx); err != nil || cached != device.value { + t.Fatalf("cached device = %+v, %v", cached, err) + } + capabilities.value.ObservedChannels[0] = 1 + if cached, err := client.Capabilities(ctx, 0); err != nil || + !slices.Equal(cached.ObservedChannels, []uint8{0}) { + t.Fatalf("cached capabilities = %+v, %v", cached, err) + } + if err = <-cameraErr; err != nil { + t.Fatal(err) + } +} + +func TestClientDiscoveryFailuresDoNotClosePreview(t *testing.T) { + clientConn, cameraConn := net.Pipe() + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + client := newClient(context.Background(), cfg, clientConn) + t.Cleanup(func() { _ = client.Close() }) + cameraErr := make(chan error, 1) + go func() { + defer cameraConn.Close() + aes, err := serveLogin(cameraConn, cfg) + if err != nil { + cameraErr <- err + return + } + requests := make(map[uint32]message, 2) + for range 2 { + frame, err := readFrame(cameraConn, cfg.Limits) + if err != nil { + cameraErr <- err + return + } + message, err := decodeFrame(frame, aes, false) + if err != nil { + cameraErr <- err + return + } + requests[message.header.Command] = message + } + device, ok := requests[commandDeviceInfo] + if !ok || requests[commandAbility].header.Command != commandAbility { + cameraErr <- fmt.Errorf("missing discovery requests") + return + } + if err = writeTestFrame(cameraConn, header{ + Command: commandDeviceInfo, Sequence: device.header.Sequence, + ResponseCode: 500, Class: classOffset, + }, nil, nil, aes, false); err != nil { + cameraErr <- err + return + } + cameraErr <- servePreviewSession(cameraConn, cfg, aes, previewRoute{stream: StreamMain}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + deviceDone := make(chan error, 1) + capabilityDone := make(chan error, 1) + go func() { + _, err := client.DeviceInfo(ctx) + deviceDone <- err + }() + go func() { + _, err := client.Capabilities(ctx, 0) + capabilityDone <- err + }() + var status *StatusError + if err = <-deviceDone; !errors.As(err, &status) || status.Code != 500 { + t.Fatalf("unexpected device failure: %v", err) + } + if err = <-capabilityDone; !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unexpected capability failure: %v", err) + } + + previewCtx, previewCancel := context.WithTimeout(context.Background(), time.Second) + defer previewCancel() + preview, err := client.StartPreview(previewCtx, 0, StreamMain) + if err != nil { + t.Fatalf("preview after discovery failure: %v; camera: %v", err, <-cameraErr) + } + for _, kind := range []MediaKind{MediaInfo, MediaVideoI} { + packet, err := preview.Read(previewCtx) + if err != nil || packet.Kind != kind { + t.Fatalf("unexpected preview packet: %+v, %v", packet, err) + } + } + if err = preview.Close(); err != nil { + t.Fatal(err) + } + if err = <-cameraErr; err != nil { + t.Fatal(err) + } +} diff --git a/pkg/baichuan/client.go b/pkg/baichuan/client.go new file mode 100644 index 000000000..a21f4b403 --- /dev/null +++ b/pkg/baichuan/client.go @@ -0,0 +1,369 @@ +package baichuan + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" +) + +var ErrPreviewOverflow = errors.New("baichuan: preview queue overflow") + +type pendingKey struct { + command uint32 + sequence uint16 +} + +type previewKey struct { + channel uint8 + stream uint8 +} + +type Client struct { + cfg Config + conn transport + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + wg sync.WaitGroup + + closeOnce sync.Once + errMu sync.Mutex + err error + closeErr error + + cipherMu sync.RWMutex + cipher cipherState + + sendMu sync.Mutex + reqMu sync.Mutex + seq uint16 + reqs map[pendingKey]chan message + + previewMu sync.RWMutex + previews map[previewKey]*Preview + + loginMu sync.Mutex + logged bool + + capabilityMu sync.Mutex + capabilities map[uint8]Capabilities + deviceMu sync.Mutex + device DeviceInfo + deviceKnown bool +} + +func (c *Client) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprint(state, "baichuan.Client") +} + +func Dial(ctx context.Context, cfg Config) (*Client, error) { + cfg, err := cfg.normalized() + if err != nil { + return nil, err + } + var conn transport + if cfg.uid != "" { + var discovery *uidDiscovery + discovery, err = discoverUID(ctx, cfg.uid, cfg.UIDLocalAddr, cfg.UIDBroadcastAddr, cfg.Timeout) + if err == nil { + conn, err = newUIDConn(discovery, cfg.Timeout) + } + } else { + conn, err = dialTCP(ctx, cfg) + } + if err != nil { + return nil, fmt.Errorf("baichuan: dial camera: %w", err) + } + return newClient(ctx, cfg, conn), nil +} + +func newClient(ctx context.Context, cfg Config, conn transport) *Client { + lifetime, cancel := context.WithCancel(context.WithoutCancel(ctx)) + c := &Client{ + cfg: cfg, conn: conn, ctx: lifetime, cancel: cancel, done: make(chan struct{}), + reqs: make(map[pendingKey]chan message), previews: make(map[previewKey]*Preview), + } + c.wg.Add(1) + go c.readLoop() + return c +} + +func (c *Client) Done() <-chan struct{} { + return c.done +} + +func (c *Client) Err() error { + c.errMu.Lock() + defer c.errMu.Unlock() + return c.err +} + +func (c *Client) Close() error { + c.shutdown(context.Canceled) + // Login may add keepalive work; wait for it before waiting on the group. + c.loginMu.Lock() + c.loginMu.Unlock() + c.wg.Wait() + c.errMu.Lock() + err := c.closeErr + c.errMu.Unlock() + return err +} + +func (c *Client) shutdown(err error) { + c.closeOnce.Do(func() { + c.errMu.Lock() + c.err = err + c.errMu.Unlock() + c.cancel() + close(c.done) + closeErr := c.conn.Close() + c.errMu.Lock() + c.closeErr = closeErr + c.errMu.Unlock() + }) +} + +func (c *Client) readLoop() { + defer c.wg.Done() + for { + value, err := readFrame(c.conn, c.cfg.Limits) + if err != nil { + if !errors.Is(err, io.EOF) || c.ctx.Err() == nil { + c.shutdown(fmt.Errorf("baichuan: read message: %w", err)) + } + return + } + + state := c.cipherState(value.header) + key := pendingKey{command: value.header.Command, sequence: value.header.Sequence} + pending := c.pending(key) + preview := c.getPreview(value.header) + binaryHint := value.header.Command == commandPreview && pending == nil && preview != nil + msg, err := decodeFrame(value, state, binaryHint) + if err != nil { + c.shutdown(fmt.Errorf("baichuan: decode message: %w", err)) + return + } + if msg.binary { + if preview != nil { + preview.deliver(msg) + } + continue + } + if pending != nil { + select { + case pending <- msg: + default: + c.shutdown(fmt.Errorf("baichuan: duplicate response for command %d sequence %d", key.command, key.sequence)) + return + } + } + } +} + +func (c *Client) cipherState(h header) cipherState { + if h.Command != commandLogin { + c.cipherMu.RLock() + state := c.cipher + c.cipherMu.RUnlock() + return state + } + c.cipherMu.Lock() + defer c.cipherMu.Unlock() + if h.Command == commandLogin { + if mode, ok := negotiatedEncryption(h.ResponseCode); ok { + c.cipher.mode = mode + } + } + return c.cipher +} + +func (c *Client) pending(key pendingKey) chan message { + c.reqMu.Lock() + defer c.reqMu.Unlock() + return c.reqs[key] +} + +func (c *Client) roundTrip(ctx context.Context, req request) (message, error) { + ctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout) + defer cancel() + key, ch, err := c.reserve(req.command) + if err != nil { + return message{}, err + } + defer c.release(key) + req.sequence = key.sequence + + c.cipherMu.RLock() + state := c.cipher + c.cipherMu.RUnlock() + packet, err := encodeRequest(req, c.cfg.Limits, state) + if err != nil { + return message{}, err + } + c.sendMu.Lock() + err = writeFull(ctx, c.conn, c.cfg.Timeout, packet) + c.sendMu.Unlock() + if err != nil { + err = fmt.Errorf("baichuan: write command %d: %w", req.command, err) + c.shutdown(err) + return message{}, err + } + + select { + case msg := <-ch: + return checkedResponse(msg) + case <-ctx.Done(): + select { + case msg := <-ch: + return checkedResponse(msg) + default: + return message{}, ctx.Err() + } + case <-c.done: + select { + case msg := <-ch: + return checkedResponse(msg) + default: + return message{}, c.Err() + } + } +} + +func checkedResponse(msg message) (message, error) { + if err := responseError(msg.header); err != nil { + return message{}, err + } + return msg, nil +} + +func (c *Client) writeRequest(ctx context.Context, req request) error { + req.sequence = c.nextSequence(req.command) + c.cipherMu.RLock() + state := c.cipher + c.cipherMu.RUnlock() + packet, err := encodeRequest(req, c.cfg.Limits, state) + if err != nil { + return err + } + c.sendMu.Lock() + err = writeFull(ctx, c.conn, c.cfg.Timeout, packet) + c.sendMu.Unlock() + if err != nil { + err = fmt.Errorf("baichuan: write command %d: %w", req.command, err) + c.shutdown(err) + } + return err +} + +func (c *Client) nextSequence(command uint32) uint16 { + c.reqMu.Lock() + defer c.reqMu.Unlock() + for { + sequence := c.seq + c.seq++ + if _, ok := c.reqs[pendingKey{command: command, sequence: sequence}]; !ok { + return sequence + } + } +} + +func (c *Client) reserve(command uint32) (pendingKey, chan message, error) { + c.reqMu.Lock() + defer c.reqMu.Unlock() + if len(c.reqs) >= c.cfg.Limits.MaxPending { + return pendingKey{}, nil, fmt.Errorf("baichuan: pending request limit reached") + } + for i := 0; i < 1<<16; i++ { + key := pendingKey{command: command, sequence: c.seq} + c.seq++ + if _, ok := c.reqs[key]; !ok { + ch := make(chan message, 1) + c.reqs[key] = ch + return key, ch, nil + } + } + return pendingKey{}, nil, fmt.Errorf("baichuan: no request sequence available") +} + +func (c *Client) release(key pendingKey) { + c.reqMu.Lock() + delete(c.reqs, key) + c.reqMu.Unlock() +} + +func (c *Client) Login(ctx context.Context) error { + c.loginMu.Lock() + defer c.loginMu.Unlock() + if c.logged { + return nil + } + + nonceMessage, err := c.roundTrip(ctx, request{command: commandLogin, class: classLegacy, forceBC: true}) + if err != nil { + return fmt.Errorf("baichuan: request login nonce: %w", err) + } + nonce, err := parseNonce(nonceMessage.payload) + if err != nil { + return fmt.Errorf("baichuan: parse login nonce: %w", err) + } + c.cipherMu.Lock() + c.cipher.setAESKey(deriveAESKey(nonce, c.cfg.password)) + c.cipherMu.Unlock() + + body, err := buildLogin(c.cfg.username, c.cfg.password, nonce) + if err != nil { + return fmt.Errorf("baichuan: build login: %w", err) + } + if _, err = c.roundTrip(ctx, request{ + command: commandLogin, class: classOffset, payload: body, forceBC: true, + }); err != nil { + return fmt.Errorf("baichuan: login: %w", err) + } + c.cipherMu.Lock() + if c.cipher.hasKey { + c.cipher.mode = encryptionAES + } + c.cipherMu.Unlock() + c.logged = true + // Active UID sessions maintain liveness with transport data and ACKs. The + // request/response ping is TCP-only and can fill a UID send window on + // firmware that doesn't answer it. + if _, ok := c.conn.(*uidConn); ok { + return nil + } + c.wg.Add(1) + go c.keepAlive() + return nil +} + +func (c *Client) keepAlive() { + defer c.wg.Done() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + failures := 0 + for { + select { + case <-ticker.C: + ctx, cancel := context.WithTimeout(c.ctx, 4*time.Second) + _, err := c.roundTrip(ctx, request{command: commandPing, class: classOffset}) + cancel() + if err == nil { + failures = 0 + continue + } + failures++ + if failures == 3 { + c.shutdown(fmt.Errorf("baichuan: keepalive failed: %w", err)) + return + } + case <-c.done: + return + } + } +} diff --git a/pkg/baichuan/client_test.go b/pkg/baichuan/client_test.go new file mode 100644 index 000000000..9201c8b3a --- /dev/null +++ b/pkg/baichuan/client_test.go @@ -0,0 +1,229 @@ +package baichuan + +import ( + "context" + "encoding/binary" + "encoding/xml" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestClientLoginAndPreview(t *testing.T) { + for _, route := range []previewRoute{ + {channel: 0, stream: StreamMain, wire: 0, handle: 0}, + {channel: 3, stream: StreamSub, wire: 1, handle: 256}, + {channel: 7, stream: StreamExtern, wire: 2, handle: 1024}, + } { + t.Run(string(route.stream), func(t *testing.T) { + clientConn, cameraConn := net.Pipe() + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + client := newClient(context.Background(), cfg, clientConn) + defer client.Close() + cameraErr := make(chan error, 1) + go func() { cameraErr <- servePreview(cameraConn, cfg, route) }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + preview, err := client.StartPreview(ctx, route.channel, route.stream) + if err != nil { + t.Fatalf("%v; camera: %v", err, <-cameraErr) + } + packet, err := preview.Read(ctx) + if err != nil || packet.Kind != MediaInfo || packet.Width != 3840 || packet.Height != 2160 { + t.Fatalf("unexpected info packet: %+v, %v", packet, err) + } + packet, err = preview.Read(ctx) + if err != nil || packet.Kind != MediaVideoI || packet.Codec != "H265" { + t.Fatalf("unexpected video packet: %+v, %v", packet, err) + } + if err = preview.Close(); err != nil { + t.Fatal(err) + } + if err = preview.Close(); err != nil { + t.Fatalf("second close: %v", err) + } + if err = <-cameraErr; err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRoundTripUsesDefaultTimeout(t *testing.T) { + clientConn, cameraConn := net.Pipe() + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + cfg.Timeout = 20 * time.Millisecond + client := newClient(context.Background(), cfg, clientConn) + defer client.Close() + read := make(chan error, 1) + go func() { + _, err := readFrame(cameraConn, cfg.Limits) + read <- err + }() + _, err = client.roundTrip(context.Background(), request{command: commandPing, class: classOffset}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unexpected response timeout: %v", err) + } + if err = <-read; err != nil { + t.Fatal(err) + } + _ = cameraConn.Close() +} + +func TestClientReserveWrapAndLimit(t *testing.T) { + c := &Client{cfg: Config{Limits: Limits{MaxPending: 2}}, seq: ^uint16(0), reqs: make(map[pendingKey]chan message)} + first, _, err := c.reserve(commandPing) + if err != nil || first.sequence != ^uint16(0) { + t.Fatalf("unexpected first reservation: %+v %v", first, err) + } + second, _, err := c.reserve(commandPing) + if err != nil || second.sequence != 0 { + t.Fatalf("unexpected wrapped reservation: %+v %v", second, err) + } + if _, _, err = c.reserve(commandPing); err == nil { + t.Fatal("accepted reservation beyond pending limit") + } +} + +type previewRoute struct { + channel uint8 + stream Stream + wire uint8 + handle uint32 +} + +func servePreview(conn net.Conn, cfg Config, route previewRoute) error { + defer conn.Close() + aes, err := serveLogin(conn, cfg) + if err != nil { + return err + } + return servePreviewSession(conn, cfg, aes, route) +} + +func servePreviewSession(conn net.Conn, cfg Config, aes cipherState, route previewRoute) error { + previewRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + preview, err := decodeFrame(previewRequest, aes, false) + if err != nil { + return err + } + var body previewEnvelope + if err = xml.Unmarshal(preview.payload, &body); err != nil || + preview.header.Command != commandPreview || preview.header.Channel != route.channel || + preview.header.Stream != route.wire || body.Preview.Channel != route.channel || + body.Preview.Stream != route.stream || body.Preview.Handle != route.handle { + return fmt.Errorf("invalid preview request: %+v %s", preview.header, preview.payload) + } + if err = writeTestFrame(conn, header{ + Command: commandPreview, Channel: route.channel, Stream: route.wire, Sequence: preview.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, nil, aes, false); err != nil { + return err + } + + media := append(infoFixture(), videoFixture()...) + media = append(media, infoFixture()...) + if err = writeTestFrame(conn, header{ + Command: commandPreview, Channel: route.channel, Stream: route.wire, Sequence: preview.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, []byte("1"), media, aes, true); err != nil { + return err + } + + stopRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + stop, err := decodeFrame(stopRequest, aes, false) + if err != nil { + return err + } + var stopBody stopPreviewEnvelope + if err = xml.Unmarshal(stop.payload, &stopBody); err != nil || + stop.header.Command != commandStopPreview || stop.header.Channel != route.channel || + stop.header.Stream != route.wire || stopBody.Preview.Channel != route.channel || + stopBody.Preview.Handle != route.handle { + return fmt.Errorf("invalid stop request: %+v %s", stop.header, stop.payload) + } + return writeTestFrame(conn, header{ + Command: commandStopPreview, Channel: route.channel, Stream: route.wire, Sequence: stop.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, nil, aes, false) +} + +func serveLogin(conn net.Conn, cfg Config) (cipherState, error) { + + nonceRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return cipherState{}, err + } + if nonceRequest.header.Command != commandLogin || nonceRequest.header.ResponseCode != 0xdc12 || + nonceRequest.header.Class != classLegacy || len(nonceRequest.body) != 0 { + return cipherState{}, fmt.Errorf("invalid nonce request: %+v", nonceRequest.header) + } + const nonce = "0123456789ABCDEF" + bc := cipherState{mode: encryptionBC} + if err = writeTestFrame(conn, header{ + Command: commandLogin, Sequence: nonceRequest.header.Sequence, + ResponseCode: 0xdd12, Class: classOffset, + }, nil, []byte(""+nonce+""), bc, false); err != nil { + return cipherState{}, err + } + + loginRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return cipherState{}, err + } + login, err := decodeFrame(loginRequest, bc, false) + if err != nil { + return cipherState{}, err + } + loginXML := string(login.payload) + if !strings.Contains(loginXML, "admin<") || strings.Contains(loginXML, ">password<") { + return cipherState{}, fmt.Errorf("invalid login XML: %s", loginXML) + } + if err = writeTestFrame(conn, header{ + Command: commandLogin, Sequence: loginRequest.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, nil, bc, false); err != nil { + return cipherState{}, err + } + + aes := cipherState{mode: encryptionAES, aesKey: deriveAESKey(nonce, cfg.password), hasKey: true} + return aes, nil +} + +func writeTestFrame(conn net.Conn, h header, ext, payload []byte, state cipherState, binaryPayload bool) error { + ext = testCrypt(state, h.Channel, ext, true) + if !binaryPayload { + payload = testCrypt(state, h.Channel, payload, true) + } + bodyLen := len(ext) + len(payload) + packet := make([]byte, 24+bodyLen) + binary.LittleEndian.PutUint32(packet, wireMagic) + binary.LittleEndian.PutUint32(packet[4:], h.Command) + binary.LittleEndian.PutUint32(packet[8:], uint32(bodyLen)) + packet[12] = h.Channel + packet[13] = h.Stream + binary.LittleEndian.PutUint16(packet[14:], h.Sequence) + binary.LittleEndian.PutUint16(packet[16:], h.ResponseCode) + binary.LittleEndian.PutUint16(packet[18:], classOffset) + binary.LittleEndian.PutUint32(packet[20:], uint32(len(ext))) + copy(packet[24:], ext) + copy(packet[24+len(ext):], payload) + _, err := conn.Write(packet) + return err +} diff --git a/pkg/baichuan/config.go b/pkg/baichuan/config.go new file mode 100644 index 000000000..6b7b369ea --- /dev/null +++ b/pkg/baichuan/config.go @@ -0,0 +1,192 @@ +package baichuan + +import ( + "encoding/json" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "time" +) + +const ( + DefaultPort = 9000 + DefaultTimeout = 10 * time.Second +) + +const ( + defaultMaxBody = 16 << 20 + defaultMaxExtension = 64 << 10 + defaultMaxPending = 64 + defaultMaxMediaFrame = 16 << 20 + defaultMaxResync = 64 << 10 + defaultMaxMediaBuffer = defaultMaxMediaFrame + defaultMaxResync + mediaVideoHeaderSize +) + +// Limits bounds memory controlled by a camera or slow peer. +type Limits struct { + MaxBody uint32 + MaxExtension uint32 + MaxPending int + MaxMediaFrame uint32 + MaxMediaBuffer uint32 + MaxResync uint32 +} + +func (l Limits) normalized() (Limits, error) { + if l.MaxBody == 0 { + l.MaxBody = defaultMaxBody + } + if l.MaxExtension == 0 { + l.MaxExtension = defaultMaxExtension + } + if l.MaxPending == 0 { + l.MaxPending = defaultMaxPending + } + if l.MaxMediaFrame == 0 { + l.MaxMediaFrame = defaultMaxMediaFrame + } + if l.MaxMediaBuffer == 0 { + l.MaxMediaBuffer = defaultMaxMediaBuffer + } + if l.MaxResync == 0 { + l.MaxResync = defaultMaxResync + } + + if l.MaxBody > 64<<20 || l.MaxExtension > 1<<20 || l.MaxPending > 1024 || + l.MaxMediaFrame > 64<<20 || l.MaxMediaBuffer > 128<<20 || l.MaxResync > 1<<20 { + return Limits{}, fmt.Errorf("baichuan: limits exceed hard ceiling") + } + if l.MaxPending < 1 || l.MaxExtension > l.MaxBody || l.MaxResync > l.MaxMediaBuffer || + uint64(l.MaxMediaFrame)+uint64(l.MaxResync)+mediaVideoHeaderSize > uint64(l.MaxMediaBuffer) { + return Limits{}, fmt.Errorf("baichuan: inconsistent limits") + } + return l, nil +} + +// Config contains a private camera endpoint and credentials. +type Config struct { + Host string + Port uint16 + Timeout time.Duration + Limits Limits + // UIDLocalAddr restricts UID discovery to the interface owning this IPv4 address. + UIDLocalAddr string + // UIDBroadcastAddr overrides the destination used for UID discovery. + UIDBroadcastAddr string + + username string + password string + uid string +} + +func NewConfig(host, username, password string) Config { + return Config{Host: host, username: username, password: password} +} + +func NewUIDConfig(uid, username, password string) Config { + return Config{uid: uid, username: username, password: password} +} + +func (c Config) normalized() (Config, error) { + if c.uid != "" { + if c.Host != "" || c.Port != 0 || !validUID(c.uid) { + return Config{}, fmt.Errorf("baichuan: invalid UID endpoint") + } + if c.UIDLocalAddr != "" { + address, err := netip.ParseAddr(strings.TrimSpace(c.UIDLocalAddr)) + if err != nil || !uidUnicastAddr(address) { + return Config{}, fmt.Errorf("baichuan: invalid UID local address") + } + c.UIDLocalAddr = address.String() + } + if c.UIDBroadcastAddr != "" { + address, err := netip.ParseAddr(strings.TrimSpace(c.UIDBroadcastAddr)) + if err != nil || !uidDiscoveryAddr(address) { + return Config{}, fmt.Errorf("baichuan: invalid UID broadcast address") + } + c.UIDBroadcastAddr = address.String() + } + } else { + if c.UIDLocalAddr != "" { + return Config{}, fmt.Errorf("baichuan: UID local address requires UID transport") + } + if c.UIDBroadcastAddr != "" { + return Config{}, fmt.Errorf("baichuan: UID broadcast address requires UID transport") + } + c.Host = strings.TrimSpace(c.Host) + if c.Host == "" { + return Config{}, fmt.Errorf("baichuan: host is required") + } + if strings.ContainsAny(c.Host, "/?#@") { + return Config{}, fmt.Errorf("baichuan: host must not contain URL components") + } + if c.Port == 0 { + c.Port = DefaultPort + } + } + if c.Timeout == 0 { + c.Timeout = DefaultTimeout + } + if c.Timeout < 0 { + return Config{}, fmt.Errorf("baichuan: timeout must be positive") + } + + var err error + if c.Limits, err = c.Limits.normalized(); err != nil { + return Config{}, err + } + return c, nil +} + +func (c Config) address() string { + return net.JoinHostPort(c.Host, strconv.Itoa(int(c.Port))) +} + +func (c Config) Format(s fmt.State, _ rune) { + if c.uid != "" { + _, _ = fmt.Fprintf(s, "baichuan.Config{Mode:%q, LocalAddr:%q, BroadcastAddr:%q, Timeout:%s}", + "uid", c.UIDLocalAddr, c.UIDBroadcastAddr, c.Timeout) + return + } + _, _ = fmt.Fprintf(s, "baichuan.Config{Host:%q, Port:%d, Timeout:%s}", c.Host, c.Port, c.Timeout) +} + +func (c Config) MarshalJSON() ([]byte, error) { + if c.uid != "" { + return json.Marshal(struct { + Mode string `json:"mode"` + LocalAddr string `json:"local_addr,omitempty"` + BroadcastAddr string `json:"broadcast_addr,omitempty"` + Timeout time.Duration `json:"timeout"` + }{"uid", c.UIDLocalAddr, c.UIDBroadcastAddr, c.Timeout}) + } + return json.Marshal(struct { + Host string `json:"host"` + Port uint16 `json:"port"` + Timeout time.Duration `json:"timeout"` + }{c.Host, c.Port, c.Timeout}) +} + +// Stream selects one preview profile exposed by the camera. +type Stream string + +const ( + StreamMain Stream = "mainStream" + StreamSub Stream = "subStream" + StreamExtern Stream = "externStream" +) + +func (s Stream) params() (uint8, uint32, error) { + switch s { + case StreamMain: + return 0, 0, nil + case StreamSub: + return 1, 256, nil + case StreamExtern: + return 2, 1024, nil + default: + return 0, 0, fmt.Errorf("baichuan: unsupported stream %q", s) + } +} diff --git a/pkg/baichuan/config_test.go b/pkg/baichuan/config_test.go new file mode 100644 index 000000000..fa538b923 --- /dev/null +++ b/pkg/baichuan/config_test.go @@ -0,0 +1,116 @@ +package baichuan + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestConfigRedaction(t *testing.T) { + uid := NewUIDConfig("SentinelCameraUID1", "secret-user", "secret-password") + uid.UIDLocalAddr = "192.0.2.28" + uid.UIDBroadcastAddr = "198.51.100.255" + for _, test := range []struct { + name string + cfg Config + secrets []string + }{ + {name: "tcp", cfg: NewConfig("camera.local", "secret-user", "secret-password"), secrets: []string{"secret-user", "secret-password"}}, + {name: "uid", cfg: uid, secrets: []string{"SentinelCameraUID1", "secret-user", "secret-password"}}, + } { + data, err := json.Marshal(test.cfg) + if err != nil { + t.Fatal(err) + } + values := []string{ + fmt.Sprintf("%v", test.cfg), fmt.Sprintf("%+v", test.cfg), fmt.Sprintf("%#v", test.cfg), + fmt.Sprintf("%s", test.cfg), fmt.Sprintf("%q", test.cfg), string(data), + } + for _, value := range values { + for _, secret := range test.secrets { + if strings.Contains(value, secret) { + t.Fatalf("%s config leaked %q", test.name, secret) + } + } + } + if _, err = test.cfg.normalized(); err != nil { + t.Fatal(err) + } + } +} + +func TestUIDLocalAddress(t *testing.T) { + cfg := NewUIDConfig("CameraUID1", "user", "password") + cfg.UIDLocalAddr = " 192.0.2.28 " + normalized, err := cfg.normalized() + if err != nil || normalized.UIDLocalAddr != "192.0.2.28" { + t.Fatalf("unexpected local address: %q %v", normalized.UIDLocalAddr, err) + } + for _, value := range []string{"loopback", "127.0.0.1", "::1"} { + cfg.UIDLocalAddr = value + if _, err = cfg.normalized(); err == nil { + t.Fatalf("accepted invalid UID local address %q", value) + } + } + tcp := NewConfig("camera", "user", "password") + tcp.UIDLocalAddr = "192.0.2.28" + if _, err = tcp.normalized(); err == nil { + t.Fatal("accepted UID local address for TCP transport") + } +} + +func TestUIDBroadcastAddress(t *testing.T) { + cfg := NewUIDConfig("CameraUID1", "user", "password") + cfg.UIDBroadcastAddr = " 198.51.100.255 " + normalized, err := cfg.normalized() + if err != nil || normalized.UIDBroadcastAddr != "198.51.100.255" { + t.Fatalf("unexpected broadcast address: %q %v", normalized.UIDBroadcastAddr, err) + } + for _, value := range []string{"broadcast", "0.0.0.0", "127.0.0.1", "224.0.0.1", "::1"} { + cfg.UIDBroadcastAddr = value + if _, err = cfg.normalized(); err == nil { + t.Fatalf("accepted invalid UID broadcast address %q", value) + } + } + tcp := NewConfig("camera", "user", "password") + tcp.UIDBroadcastAddr = "198.51.100.255" + if _, err = tcp.normalized(); err == nil { + t.Fatal("accepted UID broadcast address for TCP transport") + } +} + +func TestAggregateFormattingRedactsCredentials(t *testing.T) { + cfg := NewConfig("camera.local", "sentinel-user", "sentinel-pass") + client := &Client{cfg: cfg} + preview := &Preview{client: client, key: previewKey{channel: 2}, stream: StreamMain} + talk := &Talk{ + client: client, channel: 2, + format: TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 1016}, + } + for _, value := range []any{client, preview, talk} { + for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q"} { + text := fmt.Sprintf(format, value) + if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") { + t.Fatalf("credential leaked from %T with %s: %s", value, format, text) + } + } + } +} + +func TestLimits(t *testing.T) { + limits, err := (Limits{}).normalized() + if err != nil { + t.Fatal(err) + } + if limits.MaxBody != defaultMaxBody || limits.MaxPending != defaultMaxPending || + limits.MaxMediaBuffer != defaultMaxMediaBuffer { + t.Fatalf("unexpected defaults: %+v", limits) + } + if _, err = (Limits{MaxBody: 65 << 20}).normalized(); err == nil { + t.Fatal("expected hard ceiling error") + } + if _, err = (Limits{MaxBody: 1024, MaxExtension: 2048}).normalized(); err == nil { + t.Fatal("expected inconsistent limits error") + } +} diff --git a/pkg/baichuan/crypto.go b/pkg/baichuan/crypto.go new file mode 100644 index 000000000..62195d475 --- /dev/null +++ b/pkg/baichuan/crypto.go @@ -0,0 +1,103 @@ +package baichuan + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" +) + +type encryption uint8 + +const ( + encryptionNone encryption = iota + encryptionBC + encryptionAES +) + +var ( + bcKey = [...]byte{0x1f, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0xff} + aesIV = []byte("0123456789abcdef") +) + +type cipherState struct { + mode encryption + aesKey [aes.BlockSize]byte + block cipher.Block + hasKey bool +} + +func (s *cipherState) setAESKey(key [aes.BlockSize]byte) { + s.aesKey = key + s.block, _ = aes.NewCipher(key[:]) + s.hasKey = true +} + +func modernMD5(value string) string { + sum := md5.Sum([]byte(value)) // Baichuan uses the first 31 uppercase hex digits. + const digits = "0123456789ABCDEF" + + out := make([]byte, 31) + for i := range out { + b := sum[i/2] + if i&1 == 0 { + out[i] = digits[b>>4] + } else { + out[i] = digits[b&0x0f] + } + } + return string(out) +} + +func deriveAESKey(nonce, password string) [aes.BlockSize]byte { + value := modernMD5(nonce + "-" + password) + var key [aes.BlockSize]byte + copy(key[:], value) + return key +} + +func (s cipherState) decryptInPlace(channel uint8, b []byte) { + s.cryptInPlace(channel, b, false) +} + +func (s cipherState) cryptInPlace(channel uint8, b []byte, encrypt bool) { + switch s.mode { + case encryptionBC: + for i := range b { + b[i] ^= bcKey[(int(channel)+i)%len(bcKey)] ^ channel + } + case encryptionAES: + if !s.hasKey { + for i := range b { + b[i] ^= bcKey[(int(channel)+i)%len(bcKey)] ^ channel + } + return + } + block := s.block + if block == nil { + block, _ = aes.NewCipher(s.aesKey[:]) + } + var stream cipher.Stream + if encrypt { + stream = cipher.NewCFBEncrypter(block, aesIV) + } else { + stream = cipher.NewCFBDecrypter(block, aesIV) + } + stream.XORKeyStream(b, b) + } +} + +func negotiatedEncryption(code uint16) (encryption, bool) { + if code>>8 != 0xdd { + return 0, false + } + switch byte(code) { + case 0: + return encryptionNone, true + case 1, 0x12: + return encryptionBC, true + case 2, 3: + return encryptionAES, true + default: + return 0, false + } +} diff --git a/pkg/baichuan/crypto_test.go b/pkg/baichuan/crypto_test.go new file mode 100644 index 000000000..6098ea062 --- /dev/null +++ b/pkg/baichuan/crypto_test.go @@ -0,0 +1,41 @@ +package baichuan + +import ( + "encoding/hex" + "testing" +) + +func TestModernMD5(t *testing.T) { + if actual := modernMD5("test"); actual != "098F6BCD4621D373CADE4E832627B4F" { + t.Fatalf("unexpected digest: %s", actual) + } +} + +func TestBC(t *testing.T) { + state := cipherState{mode: encryptionBC} + encoded := testCrypt(state, 2, []byte("abc"), true) + if actual := hex.EncodeToString(encoded); actual != "5f2b3b" { + t.Fatalf("unexpected ciphertext: %s", actual) + } + if actual := string(testCrypt(state, 2, encoded, false)); actual != "abc" { + t.Fatalf("unexpected plaintext: %s", actual) + } +} + +func TestAESKnownVector(t *testing.T) { + state := cipherState{mode: encryptionAES} + state.setAESKey(deriveAESKey("nonce", "password")) + encoded := testCrypt(state, 0, []byte("camera XML"), true) + if actual := hex.EncodeToString(encoded); actual != "db73b881b9082f4c67b5" { + t.Fatalf("unexpected ciphertext: %s", actual) + } + if actual := string(testCrypt(state, 0, encoded, false)); actual != "camera XML" { + t.Fatalf("unexpected plaintext: %s", actual) + } +} + +func testCrypt(state cipherState, channel uint8, src []byte, encrypt bool) []byte { + dst := append([]byte(nil), src...) + state.cryptInPlace(channel, dst, encrypt) + return dst +} diff --git a/pkg/baichuan/device.go b/pkg/baichuan/device.go new file mode 100644 index 000000000..562e92c24 --- /dev/null +++ b/pkg/baichuan/device.go @@ -0,0 +1,88 @@ +package baichuan + +import ( + "context" + "encoding/xml" + "fmt" + "strings" +) + +const ( + maxDeviceInfoBody = 64 << 10 + maxDeviceInfoField = 128 +) + +// DeviceInfo contains non-secret camera identity reported by the firmware. +type DeviceInfo struct { + Type string + Model string + Hardware string + Firmware string +} + +func (d DeviceInfo) DisplayModel() string { + if d.Model != "" { + return d.Model + } + return d.Type +} + +type deviceInfoEnvelope struct { + Info *struct { + Type string `xml:"type"` + Model string `xml:"itemNo"` + Hardware string `xml:"hardwareVersion"` + Firmware string `xml:"firmwareVersion"` + } `xml:"VersionInfo"` +} + +func (c *Client) DeviceInfo(ctx context.Context) (DeviceInfo, error) { + // Serialize the first query so concurrent setup cannot issue or cache duplicates. + c.deviceMu.Lock() + defer c.deviceMu.Unlock() + if c.deviceKnown { + return c.device, nil + } + if err := c.Login(ctx); err != nil { + return DeviceInfo{}, err + } + response, err := c.roundTrip(ctx, request{command: commandDeviceInfo, class: classOffset}) + if err != nil { + return DeviceInfo{}, fmt.Errorf("baichuan: query device information: %w", err) + } + value, err := parseDeviceInfo(response.payload) + if err != nil { + return DeviceInfo{}, fmt.Errorf("baichuan: parse device information: %w", err) + } + c.device = value + c.deviceKnown = true + return value, nil +} + +func parseDeviceInfo(body []byte) (DeviceInfo, error) { + if len(body) > maxDeviceInfoBody { + return DeviceInfo{}, fmt.Errorf("response exceeds %d bytes", maxDeviceInfoBody) + } + var envelope deviceInfoEnvelope + if err := xml.Unmarshal(body, &envelope); err != nil { + return DeviceInfo{}, err + } + if envelope.Info == nil { + return DeviceInfo{}, fmt.Errorf("VersionInfo missing from response") + } + value := DeviceInfo{ + Type: strings.TrimSpace(envelope.Info.Type), + Model: strings.TrimSpace(envelope.Info.Model), + Hardware: strings.TrimSpace(envelope.Info.Hardware), + Firmware: strings.TrimSpace(envelope.Info.Firmware), + } + for _, field := range []string{value.Type, value.Model, value.Hardware, value.Firmware} { + if len(field) > maxDeviceInfoField { + return DeviceInfo{}, fmt.Errorf("device information field exceeds %d bytes", maxDeviceInfoField) + } + } + if value.DisplayModel() == "" { + return DeviceInfo{}, fmt.Errorf("camera model missing from response") + } + return value, nil +} diff --git a/pkg/baichuan/device_test.go b/pkg/baichuan/device_test.go new file mode 100644 index 000000000..04fe32e94 --- /dev/null +++ b/pkg/baichuan/device_test.go @@ -0,0 +1,41 @@ +package baichuan + +import ( + "strings" + "testing" +) + +func TestParseDeviceInfo(t *testing.T) { + body := []byte(` E1 Zoom E340` + + `IPC_NT14v3.2.0` + + `private camera nameprivate serial`) + value, err := parseDeviceInfo(body) + if err != nil { + t.Fatal(err) + } + if value.Type != "E1 Zoom" || value.Model != "E340" || value.DisplayModel() != "E340" || + value.Hardware != "IPC_NT14" || value.Firmware != "v3.2.0" { + t.Fatalf("unexpected device information: %+v", value) + } + if strings.Contains(value.Type+value.Model+value.Hardware+value.Firmware, "private") { + t.Fatalf("private fields escaped parser: %+v", value) + } +} + +func TestParseDeviceInfoFallbackAndLimits(t *testing.T) { + value, err := parseDeviceInfo([]byte(`TrackFlex`)) + if err != nil || value.DisplayModel() != "TrackFlex" { + t.Fatalf("fallback = %+v, %v", value, err) + } + for _, body := range [][]byte{ + make([]byte, maxDeviceInfoBody+1), + []byte(``), + []byte(``), + []byte(`` + strings.Repeat("x", maxDeviceInfoField+1) + + ``), + } { + if _, err = parseDeviceInfo(body); err == nil { + t.Fatalf("accepted invalid device information of length %d", len(body)) + } + } +} diff --git a/pkg/baichuan/media.go b/pkg/baichuan/media.go new file mode 100644 index 000000000..b9d660f25 --- /dev/null +++ b/pkg/baichuan/media.go @@ -0,0 +1,362 @@ +package baichuan + +import ( + "encoding/binary" + "fmt" + "time" +) + +const ( + mediaVideoHeaderSize = 24 + + mediaInfoV1 = 0x31303031 + mediaInfoV2 = 0x32303031 + mediaIFrameMin = 0x63643030 + mediaIFrameMax = 0x63643039 + mediaPFrameMin = 0x63643130 + mediaPFrameMax = 0x63643139 + mediaAAC = 0x62773530 + mediaAACV2 = 0x62773531 + mediaADPCM = 0x62773130 + mediaAlignment = 8 + codecH264 = 0x34363248 + codecH265 = 0x35363248 +) + +type MediaKind uint8 + +const ( + MediaInfo MediaKind = iota + 1 + MediaVideoI + MediaVideoP + MediaAAC + MediaADPCM +) + +type MediaPacket struct { + Kind MediaKind + Codec string + Data []byte + Timestamp uint32 + WallTime time.Time + Width uint32 + Height uint32 + FPS uint8 + InfoV2 bool +} + +type MediaParser struct { + limits Limits + buf []byte + scan int +} + +func NewMediaParser(limits Limits) (*MediaParser, error) { + limits, err := limits.normalized() + if err != nil { + return nil, err + } + return &MediaParser{limits: limits}, nil +} + +func (p *MediaParser) Append(data []byte) ([]MediaPacket, error) { + if uint64(len(p.buf))+uint64(len(data)) > uint64(p.limits.MaxMediaBuffer) { + return nil, fmt.Errorf("baichuan: media buffer exceeds limit %d", p.limits.MaxMediaBuffer) + } + return p.appendOwned(append([]byte(nil), data...)) +} + +// appendOwned may retain data and is only for payloads exclusively owned by the caller. +func (p *MediaParser) appendOwned(data []byte) ([]MediaPacket, error) { + return p.appendOwnedTo(data, nil) +} + +func (p *MediaParser) appendOwnedTo(data []byte, packets []MediaPacket) ([]MediaPacket, error) { + if uint64(len(p.buf))+uint64(len(data)) > uint64(p.limits.MaxMediaBuffer) { + return nil, fmt.Errorf("baichuan: media buffer exceeds limit %d", p.limits.MaxMediaBuffer) + } + if len(p.buf) == 0 { + p.buf = data + } else { + p.buf = append(p.buf, data...) + } + + packets = packets[:0] + for len(p.buf) >= 4 { + packet, size, complete, err := p.parsePacket(p.buf) + if err != nil { + return packets, err + } + if !complete { + break + } + p.scan = 0 + p.buf = p.buf[size:] + packets = append(packets, packet) + } + if len(p.buf) == 0 { + p.buf = nil + } + return packets, nil +} + +func knownMediaMagic(magic uint32) bool { + return magic == mediaInfoV1 || magic == mediaInfoV2 || + magic >= mediaIFrameMin && magic <= mediaIFrameMax || + magic >= mediaPFrameMin && magic <= mediaPFrameMax || + magic == mediaAAC || magic == mediaAACV2 || magic == mediaADPCM +} + +func (p *MediaParser) parsePacket(b []byte) (MediaPacket, int, bool, error) { + magic := binary.LittleEndian.Uint32(b) + switch { + case magic == mediaInfoV1 || magic == mediaInfoV2: + if len(b) < 32 { + return MediaPacket{}, 0, false, nil + } + if size := binary.LittleEndian.Uint32(b[4:8]); size != 32 { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid media info size %d", size) + } + return MediaPacket{ + Kind: MediaInfo, Width: binary.LittleEndian.Uint32(b[8:12]), + Height: binary.LittleEndian.Uint32(b[12:16]), FPS: b[17], InfoV2: magic == mediaInfoV2, + }, 32, true, nil + case magic >= mediaIFrameMin && magic <= mediaIFrameMax: + return p.parseVideo(b, true) + case magic >= mediaPFrameMin && magic <= mediaPFrameMax: + return p.parseVideo(b, false) + case magic == mediaAAC || magic == mediaAACV2: + return p.parseAudio(b, MediaAAC) + case magic == mediaADPCM: + return p.parseAudio(b, MediaADPCM) + default: + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: unknown media magic %#x", magic) + } +} + +func (p *MediaParser) parseVideo(b []byte, keyframe bool) (MediaPacket, int, bool, error) { + const headerSize = mediaVideoHeaderSize + if len(b) < headerSize { + return MediaPacket{}, 0, false, nil + } + var codec string + switch binary.LittleEndian.Uint32(b[4:8]) { + case codecH264: + codec = "H264" + case codecH265: + codec = "H265" + default: + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: unsupported video codec %q", b[4:8]) + } + payload := binary.LittleEndian.Uint32(b[8:12]) + extra := binary.LittleEndian.Uint32(b[12:16]) + if keyframe { + return p.parseIFrame(b, codec, payload, extra) + } + // Observed P-frames honor their declared size; only I-frames require bounded boundary recovery. + dataStart := uint64(headerSize) + uint64(extra) + plain := dataStart + uint64(payload) + total := plain + uint64(padding(payload)) + if total > uint64(p.limits.MaxMediaFrame) { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", total, p.limits.MaxMediaFrame) + } + size, complete, err := mediaBoundary(b, plain, total, p.limits.MaxResync) + if err != nil { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video payload %d extra header %d: %w", payload, extra, err) + } + if !complete { + return MediaPacket{}, 0, false, nil + } + packet := MediaPacket{ + Kind: MediaVideoP, Codec: codec, Data: b[int(dataStart):int(plain)], + Timestamp: binary.LittleEndian.Uint32(b[16:20]), + } + return packet, size, true, nil +} + +func (p *MediaParser) parseIFrame(b []byte, codec string, payload, extra uint32) (MediaPacket, int, bool, error) { + const headerSize = mediaVideoHeaderSize + dataStart := uint64(headerSize) + uint64(extra) + plain := dataStart + uint64(payload) + declared := plain + uint64(padding(payload)) + if declared > uint64(p.limits.MaxMediaFrame) { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", declared, p.limits.MaxMediaFrame) + } + size, complete := p.findVideoBoundary(b, int(dataStart), declared) + if !complete { + if uint64(len(b)) >= declared+uint64(p.limits.MaxResync)+mediaVideoHeaderSize { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: no keyframe boundary near declared size %d extra header %d", declared, extra) + } + return MediaPacket{}, 0, false, nil + } + if uint64(size) > uint64(p.limits.MaxMediaFrame) { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: video frame %d exceeds limit %d", size, p.limits.MaxMediaFrame) + } + payloadEnd := size + if uint64(size) == declared { + payloadEnd = int(plain) + } + var wallTime time.Time + if extra >= 4 { + wallTime = time.Unix(int64(binary.LittleEndian.Uint32(b[24:28])), 0).UTC() + } + return MediaPacket{ + Kind: MediaVideoI, Codec: codec, Data: b[int(dataStart):payloadEnd], + Timestamp: binary.LittleEndian.Uint32(b[16:20]), + WallTime: wallTime, + }, size, true, nil +} + +func (p *MediaParser) findVideoBoundary(b []byte, headerSize int, declared uint64) (int, bool) { + if offset := int(declared); offset <= len(b)-8 && plausibleMediaHeader(b[offset:]) { + return offset, true + } + start := int(declared) - int(p.limits.MaxResync) + if start < headerSize { + start = headerSize + } + if p.scan < start { + p.scan = start + } + end := int(declared) + int(p.limits.MaxResync) + if end > len(b)-8 { + end = len(b) - 8 + } + best, pending := -1, -1 + for i := p.scan; i <= end; i++ { + if plausibleMediaHeader(b[i:]) && (best < 0 || abs(i-int(declared)) < abs(best-int(declared))) { + best = i + } else if pending < 0 && partialVideoHeader(b[i:]) { + pending = i + } + } + if pending >= 0 { + p.scan = pending + } else if end >= p.scan { + p.scan = end + 1 + } + return best, best >= 0 +} + +func partialVideoHeader(b []byte) bool { + if len(b) >= mediaVideoHeaderSize { + return false + } + magic := binary.LittleEndian.Uint32(b) + return magic >= mediaIFrameMin && magic <= mediaIFrameMax || magic >= mediaPFrameMin && magic <= mediaPFrameMax +} + +func plausibleMediaHeader(b []byte) bool { + if len(b) < 8 { + return false + } + magic := binary.LittleEndian.Uint32(b) + switch { + case magic == mediaInfoV1 || magic == mediaInfoV2: + return binary.LittleEndian.Uint32(b[4:8]) == 32 + case magic >= mediaIFrameMin && magic <= mediaIFrameMax || magic >= mediaPFrameMin && magic <= mediaPFrameMax: + if len(b) < mediaVideoHeaderSize { + return false + } + codec := binary.LittleEndian.Uint32(b[4:8]) + return codec == codecH264 || codec == codecH265 + case magic == mediaAAC || magic == mediaAACV2 || magic == mediaADPCM: + return binary.LittleEndian.Uint16(b[4:6]) == binary.LittleEndian.Uint16(b[6:8]) + default: + return false + } +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func (p *MediaParser) parseAudio(b []byte, kind MediaKind) (MediaPacket, int, bool, error) { + if len(b) < 8 { + return MediaPacket{}, 0, false, nil + } + payload16 := binary.LittleEndian.Uint16(b[4:6]) + if payload16 != binary.LittleEndian.Uint16(b[6:8]) { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: inconsistent audio payload sizes") + } + payload := uint32(payload16) + plain := uint64(8) + uint64(payload) + total := plain + uint64(padding(payload)) + if total > uint64(p.limits.MaxMediaFrame) { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: audio frame %d exceeds limit %d", total, p.limits.MaxMediaFrame) + } + size, complete, err := mediaBoundary(b, plain, total, p.limits.MaxResync) + if err != nil { + return MediaPacket{}, 0, false, err + } + if !complete { + return MediaPacket{}, 0, false, nil + } + start := 8 + if kind == MediaADPCM { + if payload < 4 { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid ADPCM payload size %d", payload) + } + if binary.LittleEndian.Uint16(b[8:10]) != 0x0100 { + return MediaPacket{}, 0, false, fmt.Errorf("baichuan: invalid ADPCM marker") + } + start += 4 + payload -= 4 + } + return MediaPacket{Kind: kind, Data: b[start : start+int(payload)]}, size, true, nil +} + +func mediaBoundary(b []byte, plain, padded uint64, resync uint32) (int, bool, error) { + if uint64(len(b)) < plain { + return 0, false, nil + } + if plain == padded { + return int(plain), true, nil + } + if uint64(len(b)) >= plain+4 && hasMediaMagic(b[plain:]) { + return int(plain), true, nil + } + if uint64(len(b)) < padded { + return 0, false, nil + } + if uint64(len(b)) == padded { + for _, value := range b[plain:padded] { + if value != 0 { + return 0, false, nil + } + } + return int(padded), true, nil + } + if uint64(len(b)) >= padded+4 && hasMediaMagic(b[padded:]) { + return int(padded), true, nil + } + if uint64(len(b)) < padded+4 { + return 0, false, nil + } + + start := int(plain) - int(resync) + if start < 4 { + start = 4 + } + end := int(padded) + int(resync) + if end > len(b)-3 { + end = len(b) - 3 + } + for i := start; i < end; i++ { + if hasMediaMagic(b[i:]) { + return 0, false, fmt.Errorf("baichuan: media boundary differs from declared size by %d bytes (next magic %#x)", + i-int(padded), binary.LittleEndian.Uint32(b[i:])) + } + } + return 0, false, fmt.Errorf("baichuan: no media boundary after declared size %d", padded) +} + +func padding(size uint32) uint32 { + if rem := size % mediaAlignment; rem != 0 { + return mediaAlignment - rem + } + return 0 +} diff --git a/pkg/baichuan/media_test.go b/pkg/baichuan/media_test.go new file mode 100644 index 000000000..c5118f8e3 --- /dev/null +++ b/pkg/baichuan/media_test.go @@ -0,0 +1,328 @@ +package baichuan + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "strings" + "testing" +) + +func videoFixture() []byte { + b := make([]byte, 40) + binary.LittleEndian.PutUint32(b, mediaIFrameMin) + copy(b[4:8], "H265") + binary.LittleEndian.PutUint32(b[8:12], 3) + binary.LittleEndian.PutUint32(b[12:16], 8) + binary.LittleEndian.PutUint32(b[16:20], 123456) + binary.LittleEndian.PutUint32(b[24:28], 1000) + copy(b[32:35], []byte{1, 2, 3}) + return b +} + +func TestMediaParserIFrameExtraHeader(t *testing.T) { + for _, extra := range []uint32{0, 4, 8, 12} { + t.Run(fmt.Sprint(extra), func(t *testing.T) { + const payload = 3 + start := 24 + int(extra) + video := make([]byte, start+8) + binary.LittleEndian.PutUint32(video, mediaIFrameMin) + copy(video[4:8], "H265") + binary.LittleEndian.PutUint32(video[8:12], payload) + binary.LittleEndian.PutUint32(video[12:16], extra) + binary.LittleEndian.PutUint32(video[16:20], 123456) + if extra >= 4 { + binary.LittleEndian.PutUint32(video[24:28], 1000) + } + copy(video[start:start+payload], []byte{1, 2, 3}) + + p, _ := NewMediaParser(Limits{}) + packets, err := p.Append(append(video, infoFixture()...)) + if err != nil || len(packets) != 2 { + t.Fatalf("unexpected result: packets=%d err=%v", len(packets), err) + } + packet := packets[0] + if !bytes.Equal(packet.Data, []byte{1, 2, 3}) || packet.Timestamp != 123456 { + t.Fatalf("unexpected packet: %+v", packet) + } + if extra >= 4 && packet.WallTime.Unix() != 1000 || extra < 4 && !packet.WallTime.IsZero() { + t.Fatalf("unexpected wall time: %v", packet.WallTime) + } + }) + } +} + +func infoFixture() []byte { + b := make([]byte, 32) + binary.LittleEndian.PutUint32(b, mediaInfoV2) + binary.LittleEndian.PutUint32(b[4:8], 32) + binary.LittleEndian.PutUint32(b[8:12], 3840) + binary.LittleEndian.PutUint32(b[12:16], 2160) + b[17] = 20 + return b +} + +func TestMediaParserSplitAndOwnership(t *testing.T) { + p, err := NewMediaParser(Limits{}) + if err != nil { + t.Fatal(err) + } + fixture := videoFixture() + packets, err := p.Append(fixture[:19]) + if err != nil || len(packets) != 0 { + t.Fatalf("unexpected partial result: %d, %v", len(packets), err) + } + packets, err = p.Append(append(fixture[19:], infoFixture()...)) + if err != nil || len(packets) != 2 { + t.Fatalf("unexpected complete result: %d, %v", len(packets), err) + } + packet := packets[0] + if packet.Kind != MediaVideoI || packet.Codec != "H265" || packet.Timestamp != 123456 || + !packet.WallTime.Equal(packet.WallTime.UTC()) || !bytes.Equal(packet.Data, []byte{1, 2, 3}) { + t.Fatalf("unexpected packet: %+v", packet) + } + fixture[32] = 9 + if packet.Data[0] != 1 { + t.Fatal("packet data aliases parser input") + } +} + +func TestMediaParserRejectsUnknownPrefix(t *testing.T) { + p, _ := NewMediaParser(Limits{}) + if _, err := p.Append(append([]byte{9, 8, 7, 6}, infoFixture()...)); err == nil || !strings.Contains(err.Error(), "unknown media magic") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMediaParserAdjustsBrokenKeyframeLength(t *testing.T) { + p, _ := NewMediaParser(Limits{}) + video := videoFixture()[:35] + binary.LittleEndian.PutUint32(video[8:12], 100) + packets, err := p.Append(append(video, infoFixture()...)) + if err != nil || len(packets) != 2 { + t.Fatalf("unexpected result: packets=%d err=%v", len(packets), err) + } + if !bytes.Equal(packets[0].Data, []byte{1, 2, 3}) { + t.Fatalf("unexpected keyframe data: %x", packets[0].Data) + } +} + +func TestPreviewBurstPreservesPacketBytes(t *testing.T) { + limits := testLimits(t) + parser, _ := NewMediaParser(limits) + client := &Client{previews: make(map[previewKey]*Preview)} + preview := &Preview{ + client: client, messages: make(chan message, previewQueueMessages), + queueLimit: int64(limits.MaxMediaBuffer), done: make(chan struct{}), parser: parser, + } + const count = 32 + for i := range count { + frame := make([]byte, 24+(32<<10)) + binary.LittleEndian.PutUint32(frame, mediaPFrameMin) + copy(frame[4:8], "H264") + binary.LittleEndian.PutUint32(frame[8:12], 32<<10) + frame[24] = byte(i) + preview.deliver(message{payload: frame}) + } + if queued := preview.queueBytes.Load(); queued == 0 { + t.Fatal("preview burst was not queued") + } + for i := range count { + packet, err := preview.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + if packet.Kind != MediaVideoP || len(packet.Data) != 32<<10 || packet.Data[0] != byte(i) { + t.Fatalf("packet %d corrupted: kind=%d size=%d first=%d", i, packet.Kind, len(packet.Data), packet.Data[0]) + } + } + if queued := preview.queueBytes.Load(); queued != 0 { + t.Fatalf("preview retained %d queued bytes", queued) + } +} + +func TestPreviewQueueByteBudget(t *testing.T) { + client := &Client{previews: make(map[previewKey]*Preview)} + preview := &Preview{ + client: client, messages: make(chan message, previewQueueMessages), + queueLimit: 32, done: make(chan struct{}), + } + preview.deliver(message{payload: make([]byte, 20)}) + preview.deliver(message{payload: make([]byte, 20)}) + if queued := preview.queueBytes.Load(); !errors.Is(preview.Err(), ErrPreviewOverflow) || queued != 20 { + t.Fatalf("unexpected overflow state: err=%v queued=%d", preview.Err(), queued) + } +} + +func TestPreviewQueueMessageBudget(t *testing.T) { + client := &Client{previews: make(map[previewKey]*Preview)} + preview := &Preview{ + client: client, messages: make(chan message, previewQueueMessages), + queueLimit: 32, done: make(chan struct{}), + } + for range previewQueueMessages + 1 { + preview.deliver(message{}) + } + if queued := preview.queueBytes.Load(); !errors.Is(preview.Err(), ErrPreviewOverflow) || queued != 0 { + t.Fatalf("unexpected message overflow state: err=%v queued=%d", preview.Err(), queued) + } +} + +func TestMediaParserPFrameExtraHeader(t *testing.T) { + p, _ := NewMediaParser(Limits{}) + b := make([]byte, 36) + binary.LittleEndian.PutUint32(b, mediaPFrameMin) + copy(b[4:8], "H265") + binary.LittleEndian.PutUint32(b[8:12], 3) + binary.LittleEndian.PutUint32(b[12:16], 4) + copy(b[28:31], []byte{1, 2, 3}) + packets, err := p.Append(b) + if err != nil || len(packets) != 1 || !bytes.Equal(packets[0].Data, []byte{1, 2, 3}) { + t.Fatalf("unexpected P-frame result: %+v err=%v", packets, err) + } +} + +func TestMediaParserRejectsADPCMUnderflow(t *testing.T) { + p, _ := NewMediaParser(Limits{}) + b := make([]byte, 16) + binary.LittleEndian.PutUint32(b, mediaADPCM) + binary.LittleEndian.PutUint16(b[4:6], 2) + binary.LittleEndian.PutUint16(b[6:8], 2) + if _, err := p.Append(b); err == nil || !strings.Contains(err.Error(), "ADPCM payload") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMediaParserRejectsInconsistentAudioSize(t *testing.T) { + p, _ := NewMediaParser(Limits{}) + b := make([]byte, 16) + binary.LittleEndian.PutUint32(b, mediaAAC) + binary.LittleEndian.PutUint16(b[4:6], 2) + binary.LittleEndian.PutUint16(b[6:8], 3) + if _, err := p.Append(b); err == nil || !strings.Contains(err.Error(), "inconsistent audio") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMediaParserUnpaddedFragmentDoesNotConsumeNextHeader(t *testing.T) { + audio := make([]byte, 13) + binary.LittleEndian.PutUint32(audio, mediaAAC) + binary.LittleEndian.PutUint16(audio[4:6], 5) + binary.LittleEndian.PutUint16(audio[6:8], 5) + copy(audio[8:], "audio") + info := infoFixture() + p, _ := NewMediaParser(Limits{}) + packets, err := p.Append(append(audio, info[:3]...)) + if err != nil || len(packets) != 0 { + t.Fatalf("unexpected first fragment: packets=%d err=%v", len(packets), err) + } + packets, err = p.Append(info[3:]) + if err != nil || len(packets) != 2 || string(packets[0].Data) != "audio" || packets[1].Kind != MediaInfo { + t.Fatalf("unexpected completed packets: %+v err=%v", packets, err) + } +} + +func TestMediaParserFrameLimit(t *testing.T) { + p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64}) + if err != nil { + t.Fatal(err) + } + b := make([]byte, 24) + binary.LittleEndian.PutUint32(b, mediaPFrameMin) + copy(b[4:8], "H264") + binary.LittleEndian.PutUint32(b[8:12], 100) + if _, err = p.Append(b); err == nil || !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("unexpected error: %v", err) + } + + p, _ = NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64}) + binary.LittleEndian.PutUint32(b, mediaIFrameMin) + binary.LittleEndian.PutUint32(b[8:12], 0) + binary.LittleEndian.PutUint32(b[12:16], ^uint32(0)) + if _, err = p.Append(b); err == nil || !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("unexpected I-frame error: %v", err) + } + + p, _ = NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64}) + b = make([]byte, 72) + binary.LittleEndian.PutUint32(b, mediaIFrameMin) + copy(b[4:8], "H264") + binary.LittleEndian.PutUint32(b[8:12], 40) + if _, err = p.Append(append(b, infoFixture()...)); err == nil || + !strings.Contains(err.Error(), "video frame 72 exceeds limit 64") { + t.Fatalf("accepted adjusted I-frame beyond limit: %v", err) + } +} + +func TestMediaParserScansFragmentedKeyframeLinearly(t *testing.T) { + const resync = 4096 + p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 8192, MaxResync: resync}) + if err != nil { + t.Fatal(err) + } + header := make([]byte, 24) + binary.LittleEndian.PutUint32(header, mediaIFrameMin) + copy(header[4:8], "H264") + if _, err = p.Append(header); err != nil { + t.Fatal(err) + } + previous := len(header) + for range resync + 24 { + _, err = p.Append([]byte{0}) + if p.scan != 0 { + if p.scan-previous > 1 { + t.Fatalf("rescanned %d keyframe offsets", p.scan-previous) + } + previous = p.scan + } + } + if err == nil || !strings.Contains(err.Error(), "no keyframe boundary") { + t.Fatalf("unexpected fragmented keyframe result: %v", err) + } +} + +func TestMediaParserAcceptsKeyframeBoundaryAtResyncLimit(t *testing.T) { + const resync = 64 + p, err := NewMediaParser(Limits{MaxMediaFrame: 128, MaxMediaBuffer: 216, MaxResync: resync}) + if err != nil { + t.Fatal(err) + } + b := make([]byte, 24+resync+24) + binary.LittleEndian.PutUint32(b, mediaIFrameMin) + copy(b[4:8], "H264") + next := 24 + resync + binary.LittleEndian.PutUint32(b[next:], mediaPFrameMin) + copy(b[next+4:next+8], "H264") + packets, err := p.Append(b[:next+8]) + if err != nil || len(packets) != 0 { + t.Fatalf("rejected partial boundary: packets=%d err=%v", len(packets), err) + } + packets, err = p.Append(b[next+8:]) + if err != nil || len(packets) != 2 { + t.Fatalf("unexpected boundary result: packets=%d err=%v", len(packets), err) + } +} + +func TestMediaParserAppendLimit(t *testing.T) { + p, err := NewMediaParser(Limits{MaxMediaFrame: 64, MaxMediaBuffer: 160, MaxResync: 64}) + if err != nil { + t.Fatal(err) + } + if _, err = p.Append(make([]byte, 161)); err == nil || !strings.Contains(err.Error(), "media buffer exceeds limit 160") { + t.Fatalf("unexpected append error: %v", err) + } + if p.buf != nil { + t.Fatal("oversized append changed parser state") + } +} + +func FuzzMediaParser(f *testing.F) { + f.Add(videoFixture()) + f.Add(infoFixture()) + f.Fuzz(func(t *testing.T, data []byte) { + p, _ := NewMediaParser(Limits{MaxMediaFrame: 4096, MaxMediaBuffer: 8192, MaxResync: 1024}) + _, _ = p.Append(data) + }) +} diff --git a/pkg/baichuan/message.go b/pkg/baichuan/message.go new file mode 100644 index 000000000..0f7b624e0 --- /dev/null +++ b/pkg/baichuan/message.go @@ -0,0 +1,245 @@ +package baichuan + +import ( + "encoding/binary" + "fmt" + "io" +) + +const ( + wireMagic = 0x0abcdef0 + + classLegacy = 0x6514 + classModern = 0x6614 + classOffset = 0x6414 + classAlt = 0x0000 + + commandLogin = 1 + commandPreview = 3 + commandStopPreview = 4 + commandTalkAbility = 10 + commandStopTalk = 11 + commandDeviceInfo = 80 + commandPing = 93 + commandAbility = 151 + commandTalkConfig = 201 + commandTalkData = 202 +) + +type header struct { + Command uint32 + BodyLen uint32 + Channel uint8 + Stream uint8 + Sequence uint16 + ResponseCode uint16 + Class uint16 + PayloadOffset uint32 +} + +func (h header) hasOffset() bool { + return h.Class == classOffset || h.Class == classAlt +} + +type frame struct { + header header + body []byte +} + +type message struct { + header header + extension []byte + payload []byte + binary bool + encrypt int + hasEncryptLen bool + checkPos int + hasCheckPos bool +} + +type request struct { + command uint32 + channel uint8 + stream uint8 + sequence uint16 + class uint16 + extension []byte + payload []byte + binary bool + forceBC bool +} + +func readFrame(r io.Reader, limits Limits) (frame, error) { + var base [20]byte + if _, err := io.ReadFull(r, base[:]); err != nil { + return frame{}, err + } + if magic := binary.LittleEndian.Uint32(base[:4]); magic != wireMagic { + return frame{}, fmt.Errorf("baichuan: invalid message magic %#x", magic) + } + + h := header{ + Command: binary.LittleEndian.Uint32(base[4:8]), + BodyLen: binary.LittleEndian.Uint32(base[8:12]), + Channel: base[12], + Stream: base[13], + Sequence: binary.LittleEndian.Uint16(base[14:16]), + ResponseCode: binary.LittleEndian.Uint16(base[16:18]), + Class: binary.LittleEndian.Uint16(base[18:20]), + } + if h.Class != classLegacy && h.Class != classModern && h.Class != classOffset && h.Class != classAlt { + return frame{}, fmt.Errorf("baichuan: unsupported message class %#x", h.Class) + } + if h.BodyLen > limits.MaxBody { + return frame{}, fmt.Errorf("baichuan: message body %d exceeds limit %d", h.BodyLen, limits.MaxBody) + } + if h.hasOffset() { + var offset [4]byte + if _, err := io.ReadFull(r, offset[:]); err != nil { + return frame{}, err + } + h.PayloadOffset = binary.LittleEndian.Uint32(offset[:]) + if h.PayloadOffset > h.BodyLen || h.PayloadOffset > limits.MaxExtension { + return frame{}, fmt.Errorf("baichuan: invalid payload offset %d for body %d", h.PayloadOffset, h.BodyLen) + } + } + payload := h.BodyLen - h.PayloadOffset + if limit := payloadLimit(h.Command, limits); payload > limit { + return frame{}, fmt.Errorf("baichuan: command %d payload %d exceeds limit %d", h.Command, payload, limit) + } + + body := make([]byte, h.BodyLen) + if _, err := io.ReadFull(r, body); err != nil { + return frame{}, err + } + return frame{header: h, body: body}, nil +} + +func payloadLimit(command uint32, limits Limits) uint32 { + var limit uint32 + switch command { + case commandLogin: + limit = maxNonceBody + case commandTalkAbility: + limit = maxTalkAbilityBody + case commandDeviceInfo: + limit = maxDeviceInfoBody + case commandAbility: + limit = maxCapabilityBody + default: + return limits.MaxBody + } + if limit > limits.MaxBody { + return limits.MaxBody + } + return limit +} + +func decodeFrame(value frame, cipher cipherState, binaryHint bool) (message, error) { + if value.header.PayloadOffset > uint32(len(value.body)) { + return message{}, fmt.Errorf("baichuan: invalid payload offset %d for body %d", + value.header.PayloadOffset, len(value.body)) + } + offset := int(value.header.PayloadOffset) + extensionBody := value.body[:offset] + cipher.decryptInPlace(value.header.Channel, extensionBody) + extensionMeta, err := parseExtension(extensionBody) + if err != nil { + return message{}, err + } + binaryPayload := binaryHint || extensionMeta.BinaryData != nil && *extensionMeta.BinaryData == 1 + + payload := value.body[offset:] + encryptLen := 0 + if !binaryPayload { + cipher.decryptInPlace(value.header.Channel, payload) + } else { + if extensionMeta.EncryptLen != nil { + n := *extensionMeta.EncryptLen + if n < 0 || n > len(payload) { + return message{}, fmt.Errorf("baichuan: invalid encrypted prefix %d for payload %d", n, len(payload)) + } + if n > 0 { + cipher.decryptInPlace(value.header.Channel, payload[:n]) + } + encryptLen = n + } + } + msg := message{ + header: value.header, extension: extensionBody, payload: payload, + binary: binaryPayload, encrypt: encryptLen, hasEncryptLen: extensionMeta.EncryptLen != nil, + } + if extensionMeta.CheckPos != nil { + msg.checkPos = *extensionMeta.CheckPos + msg.hasCheckPos = true + } + return msg, nil +} + +func hasMediaMagic(b []byte) bool { + return len(b) >= 4 && knownMediaMagic(binary.LittleEndian.Uint32(b)) +} + +func encodeRequest(value request, limits Limits, cipher cipherState) ([]byte, error) { + if value.class != classLegacy && value.class != classModern && value.class != classOffset && value.class != classAlt { + return nil, fmt.Errorf("baichuan: unsupported request class %#x", value.class) + } + if value.forceBC { + cipher.mode = encryptionBC + } + bodyLen := uint64(len(value.extension)) + uint64(len(value.payload)) + if bodyLen > uint64(limits.MaxBody) || len(value.extension) > int(limits.MaxExtension) { + return nil, fmt.Errorf("baichuan: request exceeds configured limits") + } + + headerLen := 20 + if value.class == classOffset || value.class == classAlt { + headerLen = 24 + } + packet := make([]byte, headerLen+int(bodyLen)) + binary.LittleEndian.PutUint32(packet[0:4], wireMagic) + binary.LittleEndian.PutUint32(packet[4:8], value.command) + binary.LittleEndian.PutUint32(packet[8:12], uint32(bodyLen)) + packet[12] = value.channel + packet[13] = value.stream + binary.LittleEndian.PutUint16(packet[14:16], value.sequence) + if value.class == classLegacy && value.command == commandLogin && len(value.payload) == 0 { + binary.LittleEndian.PutUint16(packet[16:18], 0xdc12) + } + binary.LittleEndian.PutUint16(packet[18:20], value.class) + if headerLen == 24 { + binary.LittleEndian.PutUint32(packet[20:24], uint32(len(value.extension))) + } + extension := packet[headerLen : headerLen+len(value.extension)] + payload := packet[headerLen+len(value.extension):] + copy(extension, value.extension) + copy(payload, value.payload) + if value.class != classLegacy { + cipher.cryptInPlace(value.channel, extension, true) + if !value.binary { + cipher.cryptInPlace(value.channel, payload, true) + } + } + return packet, nil +} + +func responseError(h header) error { + if _, ok := negotiatedEncryption(h.ResponseCode); ok { + return nil + } + switch h.ResponseCode { + case 0, 200, 201, 300: + return nil + default: + return &StatusError{Command: h.Command, Code: h.ResponseCode} + } +} + +type StatusError struct { + Command uint32 + Code uint16 +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("baichuan: command %d failed with status %d", e.Command, e.Code) +} diff --git a/pkg/baichuan/message_test.go b/pkg/baichuan/message_test.go new file mode 100644 index 000000000..01e0814c9 --- /dev/null +++ b/pkg/baichuan/message_test.go @@ -0,0 +1,100 @@ +package baichuan + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "strings" + "testing" +) + +func testLimits(t *testing.T) Limits { + t.Helper() + limits, err := (Limits{}).normalized() + if err != nil { + t.Fatal(err) + } + return limits +} + +func TestEncodeNonceRequest(t *testing.T) { + packet, err := encodeRequest(request{ + command: commandLogin, sequence: 7, class: classLegacy, forceBC: true, + }, testLimits(t), cipherState{}) + if err != nil { + t.Fatal(err) + } + const expected = "f0debc0a01000000000000000000070012dc1465" + if actual := hex.EncodeToString(packet); actual != expected { + t.Fatalf("unexpected packet:\n%s\n%s", actual, expected) + } +} + +func TestReadFrameBounds(t *testing.T) { + limits := testLimits(t) + header := make([]byte, 24) + binary.LittleEndian.PutUint32(header, wireMagic) + binary.LittleEndian.PutUint32(header[8:], limits.MaxBody+1) + binary.LittleEndian.PutUint16(header[18:], classOffset) + if _, err := readFrame(bytes.NewReader(header), limits); err == nil || !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("unexpected error: %v", err) + } + + binary.LittleEndian.PutUint32(header[8:], 1) + binary.LittleEndian.PutUint32(header[20:], 2) + if _, err := readFrame(bytes.NewReader(header), limits); err == nil || !strings.Contains(err.Error(), "payload offset") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadFrameRejectsControlPayloadBeforeRead(t *testing.T) { + limits := testLimits(t) + for command, limit := range map[uint32]uint32{ + commandLogin: maxNonceBody, commandTalkAbility: maxTalkAbilityBody, + commandDeviceInfo: maxDeviceInfoBody, commandAbility: maxCapabilityBody, + } { + header := make([]byte, 20) + binary.LittleEndian.PutUint32(header, wireMagic) + binary.LittleEndian.PutUint32(header[4:], command) + binary.LittleEndian.PutUint32(header[8:], limit+1) + binary.LittleEndian.PutUint16(header[18:], classLegacy) + if _, err := readFrame(bytes.NewReader(header), limits); err == nil || + !strings.Contains(err.Error(), "payload") { + t.Fatalf("command %d read oversized payload: %v", command, err) + } + } +} + +func TestDecodeFrame(t *testing.T) { + state := cipherState{mode: encryptionBC} + ext := []byte("1") + body := append(testCrypt(state, 1, ext, true), []byte{1, 2, 3}...) + msg, err := decodeFrame(frame{ + header: header{Channel: 1, BodyLen: uint32(len(body)), PayloadOffset: uint32(len(ext))}, + body: body, + }, state, false) + if err != nil { + t.Fatal(err) + } + if !msg.binary || !bytes.Equal(msg.payload, []byte{1, 2, 3}) { + t.Fatalf("unexpected message: %+v", msg) + } +} + +func TestDecodeFrameRejectsInvalidOffset(t *testing.T) { + _, err := decodeFrame(frame{ + header: header{PayloadOffset: 2}, body: []byte{1}, + }, cipherState{}, false) + if err == nil || !strings.Contains(err.Error(), "invalid payload offset") { + t.Fatalf("unexpected error: %v", err) + } +} + +func FuzzReadFrame(f *testing.F) { + f.Add([]byte{0xf0, 0xde, 0xbc, 0x0a}) + f.Add(make([]byte, 24)) + limits, _ := (Limits{MaxBody: 4096, MaxExtension: 1024}).normalized() + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = readFrame(bytes.NewReader(data), limits) + }) +} diff --git a/pkg/baichuan/preview.go b/pkg/baichuan/preview.go new file mode 100644 index 000000000..ada33927d --- /dev/null +++ b/pkg/baichuan/preview.go @@ -0,0 +1,218 @@ +package baichuan + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" +) + +const previewQueueMessages = 64 + +func (c *Client) StartPreview(ctx context.Context, channel uint8, stream Stream) (*Preview, error) { + if err := c.Login(ctx); err != nil { + return nil, err + } + streamType, handle, err := stream.params() + if err != nil { + return nil, err + } + body, err := buildPreview(channel, stream, handle) + if err != nil { + return nil, fmt.Errorf("baichuan: build preview request: %w", err) + } + parser, err := NewMediaParser(c.cfg.Limits) + if err != nil { + return nil, err + } + p := &Preview{ + client: c, key: previewKey{channel: channel, stream: streamType}, stream: stream, + handle: handle, messages: make(chan message, previewQueueMessages), + queueLimit: int64(c.cfg.Limits.MaxMediaBuffer), done: make(chan struct{}), parser: parser, + } + if err = c.addPreview(p); err != nil { + return nil, err + } + if _, err = c.roundTrip(ctx, request{ + command: commandPreview, channel: channel, stream: streamType, class: classOffset, payload: body, + }); err != nil { + p.closeLocal(err) + return nil, fmt.Errorf("baichuan: start preview: %w", err) + } + return p, nil +} + +func (c *Client) addPreview(p *Preview) error { + c.previewMu.Lock() + defer c.previewMu.Unlock() + if _, ok := c.previews[p.key]; ok { + return fmt.Errorf("baichuan: preview already active for channel %d stream %d", p.key.channel, p.key.stream) + } + c.previews[p.key] = p + return nil +} + +func (c *Client) removePreview(p *Preview) { + c.previewMu.Lock() + if c.previews[p.key] == p { + delete(c.previews, p.key) + } + c.previewMu.Unlock() +} + +func (c *Client) getPreview(h header) *Preview { + c.previewMu.RLock() + p := c.previews[previewKey{channel: h.Channel, stream: h.Stream}] + c.previewMu.RUnlock() + return p +} + +type Preview struct { + client *Client + key previewKey + stream Stream + handle uint32 + + messages chan message + queueLimit int64 + queueBytes atomic.Int64 + done chan struct{} + close sync.Once + stop sync.Once + stopErr error + errMu sync.Mutex + err error + + readMu sync.Mutex + parser *MediaParser + packets []MediaPacket + next int +} + +func (p *Preview) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprintf(state, "baichuan.Preview{Channel:%d, Stream:%q}", p.key.channel, p.stream) +} + +func (p *Preview) deliver(msg message) { + select { + case <-p.done: + return + default: + } + size := int64(len(msg.extension) + len(msg.payload)) + if !p.reserveQueue(size) { + p.closeLocal(ErrPreviewOverflow) + return + } + select { + case p.messages <- msg: + case <-p.done: + p.queueBytes.Add(-size) + default: + p.queueBytes.Add(-size) + p.closeLocal(ErrPreviewOverflow) + } +} + +func (p *Preview) reserveQueue(size int64) bool { + for { + queued := p.queueBytes.Load() + if size > p.queueLimit-queued { + return false + } + if !p.queueBytes.CompareAndSwap(queued, queued+size) { + continue + } + return true + } +} + +func (p *Preview) Read(ctx context.Context) (MediaPacket, error) { + p.readMu.Lock() + defer p.readMu.Unlock() + for { + select { + case <-p.done: + return MediaPacket{}, p.Err() + case <-p.client.done: + return MediaPacket{}, p.client.Err() + default: + } + if p.next < len(p.packets) { + packet := p.packets[p.next] + p.packets[p.next] = MediaPacket{} + p.next++ + return packet, nil + } + select { + case msg := <-p.messages: + p.queueBytes.Add(-int64(len(msg.extension) + len(msg.payload))) + select { + case <-p.done: + return MediaPacket{}, p.Err() + default: + } + residue := len(p.parser.buf) + packets, err := p.parser.appendOwnedTo(msg.payload, p.packets) + if err != nil { + err = fmt.Errorf("baichuan: parse media with residue %d, extension %d, encrypted prefix %d (present %t), check position %d (present %t), payload %d, parsed %d, remaining %d: %w", + residue, len(msg.extension), msg.encrypt, msg.hasEncryptLen, msg.checkPos, msg.hasCheckPos, + len(msg.payload), len(packets), len(p.parser.buf), err) + p.closeLocal(err) + return MediaPacket{}, err + } + p.packets = packets + p.next = 0 + case <-p.done: + return MediaPacket{}, p.Err() + case <-p.client.done: + return MediaPacket{}, p.client.Err() + case <-ctx.Done(): + return MediaPacket{}, ctx.Err() + } + } +} + +func (p *Preview) Err() error { + p.errMu.Lock() + defer p.errMu.Unlock() + if p.err == nil { + return io.EOF + } + return p.err +} + +func (p *Preview) closeLocal(err error) { + p.close.Do(func() { + p.errMu.Lock() + p.err = err + p.errMu.Unlock() + p.client.removePreview(p) + close(p.done) + }) +} + +func (p *Preview) Close() error { + p.stop.Do(func() { + p.closeLocal(context.Canceled) + if p.client.ctx.Err() != nil { + return + } + body, err := buildStopPreview(p.key.channel, p.handle) + if err != nil { + p.stopErr = err + return + } + ctx, cancel := context.WithTimeout(p.client.ctx, p.client.cfg.Timeout) + defer cancel() + _, err = p.client.roundTrip(ctx, request{ + command: commandStopPreview, channel: p.key.channel, stream: p.key.stream, + class: classOffset, payload: body, + }) + if err != nil { + p.stopErr = fmt.Errorf("baichuan: stop preview: %w", err) + } + }) + return p.stopErr +} diff --git a/pkg/baichuan/talk.go b/pkg/baichuan/talk.go new file mode 100644 index 000000000..c411a7293 --- /dev/null +++ b/pkg/baichuan/talk.go @@ -0,0 +1,221 @@ +package baichuan + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" +) + +type UnsupportedTalkError struct { + Reason string +} + +func (e *UnsupportedTalkError) Error() string { + return "baichuan: talkback unsupported: " + e.Reason +} + +type TalkFormat struct { + SampleRate uint32 + SamplePrecision uint32 + SamplesPerBlock uint32 +} + +func (f TalkFormat) BytesPerBlock() int { + return 4 + int(f.SamplesPerBlock)/2 +} + +func (c *Client) ProbeTalk(ctx context.Context, channel uint8) (TalkFormat, error) { + if err := c.Login(ctx); err != nil { + return TalkFormat{}, err + } + config, err := c.talkConfig(ctx, channel) + if err != nil { + return TalkFormat{}, err + } + return formatOf(config), nil +} + +func (c *Client) talkConfig(ctx context.Context, channel uint8) (talkConfig, error) { + extension, err := buildTalkExtension(channel, false) + if err != nil { + return talkConfig{}, err + } + msg, err := c.roundTrip(ctx, request{ + command: commandTalkAbility, channel: channel, class: classOffset, extension: extension, + }) + if err != nil { + var status *StatusError + if errors.As(err, &status) && unsupportedTalkStatus(status.Code) { + return talkConfig{}, &UnsupportedTalkError{Reason: fmt.Sprintf("camera status %d", status.Code)} + } + return talkConfig{}, fmt.Errorf("baichuan: query talk ability: %w", err) + } + ability, err := decodeTalkAbility(msg.payload) + if err != nil { + return talkConfig{}, fmt.Errorf("baichuan: decode talk ability: %w", err) + } + return selectTalkConfig(channel, ability) +} + +func unsupportedTalkStatus(code uint16) bool { + switch code { + case 400, 404, 405, 501: + return true + default: + return false + } +} + +func formatOf(config talkConfig) TalkFormat { + return TalkFormat{ + SampleRate: config.Audio.SampleRate, SamplePrecision: config.Audio.SamplePrecision, + SamplesPerBlock: config.Audio.SamplesPerBlock, + } +} + +type Talk struct { + client *Client + channel uint8 + format TalkFormat + extension []byte + + mu sync.Mutex + closed bool + sequence uint16 + closeOnce sync.Once + closeDone chan struct{} + closeErr error +} + +func (t *Talk) String() string { + return fmt.Sprintf("baichuan.Talk{Channel:%d, Audio:%+v}", t.channel, t.format) +} + +func (t *Talk) GoString() string { + return t.String() +} + +func (c *Client) StartTalk(ctx context.Context, channel uint8) (*Talk, error) { + if err := c.Login(ctx); err != nil { + return nil, err + } + config, err := c.talkConfig(ctx, channel) + if err != nil { + return nil, err + } + if err = c.startTalk(ctx, config); err != nil { + return nil, err + } + extension, err := buildTalkExtension(channel, true) + if err != nil { + return nil, errors.Join(err, c.stopTalk(ctx, channel)) + } + return &Talk{ + client: c, channel: channel, format: formatOf(config), extension: extension, closeDone: make(chan struct{}), + }, nil +} + +func (c *Client) startTalk(ctx context.Context, config talkConfig) error { + extension, err := buildTalkExtension(config.Channel, false) + if err != nil { + return err + } + body, err := marshalDocument(talkConfigEnvelope{Config: config}) + if err != nil { + return err + } + req := request{ + command: commandTalkConfig, channel: config.Channel, class: classOffset, + extension: extension, payload: body, + } + if _, err = c.roundTrip(ctx, req); err != nil { + var status *StatusError + if !errors.As(err, &status) || status.Code != 422 { + return fmt.Errorf("baichuan: configure talk: %w", err) + } + if resetErr := c.stopTalk(ctx, config.Channel); resetErr != nil { + return errors.Join(fmt.Errorf("baichuan: reset stale talk: %w", err), resetErr) + } + if _, err = c.roundTrip(ctx, req); err != nil { + return fmt.Errorf("baichuan: configure talk after reset: %w", err) + } + } + return nil +} + +func (c *Client) stopTalk(ctx context.Context, channel uint8) error { + extension, err := buildTalkExtension(channel, false) + if err != nil { + return err + } + _, err = c.roundTrip(ctx, request{ + command: commandStopTalk, channel: channel, class: classOffset, extension: extension, + }) + var status *StatusError + if errors.As(err, &status) && status.Code == 422 { + return nil + } + return err +} + +func (t *Talk) Format() TalkFormat { + return t.format +} + +func (t *Talk) WriteBlock(ctx context.Context, block []byte) error { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return fmt.Errorf("baichuan: write talk: %w", context.Canceled) + } + if len(block) != t.format.BytesPerBlock() { + return fmt.Errorf("baichuan: ADPCM block size %d, want %d", len(block), t.format.BytesPerBlock()) + } + payload, err := buildTalkPayload(block, t.sequence) + if err != nil { + return err + } + t.sequence++ + return t.client.writeRequest(ctx, request{ + command: commandTalkData, channel: t.channel, class: classOffset, + extension: t.extension, payload: payload, binary: true, + }) +} + +func (t *Talk) Close(ctx context.Context) error { + t.closeOnce.Do(func() { + t.mu.Lock() + t.closed = true + t.mu.Unlock() + go func() { + closeCtx, cancel := context.WithTimeout(t.client.ctx, t.client.cfg.Timeout) + t.closeErr = t.client.stopTalk(closeCtx, t.channel) + cancel() + close(t.closeDone) + }() + }) + select { + case <-t.closeDone: + return t.closeErr + case <-ctx.Done(): + return ctx.Err() + } +} + +func buildTalkPayload(block []byte, sequence uint16) ([]byte, error) { + if len(block) > int(^uint16(0))-4 { + return nil, fmt.Errorf("baichuan: ADPCM block too large: %d", len(block)) + } + size := len(block) + 4 + total := 8 + size + int(padding(uint32(size))) + b := make([]byte, total) + binary.LittleEndian.PutUint32(b, mediaADPCM) + binary.LittleEndian.PutUint16(b[4:6], uint16(size)) + binary.LittleEndian.PutUint16(b[6:8], uint16(size)) + binary.LittleEndian.PutUint16(b[8:10], 0x0100) + binary.LittleEndian.PutUint16(b[10:12], sequence) + copy(b[12:], block) + return b, nil +} diff --git a/pkg/baichuan/talk_test.go b/pkg/baichuan/talk_test.go new file mode 100644 index 000000000..a342014fd --- /dev/null +++ b/pkg/baichuan/talk_test.go @@ -0,0 +1,247 @@ +package baichuan + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestClientTalk(t *testing.T) { + clientConn, cameraConn := net.Pipe() + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + client := newClient(context.Background(), cfg, clientConn) + defer client.Close() + + cameraErr := make(chan error, 1) + go func() { + cameraErr <- serveTalk(cameraConn, cfg) + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + talk, err := client.StartTalk(ctx, 2) + if err != nil { + t.Fatalf("%v; camera: %v", err, <-cameraErr) + } + format := talk.Format() + if format.SampleRate != 16000 || format.SamplePrecision != 16 || format.SamplesPerBlock != 1016 { + t.Fatalf("unexpected format: %+v", format) + } + encoder := ADPCMEncoder{} + block, err := encoder.EncodeBlock(make([]int16, format.SamplesPerBlock)) + if err != nil { + t.Fatal(err) + } + if err = talk.WriteBlock(ctx, block); err != nil { + t.Fatal(err) + } + if err = talk.Close(ctx); err != nil { + t.Fatal(err) + } + if err = talk.Close(ctx); err != nil { + t.Fatalf("second close: %v", err) + } + if err = <-cameraErr; err != nil { + t.Fatal(err) + } +} + +func serveTalk(conn net.Conn, cfg Config) error { + defer conn.Close() + aes, err := serveLogin(conn, cfg) + if err != nil { + return err + } + abilityRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + ability, err := decodeFrame(abilityRequest, aes, false) + if err != nil { + return err + } + if ability.header.Command != commandTalkAbility || ability.header.Channel != 2 || + !strings.Contains(string(ability.extension), "2") { + return fmt.Errorf("invalid talk ability request: %+v %s", ability.header, ability.extension) + } + body := []byte(`fullDuplexspeakeradpcm16000161016mono`) + if err = writeTestFrame(conn, header{ + Command: commandTalkAbility, Channel: 2, Sequence: ability.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, body, aes, false); err != nil { + return err + } + + configRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + config, err := decodeFrame(configRequest, aes, false) + if err != nil { + return err + } + if config.header.Command != commandTalkConfig || + !strings.Contains(string(config.payload), "1016") { + return fmt.Errorf("invalid talk config: %+v %s", config.header, config.payload) + } + if err = writeTestFrame(conn, header{ + Command: commandTalkConfig, Channel: 2, Sequence: config.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, nil, aes, false); err != nil { + return err + } + + dataFrame, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + data, err := decodeFrame(dataFrame, aes, true) + if err != nil { + return err + } + if data.header.Command != commandTalkData || len(data.payload) != 528 || + binary.LittleEndian.Uint32(data.payload) != mediaADPCM || + binary.LittleEndian.Uint16(data.payload[4:6]) != 516 { + return fmt.Errorf("invalid talk data: %+v payload=%d", data.header, len(data.payload)) + } + + stopRequest, err := readFrame(conn, cfg.Limits) + if err != nil { + return err + } + stop, err := decodeFrame(stopRequest, aes, false) + if err != nil { + return err + } + if stop.header.Command != commandStopTalk || stop.header.Channel != 2 { + return fmt.Errorf("invalid stop talk: %+v", stop.header) + } + return writeTestFrame(conn, header{ + Command: commandStopTalk, Channel: 2, Sequence: stop.header.Sequence, + ResponseCode: 200, Class: classOffset, + }, nil, nil, aes, false) +} + +func TestSelectTalkConfigSkipsInvalidADPCMProfile(t *testing.T) { + ability := &talkAbility{ + Duplexes: []talkDuplex{{Value: "fullDuplex"}}, + Modes: []talkMode{{Value: "speaker"}}, + } + for _, samples := range []uint32{1015, 1016} { + ability.Configs = append(ability.Configs, struct { + Value talkAudioConfig `xml:"audioConfig"` + }{Value: talkAudioConfig{ + AudioType: "adpcm", SampleRate: 16000, SamplePrecision: 16, + SamplesPerBlock: samples, SoundTrack: "mono", + }}) + } + config, err := selectTalkConfig(0, ability) + if err != nil { + t.Fatal(err) + } + if config.Audio.SamplesPerBlock != 1016 { + t.Fatalf("selected unsafe profile: %+v", config.Audio) + } +} + +func TestDecodeTalkAbilityLimits(t *testing.T) { + for _, body := range [][]byte{ + []byte(strings.Repeat(" ", maxTalkAbilityBody+1)), + []byte(`` + strings.Repeat(`fullDuplex`, + maxTalkAbilityOptions+1) + ``), + []byte(``), + } { + if _, err := decodeTalkAbility(body); err == nil { + t.Fatal("accepted oversized talk ability") + } + } +} + +func TestSelectTalkConfigRejectsIncompleteAbility(t *testing.T) { + valid := talkAudioConfig{ + AudioType: "adpcm", SampleRate: 16000, SamplePrecision: 16, + SamplesPerBlock: 1016, SoundTrack: "mono", + } + for _, test := range []struct { + name string + ability talkAbility + }{ + {name: "duplex", ability: talkAbility{Modes: []talkMode{{Value: "speaker"}}}}, + {name: "mode", ability: talkAbility{Duplexes: []talkDuplex{{Value: "fullDuplex"}}}}, + {name: "ADPCM", ability: talkAbility{ + Duplexes: []talkDuplex{{Value: "fullDuplex"}}, Modes: []talkMode{{Value: "speaker"}}, + }}, + } { + t.Run(test.name, func(t *testing.T) { + if test.name != "ADPCM" { + test.ability.Configs = append(test.ability.Configs, struct { + Value talkAudioConfig `xml:"audioConfig"` + }{Value: valid}) + } + if _, err := selectTalkConfig(0, &test.ability); err == nil { + t.Fatal("accepted incomplete talk ability") + } + }) + } +} + +func TestUnsupportedTalkStatus(t *testing.T) { + for _, code := range []uint16{400, 404, 405, 501} { + if !unsupportedTalkStatus(code) { + t.Fatalf("status %d should mean unsupported", code) + } + } + for _, code := range []uint16{401, 403, 422, 500, 503} { + if unsupportedTalkStatus(code) { + t.Fatalf("status %d should remain an operational error", code) + } + } +} + +func TestADPCMEncoder(t *testing.T) { + encoder := ADPCMEncoder{} + block, err := encoder.EncodeBlock([]int16{0, 0}) + if err != nil { + t.Fatal(err) + } + if len(block) != 5 || string(block) != "\x00\x00\x00\x00\x00" { + t.Fatalf("unexpected silence block: %x", block) + } + for _, samples := range [][]int16{nil, {1}, {1, 2, 3}} { + if _, err = encoder.EncodeBlock(samples); err == nil { + t.Fatalf("accepted %d samples", len(samples)) + } + } +} + +func TestDecodeADPCMBlock(t *testing.T) { + samples, err := DecodeADPCMBlock([]byte{0, 0, 0, 0, 0x10}) + if err != nil { + t.Fatal(err) + } + if len(samples) != 2 || samples[0] != 1 || samples[1] != 1 { + t.Fatalf("unexpected samples: %v", samples) + } + for _, block := range [][]byte{nil, {0, 0, 0, 0}, {0, 0, 89, 0, 0}} { + if _, err = DecodeADPCMBlock(block); err == nil { + t.Fatalf("accepted invalid block: %x", block) + } + } +} + +func FuzzDecodeADPCMBlock(f *testing.F) { + f.Add([]byte{0, 0, 0, 0, 0x10}) + f.Add([]byte{0, 0, 89, 0, 0}) + f.Fuzz(func(t *testing.T, block []byte) { + _, _ = DecodeADPCMBlock(block) + }) +} diff --git a/pkg/baichuan/talk_xml.go b/pkg/baichuan/talk_xml.go new file mode 100644 index 000000000..306a5278f --- /dev/null +++ b/pkg/baichuan/talk_xml.go @@ -0,0 +1,159 @@ +package baichuan + +import ( + "encoding/xml" + "fmt" + "strings" +) + +const ( + maxTalkAbilityBody = 64 << 10 + maxTalkAbilityOptions = 32 + maxTalkAbilityText = 64 +) + +type talkAudioConfig struct { + Priority *uint32 `xml:"priority,omitempty"` + AudioType string `xml:"audioType"` + SampleRate uint32 `xml:"sampleRate"` + SamplePrecision uint32 `xml:"samplePrecision"` + SamplesPerBlock uint32 `xml:"lengthPerEncoder"` + SoundTrack string `xml:"soundTrack"` +} + +type talkAbility struct { + Version string `xml:"version,attr"` + Duplexes []talkDuplex `xml:"duplexList"` + Modes []talkMode `xml:"audioStreamModeList"` + Configs []struct { + Value talkAudioConfig `xml:"audioConfig"` + } `xml:"audioConfigList"` +} + +type talkDuplex struct { + Value string `xml:"duplex"` +} + +type talkMode struct { + Value string `xml:"audioStreamMode"` +} + +type talkAbilityEnvelope struct { + XMLName xml.Name `xml:"body"` + Ability *talkAbility `xml:"TalkAbility"` +} + +type talkConfig struct { + Version string `xml:"version,attr"` + Channel uint8 `xml:"channelId"` + Duplex string `xml:"duplex"` + Mode string `xml:"audioStreamMode"` + Audio talkAudioConfig `xml:"audioConfig"` +} + +type talkConfigEnvelope struct { + XMLName xml.Name `xml:"body"` + Config talkConfig `xml:"TalkConfig"` +} + +type talkExtension struct { + XMLName xml.Name `xml:"Extension"` + Version string `xml:"version,attr"` + Channel uint8 `xml:"channelId"` + Binary *uint8 `xml:"binaryData,omitempty"` +} + +func decodeTalkAbility(b []byte) (*talkAbility, error) { + if len(b) > maxTalkAbilityBody { + return nil, fmt.Errorf("talk ability XML exceeds %d bytes", maxTalkAbilityBody) + } + var envelope talkAbilityEnvelope + if err := xml.Unmarshal(b, &envelope); err != nil { + return nil, err + } + if envelope.Ability == nil { + return nil, &UnsupportedTalkError{Reason: "ability missing"} + } + ability := envelope.Ability + if len(ability.Duplexes) > maxTalkAbilityOptions || len(ability.Modes) > maxTalkAbilityOptions || + len(ability.Configs) > maxTalkAbilityOptions || len(ability.Version) > maxTalkAbilityText { + return nil, fmt.Errorf("talk ability exceeds semantic limits") + } + for _, option := range ability.Duplexes { + if len(option.Value) > maxTalkAbilityText { + return nil, fmt.Errorf("talk ability exceeds semantic limits") + } + } + for _, option := range ability.Modes { + if len(option.Value) > maxTalkAbilityText { + return nil, fmt.Errorf("talk ability exceeds semantic limits") + } + } + for _, option := range ability.Configs { + if len(option.Value.AudioType) > maxTalkAbilityText || len(option.Value.SoundTrack) > maxTalkAbilityText { + return nil, fmt.Errorf("talk ability exceeds semantic limits") + } + } + return envelope.Ability, nil +} + +func selectTalkConfig(channel uint8, ability *talkAbility) (talkConfig, error) { + if len(ability.Duplexes) == 0 || len(ability.Modes) == 0 { + return talkConfig{}, &UnsupportedTalkError{Reason: "incomplete ability"} + } + var audio talkAudioConfig + var adpcm bool + for _, option := range ability.Configs { + if !strings.EqualFold(option.Value.AudioType, "adpcm") { + continue + } + adpcm = true + if validTalkAudio(option.Value) { + audio = option.Value + break + } + } + if !adpcm { + return talkConfig{}, &UnsupportedTalkError{Reason: "ADPCM profile missing"} + } + if audio.AudioType == "" { + return talkConfig{}, &UnsupportedTalkError{Reason: "invalid ADPCM profile"} + } + audio.Priority = nil + version := ability.Version + if version == "" { + version = "1.1" + } + duplex := ability.Duplexes[0].Value + for _, option := range ability.Duplexes { + if strings.EqualFold(option.Value, "fullDuplex") { + duplex = option.Value + break + } + } + mode := ability.Modes[0].Value + for _, option := range ability.Modes { + if strings.EqualFold(option.Value, "speaker") { + mode = option.Value + break + } + } + return talkConfig{ + Version: version, Channel: channel, Duplex: duplex, Mode: mode, Audio: audio, + }, nil +} + +func validTalkAudio(audio talkAudioConfig) bool { + return audio.SampleRate >= 8000 && audio.SampleRate <= 48000 && audio.SamplePrecision == 16 && + audio.SamplesPerBlock >= 2 && audio.SamplesPerBlock <= 8192 && audio.SamplesPerBlock&1 == 0 && + (audio.SoundTrack == "" || strings.EqualFold(audio.SoundTrack, "mono")) +} + +func buildTalkExtension(channel uint8, binaryData bool) ([]byte, error) { + extension := talkExtension{Version: "1.1", Channel: channel} + if binaryData { + value := uint8(1) + extension.Binary = &value + } + return marshalDocument(extension) +} diff --git a/pkg/baichuan/transport.go b/pkg/baichuan/transport.go new file mode 100644 index 000000000..908685d56 --- /dev/null +++ b/pkg/baichuan/transport.go @@ -0,0 +1,81 @@ +package baichuan + +import ( + "context" + "io" + "net" + "sync" + "time" +) + +type transport interface { + io.Reader + io.Writer + SetWriteDeadline(time.Time) error + Close() error +} + +func dialTCP(ctx context.Context, cfg Config) (transport, error) { + dialer := net.Dialer{Timeout: cfg.Timeout} + conn, err := dialer.DialContext(ctx, "tcp", cfg.address()) + if err != nil { + return nil, err + } + return conn, nil +} + +func writeFull(ctx context.Context, conn transport, timeout time.Duration, b []byte) error { + deadline := time.Now().Add(timeout) + if value, ok := ctx.Deadline(); ok && value.Before(deadline) { + deadline = value + } + if err := conn.SetWriteDeadline(deadline); err != nil { + return err + } + var interrupt sync.WaitGroup + interrupt.Add(1) + stop := context.AfterFunc(ctx, func() { + _ = conn.SetWriteDeadline(time.Now()) + interrupt.Done() + }) + defer func() { + if stop() { + interrupt.Done() + } + interrupt.Wait() + _ = conn.SetWriteDeadline(time.Time{}) + }() + + for len(b) > 0 { + n, err := conn.Write(b) + if n > 0 { + b = b[n:] + } + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + if n == 0 { + return io.ErrShortWrite + } + } + return nil +} + +func interruptDeadline(ctx context.Context, set func(time.Time) error) func() { + var done sync.WaitGroup + done.Add(1) + stop := context.AfterFunc(ctx, func() { + _ = set(time.Now()) + done.Done() + }) + return func() { + if stop() { + done.Done() + } + done.Wait() + _ = set(time.Time{}) + } +} diff --git a/pkg/baichuan/transport_test.go b/pkg/baichuan/transport_test.go new file mode 100644 index 000000000..0da6aff4c --- /dev/null +++ b/pkg/baichuan/transport_test.go @@ -0,0 +1,144 @@ +package baichuan + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "sync" + "testing" + "time" +) + +type shortTransport struct { + bytes.Buffer + step int +} + +type failingTransport struct { + closed chan struct{} + once sync.Once +} + +type cancelTransport struct { + shortTransport + cancel context.CancelFunc + started, release, wrote chan struct{} + mu sync.Mutex + deadlines int +} + +func (t *failingTransport) Read([]byte) (int, error) { + <-t.closed + return 0, io.EOF +} + +func (t *failingTransport) Write([]byte) (int, error) { return 1, io.ErrUnexpectedEOF } +func (t *failingTransport) SetWriteDeadline(time.Time) error { return nil } +func (t *failingTransport) Close() error { + t.once.Do(func() { close(t.closed) }) + return nil +} + +func (t *shortTransport) Write(b []byte) (int, error) { + if len(b) > t.step { + b = b[:t.step] + } + return t.Buffer.Write(b) +} + +func (t *shortTransport) SetWriteDeadline(time.Time) error { return nil } +func (t *shortTransport) Close() error { return nil } + +func (t *cancelTransport) Write(b []byte) (int, error) { + t.cancel() + <-t.started + close(t.wrote) + return len(b), nil +} + +func (t *cancelTransport) SetWriteDeadline(deadline time.Time) error { + if deadline.IsZero() { + return nil + } + t.mu.Lock() + t.deadlines++ + interrupt := t.deadlines == 2 + t.mu.Unlock() + if interrupt { + close(t.started) + <-t.release + } + return nil +} + +func TestWriteFullHandlesShortWrites(t *testing.T) { + conn := &shortTransport{step: 2} + if err := writeFull(context.Background(), conn, time.Second, []byte("abcdef")); err != nil { + t.Fatal(err) + } + if actual := conn.String(); actual != "abcdef" { + t.Fatalf("unexpected payload: %s", actual) + } +} + +func TestWriteFullWaitsForCancellationCallback(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + conn := &cancelTransport{ + cancel: cancel, started: make(chan struct{}), release: make(chan struct{}), wrote: make(chan struct{}), + } + released := false + defer func() { + if !released { + close(conn.release) + } + }() + done := make(chan error, 1) + go func() { done <- writeFull(ctx, conn, time.Second, []byte("payload")) }() + <-conn.wrote + select { + case err := <-done: + t.Fatalf("write returned while deadline callback was active: %v", err) + case <-time.After(20 * time.Millisecond): + } + close(conn.release) + released = true + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestClientCloseInterruptsFrameRead(t *testing.T) { + conn, peer := net.Pipe() + defer peer.Close() + client := newClient(context.Background(), Config{}, conn) + done := make(chan error, 1) + go func() { done <- client.Close() }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("client close did not interrupt frame read") + } +} + +func TestClientPartialWriteShutsDown(t *testing.T) { + cfg, err := NewConfig("camera.local", "admin", "password").normalized() + if err != nil { + t.Fatal(err) + } + client := newClient(context.Background(), cfg, &failingTransport{closed: make(chan struct{})}) + _, err = client.roundTrip(context.Background(), request{command: commandPing, class: classOffset}) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected write error: %v", err) + } + select { + case <-client.Done(): + default: + t.Fatal("client remained active after partial write") + } + client.wg.Wait() +} diff --git a/pkg/baichuan/udp.go b/pkg/baichuan/udp.go new file mode 100644 index 000000000..4d9f9337b --- /dev/null +++ b/pkg/baichuan/udp.go @@ -0,0 +1,157 @@ +package baichuan + +import ( + "encoding/binary" + "fmt" + "hash/crc32" +) + +const ( + uidMagicDiscovery uint32 = 0x2A87CF3A + uidMagicAck uint32 = 0x2A87CF20 + uidMagicData uint32 = 0x2A87CF10 + + uidDiscoveryHeader = 20 + uidAckHeader = 28 + uidDataHeader = 20 + uidMaxDatagram = 65507 +) + +var uidXMLKey = [...]uint32{ + 0x1f2d3c4b, 0x5a6c7f8d, 0x38172e4b, 0x8271635a, + 0x863f1a2b, 0xa5c6f7d8, 0x8371e1b4, 0x17f2d3a5, +} + +type uidPacket struct { + magic uint32 + connectionID int32 + packetID uint32 + transaction uint32 + payload []byte +} + +func marshalUIDDiscovery(transaction uint32, payload []byte) ([]byte, error) { + if err := checkUIDPayload(len(payload), uidDiscoveryHeader, uidMaxDatagram-uidDiscoveryHeader); err != nil { + return nil, err + } + b := make([]byte, uidDiscoveryHeader+len(payload)) + binary.LittleEndian.PutUint32(b, uidMagicDiscovery) + binary.LittleEndian.PutUint32(b[4:], uint32(len(payload))) + binary.LittleEndian.PutUint32(b[8:], 1) + binary.LittleEndian.PutUint32(b[12:], transaction) + binary.LittleEndian.PutUint32(b[16:], uidChecksum(payload)) + copy(b[uidDiscoveryHeader:], payload) + return b, nil +} + +func marshalUIDData(connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) { + b := make([]byte, uidDataHeader+len(payload)) + return encodeUIDData(b, connectionID, packetID, payload, maxPayload) +} + +func encodeUIDData(b []byte, connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) { + if err := checkUIDPayload(len(payload), uidDataHeader, maxPayload); err != nil { + return nil, err + } + if len(b) < uidDataHeader+len(payload) { + return nil, fmt.Errorf("baichuan: short UID data buffer") + } + b = b[:uidDataHeader+len(payload)] + clear(b[:uidDataHeader]) + binary.LittleEndian.PutUint32(b, uidMagicData) + binary.LittleEndian.PutUint32(b[4:], uint32(connectionID)) + binary.LittleEndian.PutUint32(b[12:], packetID) + binary.LittleEndian.PutUint32(b[16:], uint32(len(payload))) + copy(b[uidDataHeader:], payload) + return b, nil +} + +func marshalUIDAck(connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) { + b := make([]byte, uidAckHeader+len(payload)) + return encodeUIDAck(b, connectionID, packetID, payload, maxPayload) +} + +func encodeUIDAck(b []byte, connectionID int32, packetID uint32, payload []byte, maxPayload int) ([]byte, error) { + if err := checkUIDPayload(len(payload), uidAckHeader, maxPayload); err != nil { + return nil, err + } + if len(b) < uidAckHeader+len(payload) { + return nil, fmt.Errorf("baichuan: short UID ACK buffer") + } + b = b[:uidAckHeader+len(payload)] + clear(b[:uidAckHeader]) + binary.LittleEndian.PutUint32(b, uidMagicAck) + binary.LittleEndian.PutUint32(b[4:], uint32(connectionID)) + binary.LittleEndian.PutUint32(b[16:], packetID) + binary.LittleEndian.PutUint32(b[24:], uint32(len(payload))) + copy(b[uidAckHeader:], payload) + return b, nil +} + +func parseUIDPacket(b []byte, maxPayload int) (uidPacket, error) { + if len(b) < 4 { + return uidPacket{}, fmt.Errorf("baichuan: short UID packet") + } + if len(b) > uidMaxDatagram || maxPayload < 0 { + return uidPacket{}, fmt.Errorf("baichuan: UID packet exceeds limit") + } + p := uidPacket{magic: binary.LittleEndian.Uint32(b)} + var header, sizeAt int + switch p.magic { + case uidMagicDiscovery: + header, sizeAt = uidDiscoveryHeader, 4 + if len(b) >= header { + p.transaction = binary.LittleEndian.Uint32(b[12:]) + } + case uidMagicAck: + header, sizeAt = uidAckHeader, 24 + if len(b) >= header { + p.connectionID = int32(binary.LittleEndian.Uint32(b[4:])) + p.packetID = binary.LittleEndian.Uint32(b[16:]) + } + case uidMagicData: + header, sizeAt = uidDataHeader, 16 + if len(b) >= header { + p.connectionID = int32(binary.LittleEndian.Uint32(b[4:])) + p.packetID = binary.LittleEndian.Uint32(b[12:]) + } + default: + return uidPacket{}, fmt.Errorf("baichuan: unknown UID packet") + } + if len(b) < header { + return uidPacket{}, fmt.Errorf("baichuan: short UID packet") + } + size := binary.LittleEndian.Uint32(b[sizeAt:]) + if uint64(size) > uint64(maxPayload) || uint64(size) != uint64(len(b)-header) { + return uidPacket{}, fmt.Errorf("baichuan: invalid UID payload length") + } + p.payload = b[header:] + if p.magic == uidMagicDiscovery && uidChecksum(p.payload) != binary.LittleEndian.Uint32(b[16:]) { + return uidPacket{}, fmt.Errorf("baichuan: UID checksum mismatch") + } + return p, nil +} + +func checkUIDPayload(size, header, maxPayload int) error { + if size < 0 || maxPayload < 0 || size > maxPayload || size > uidMaxDatagram-header { + return fmt.Errorf("baichuan: UID payload exceeds limit") + } + return nil +} + +func xorUID(dst, src []byte, transaction uint32) { + dst = dst[:len(src)] + for i, value := range src { + key := uidXMLKey[(i>>2)%len(uidXMLKey)] + transaction + dst[i] = value ^ byte(key>>((i&3)*8)) + } +} + +func uidChecksum(b []byte) uint32 { + table := crc32.IEEETable + var sum uint32 + for _, value := range b { + sum = table[byte(sum)^value] ^ sum>>8 + } + return sum +} diff --git a/pkg/baichuan/udp_test.go b/pkg/baichuan/udp_test.go new file mode 100644 index 000000000..cd598130c --- /dev/null +++ b/pkg/baichuan/udp_test.go @@ -0,0 +1,68 @@ +package baichuan + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestUIDPacketRejectsMalformedInput(t *testing.T) { + valid, err := marshalUIDDiscovery(1, []byte("payload")) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + data []byte + max int + want string + }{ + {name: "short", data: valid[:3], max: 128, want: "short"}, + {name: "header", data: valid[:12], max: 128, want: "short"}, + {name: "unknown", data: append([]byte{1, 2, 3, 4}, valid[4:]...), max: 128, want: "unknown"}, + {name: "truncated", data: valid[:len(valid)-1], max: 128, want: "length"}, + {name: "trailing", data: append(append([]byte(nil), valid...), 0), max: 128, want: "length"}, + {name: "limited", data: valid, max: 3, want: "length"}, + {name: "negative limit", data: valid, max: -1, want: "limit"}, + {name: "datagram limit", data: make([]byte, uidMaxDatagram+1), max: uidMaxDatagram, want: "limit"}, + {name: "checksum", data: append([]byte(nil), valid...), max: 128, want: "checksum"}, + } + tests[len(tests)-1].data[len(valid)-1] ^= 1 + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := parseUIDPacket(test.data, test.max); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUIDPacketMarshalLimits(t *testing.T) { + payload := make([]byte, 9) + if _, err := marshalUIDData(1, 1, payload, 8); err == nil { + t.Fatal("data payload exceeded limit") + } + if _, err := marshalUIDAck(1, 1, payload, 8); err == nil { + t.Fatal("ack payload exceeded limit") + } + if _, err := marshalUIDDiscovery(1, make([]byte, uidMaxDatagram)); err == nil { + t.Fatal("discovery payload exceeded datagram limit") + } +} + +func TestUIDChecksumKnownVector(t *testing.T) { + if sum := uidChecksum([]byte("123456789")); sum != 0x2dfd2d88 { + t.Fatalf("unexpected checksum: %08x", sum) + } +} + +func FuzzUIDPacket(f *testing.F) { + for _, magic := range []uint32{uidMagicDiscovery, uidMagicAck, uidMagicData} { + b := make([]byte, uidAckHeader) + binary.LittleEndian.PutUint32(b, magic) + f.Add(b) + } + f.Fuzz(func(t *testing.T, b []byte) { + _, _ = parseUIDPacket(b, 2048) + }) +} diff --git a/pkg/baichuan/uid_conn.go b/pkg/baichuan/uid_conn.go new file mode 100644 index 000000000..dd471b363 --- /dev/null +++ b/pkg/baichuan/uid_conn.go @@ -0,0 +1,293 @@ +package baichuan + +import ( + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "sync" + "time" +) + +const ( + uidSendWindow = 64 + uidReceiveWindow = 128 + uidReadQueue = 512 + uidMaintenance = 25 * time.Millisecond + uidRetransmit = 100 * time.Millisecond + uidMaxRetransmit = time.Second + uidSocketReadBuffer = 1 << 20 +) + +var errUIDReadOverflow = errors.New("baichuan: UID read queue overflow") + +type uidBuffer [uidMTU]byte + +type uidChunk struct { + buffer *uidBuffer + data []byte +} + +type uidSendSlot struct { + buffer *uidBuffer + packet []byte + packetID uint32 + firstSend time.Time + lastSend time.Time + interval time.Duration + used bool +} + +type uidReceiveSlot struct { + chunk uidChunk + packetID uint32 + used bool +} + +type uidConn struct { + conn *net.UDPConn + remote netip.AddrPort + clientID int32 + cameraID int32 + timeout time.Duration + + done chan struct{} + closeOnce sync.Once + wg sync.WaitGroup + errMu sync.Mutex + err error + + readMu sync.Mutex + readCurrent uidChunk + readQueue chan uidChunk + readDeadline uidDeadline + + writeMu sync.Mutex + writeDeadline uidDeadline + socketWriteMu sync.Mutex + + sendMu sync.Mutex + sendSlots [uidSendWindow]uidSendSlot + sendCount int + nextSend uint32 + sendWake chan struct{} + + receiveMu sync.Mutex + receiveSlots [uidReceiveWindow]uidReceiveSlot + nextReceive uint32 + received bool + ackDirty bool + + pool sync.Pool +} + +func newUIDConn(discovery *uidDiscovery, timeout time.Duration) (*uidConn, error) { + if discovery == nil || discovery.conn == nil || !discovery.remote.IsValid() || + !discovery.remote.Addr().Is4() || discovery.remote.Port() == 0 || + discovery.clientID <= 0 || discovery.cameraID <= 0 || timeout <= 0 { + return nil, errors.New("baichuan: invalid UID connection") + } + if err := discovery.conn.SetReadBuffer(uidSocketReadBuffer); err != nil { + _ = discovery.conn.Close() + return nil, fmt.Errorf("baichuan: set UID receive buffer: %w", err) + } + _ = discovery.conn.SetReadDeadline(time.Time{}) + s := &uidConn{ + conn: discovery.conn, remote: discovery.remote, + clientID: discovery.clientID, cameraID: discovery.cameraID, timeout: timeout, + done: make(chan struct{}), readQueue: make(chan uidChunk, uidReadQueue), + sendWake: make(chan struct{}, 1), + } + s.readDeadline.init() + s.writeDeadline.init() + s.pool.New = func() any { return new(uidBuffer) } + s.wg.Add(2) + go s.readLoop() + go s.maintenanceLoop() + return s, nil +} + +func (s *uidConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + s.readMu.Lock() + defer s.readMu.Unlock() + for { + if len(s.readCurrent.data) != 0 { + n := copy(p, s.readCurrent.data) + s.readCurrent.data = s.readCurrent.data[n:] + if len(s.readCurrent.data) == 0 { + s.putBuffer(s.readCurrent.buffer) + s.readCurrent = uidChunk{} + } + return n, nil + } + select { + case <-s.done: + return 0, s.readError() + default: + } + select { + case s.readCurrent = <-s.readQueue: + continue + default: + } + deadline, changed, timeout := s.readDeadline.snapshot() + expired := !deadline.IsZero() && !time.Now().Before(deadline) + if expired { + return 0, os.ErrDeadlineExceeded + } + select { + case chunk := <-s.readQueue: + s.readCurrent = chunk + case <-changed: + case <-s.done: + return 0, s.readError() + case <-timeout: + return 0, os.ErrDeadlineExceeded + } + } +} + +func (s *uidConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + written := 0 + for len(p) != 0 { + n := len(p) + if n > uidMTU-uidDataHeader { + n = uidMTU - uidDataHeader + } + if err := s.writeChunk(p[:n]); err != nil { + return written, err + } + written += n + p = p[n:] + } + return written, nil +} + +func (s *uidConn) SetReadDeadline(value time.Time) error { + s.readDeadline.set(value) + return nil +} + +func (s *uidConn) SetWriteDeadline(value time.Time) error { + s.writeDeadline.set(value) + // A later datagram installs a fresh bounded socket deadline. Keeping the + // current one here prevents a concurrent clear from unbounding that write. + if value.IsZero() { + return nil + } + return s.conn.SetWriteDeadline(value) +} + +func (s *uidConn) Close() error { + s.shutdown(net.ErrClosed) + s.readDeadline.set(time.Time{}) + s.writeDeadline.set(time.Time{}) + s.wg.Wait() + s.writeMu.Lock() + s.releaseBuffers() + s.writeMu.Unlock() + return nil +} + +func (s *uidConn) shutdown(err error) { + s.closeOnce.Do(func() { + s.errMu.Lock() + s.err = err + s.errMu.Unlock() + close(s.done) + _ = s.conn.Close() + s.signalSend() + }) +} + +func (s *uidConn) readError() error { + s.errMu.Lock() + err := s.err + s.errMu.Unlock() + if errors.Is(err, net.ErrClosed) { + return io.EOF + } + if err == nil { + return io.EOF + } + return err +} + +func (s *uidConn) writeError() error { + if err := s.readError(); err != io.EOF { + return err + } + return io.ErrClosedPipe +} + +func (s *uidConn) getBuffer() *uidBuffer { + return s.pool.Get().(*uidBuffer) +} + +func (s *uidConn) putBuffer(b *uidBuffer) { + if b != nil { + s.pool.Put(b) + } +} + +func (s *uidConn) signalSend() { + select { + case s.sendWake <- struct{}{}: + default: + } +} + +func (s *uidConn) writeDatagram(packet []byte) error { + s.socketWriteMu.Lock() + deadline := time.Now().Add(s.timeout) + if value, _, _ := s.writeDeadline.snapshot(); !value.IsZero() && value.Before(deadline) { + deadline = value + } + if err := s.conn.SetWriteDeadline(deadline); err != nil { + s.socketWriteMu.Unlock() + return err + } + _, err := s.conn.WriteToUDPAddrPort(packet, s.remote) + s.socketWriteMu.Unlock() + return err +} + +func (s *uidConn) releaseBuffers() { + s.readMu.Lock() + if s.readCurrent.buffer != nil { + s.putBuffer(s.readCurrent.buffer) + s.readCurrent = uidChunk{} + } + for len(s.readQueue) != 0 { + chunk := <-s.readQueue + s.putBuffer(chunk.buffer) + } + s.readMu.Unlock() + s.sendMu.Lock() + for i := range s.sendSlots { + if s.sendSlots[i].used { + s.putBuffer(s.sendSlots[i].buffer) + s.sendSlots[i] = uidSendSlot{} + } + } + s.sendCount = 0 + s.sendMu.Unlock() + s.receiveMu.Lock() + for i := range s.receiveSlots { + if s.receiveSlots[i].used { + s.putBuffer(s.receiveSlots[i].chunk.buffer) + s.receiveSlots[i] = uidReceiveSlot{} + } + } + s.receiveMu.Unlock() +} diff --git a/pkg/baichuan/uid_conn_test.go b/pkg/baichuan/uid_conn_test.go new file mode 100644 index 000000000..a39c89858 --- /dev/null +++ b/pkg/baichuan/uid_conn_test.go @@ -0,0 +1,241 @@ +package baichuan + +import ( + "errors" + "io" + "math" + "net" + "os" + "testing" + "time" +) + +func TestUIDConnOrdersDataAndAcknowledges(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + sendUIDData(t, camera, conn, 1, []byte("B")) + sendUIDData(t, camera, conn, 0, []byte("A")) + b := make([]byte, 2) + if _, err := io.ReadFull(conn, b); err != nil { + t.Fatal(err) + } + if string(b) != "AB" { + t.Fatalf("unexpected ordered data: %q", b) + } + packet := readUIDPacket(t, camera, time.Second) + if packet.magic != uidMagicAck || packet.connectionID != conn.cameraID || + packet.packetID != 1 || len(packet.payload) != 0 { + t.Fatalf("unexpected ACK: %+v", packet) + } +} + +func TestUIDConnDoesNotRedeliverDuplicateData(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + sendUIDData(t, camera, conn, 0, []byte("A")) + b := make([]byte, 1) + if _, err := io.ReadFull(conn, b); err != nil || string(b) != "A" { + t.Fatalf("unexpected first delivery: %q %v", b, err) + } + sendUIDData(t, camera, conn, 0, []byte("A")) + if err := conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { + t.Fatal(err) + } + if _, err := conn.Read(b); !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("duplicate reached reader: %v", err) + } +} + +func TestUIDConnConsumesEmptyData(t *testing.T) { + conn, _ := newTestUIDConn(t, time.Second) + b := conn.getBuffer() + if !conn.handleUIDData(uidPacket{packetID: 0, payload: b[:0]}, b) { + t.Fatal("empty packet buffer was not consumed") + } + conn.receiveMu.Lock() + next := conn.nextReceive + conn.receiveMu.Unlock() + if next != 1 || len(conn.readQueue) != 0 { + t.Fatalf("empty packet reached reader: next=%d queued=%d", next, len(conn.readQueue)) + } +} + +func TestUIDConnDeadlinesAndClose(t *testing.T) { + conn, _ := newTestUIDConn(t, time.Second) + if err := conn.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("unexpected read deadline error: %v", err) + } + if err := conn.SetWriteDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + payload := make([]byte, (uidMTU-uidDataHeader)*(uidSendWindow+1)) + n, err := conn.Write(payload) + if !errors.Is(err, os.ErrDeadlineExceeded) || n != (uidMTU-uidDataHeader)*uidSendWindow { + t.Fatalf("unexpected bounded write: n=%d err=%v", n, err) + } + if err = conn.Close(); err != nil { + t.Fatal(err) + } + if _, err = conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatalf("unexpected closed read: %v", err) + } + if _, err = conn.Write([]byte{1}); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("unexpected closed write: %v", err) + } +} + +func TestUIDConnIgnoresWrongEndpoint(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + spoof, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer spoof.Close() + packet, err := marshalUIDData(conn.clientID, 0, []byte("spoof"), uidMTU-uidDataHeader) + if err != nil { + t.Fatal(err) + } + if _, err = spoof.WriteToUDPAddrPort(packet, conn.conn.LocalAddr().(*net.UDPAddr).AddrPort()); err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, []byte{1, 2, 3, 4}) + _ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) + if _, err = conn.Read(make([]byte, 1)); !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("spoofed packet reached reader: %v", err) + } +} + +func TestUIDConnSequenceWrap(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + conn.sendMu.Lock() + conn.nextSend = math.MaxUint32 + conn.sendMu.Unlock() + payload := make([]byte, uidMTU-uidDataHeader+1) + if _, err := conn.Write(payload); err != nil { + t.Fatal(err) + } + first := readUIDPacket(t, camera, time.Second) + second := readUIDPacket(t, camera, time.Second) + if first.packetID != math.MaxUint32 || second.packetID != 0 { + t.Fatalf("send sequence did not wrap: %d %d", first.packetID, second.packetID) + } + ack, err := marshalUIDAck(conn.clientID, 0, nil, uidSendWindow) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, ack) + waitFor(t, time.Second, func() bool { + conn.sendMu.Lock() + count := conn.sendCount + conn.sendMu.Unlock() + return count == 0 + }) + + conn.receiveMu.Lock() + conn.nextReceive = math.MaxUint32 + conn.receiveMu.Unlock() + sendUIDData(t, camera, conn, 0, []byte("B")) + sendUIDData(t, camera, conn, math.MaxUint32, []byte("A")) + b := make([]byte, 2) + if _, err = io.ReadFull(conn, b); err != nil || string(b) != "AB" { + t.Fatalf("receive sequence did not wrap: %q %v", b, err) + } +} + +func TestUIDConnReadQueueOverflowIsTerminal(t *testing.T) { + conn, _ := newTestUIDConn(t, time.Second) + for packetID := uint32(0); packetID <= uidReadQueue; packetID++ { + b := conn.getBuffer() + packet, err := encodeUIDData(b[:], conn.clientID, packetID, []byte{1}, 1) + if err != nil { + t.Fatal(err) + } + parsed, err := parseUIDPacket(packet, 1) + if err != nil { + t.Fatal(err) + } + if !conn.handleUIDData(parsed, b) { + conn.putBuffer(b) + } + } + select { + case <-conn.done: + case <-time.After(time.Second): + t.Fatal("overflow did not close connection") + } + if !errors.Is(conn.readError(), errUIDReadOverflow) { + t.Fatalf("unexpected overflow error: %v", conn.readError()) + } +} + +func newTestUIDConn(t *testing.T, timeout time.Duration) (*uidConn, *net.UDPConn) { + t.Helper() + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + camera, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + client.Close() + t.Fatal(err) + } + discovery := &uidDiscovery{ + conn: client, remote: camera.LocalAddr().(*net.UDPAddr).AddrPort(), + clientID: 1001, cameraID: 2002, + } + conn, err := newUIDConn(discovery, timeout) + if err != nil { + camera.Close() + t.Fatal(err) + } + t.Cleanup(func() { + _ = conn.Close() + _ = camera.Close() + }) + return conn, camera +} + +func sendUIDData(t *testing.T, camera *net.UDPConn, conn *uidConn, packetID uint32, payload []byte) { + t.Helper() + packet, err := marshalUIDData(conn.clientID, packetID, payload, uidMTU-uidDataHeader) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, packet) +} + +func writeUIDPacket(t *testing.T, camera *net.UDPConn, conn *uidConn, packet []byte) { + t.Helper() + if _, err := camera.WriteToUDPAddrPort(packet, conn.conn.LocalAddr().(*net.UDPAddr).AddrPort()); err != nil { + t.Fatal(err) + } +} + +func readUIDPacket(t *testing.T, camera *net.UDPConn, timeout time.Duration) uidPacket { + t.Helper() + if err := camera.SetReadDeadline(time.Now().Add(timeout)); err != nil { + t.Fatal(err) + } + b := make([]byte, uidMTU) + n, _, err := camera.ReadFromUDPAddrPort(b) + if err != nil { + t.Fatal(err) + } + packet, err := parseUIDPacket(b[:n], uidMTU-uidDataHeader) + if err != nil { + t.Fatal(err) + } + return packet +} + +func waitFor(t *testing.T, timeout time.Duration, ready func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !ready() { + if !time.Now().Before(deadline) { + t.Fatal("condition timed out") + } + time.Sleep(time.Millisecond) + } +} diff --git a/pkg/baichuan/uid_deadline.go b/pkg/baichuan/uid_deadline.go new file mode 100644 index 000000000..db6eeac46 --- /dev/null +++ b/pkg/baichuan/uid_deadline.go @@ -0,0 +1,51 @@ +package baichuan + +import ( + "sync" + "time" +) + +type uidDeadline struct { + mu sync.Mutex + value time.Time + changed chan struct{} + timer *time.Timer +} + +func (d *uidDeadline) set(value time.Time) { + d.mu.Lock() + if !d.timer.Stop() { + select { + case <-d.timer.C: + default: + } + } + close(d.changed) + d.value = value + d.changed = make(chan struct{}) + if !value.IsZero() { + duration := time.Until(value) + if duration < 0 { + duration = 0 + } + d.timer.Reset(duration) + } + d.mu.Unlock() +} + +func (d *uidDeadline) init() { + d.changed = make(chan struct{}) + d.timer = time.NewTimer(time.Hour) + d.timer.Stop() +} + +func (d *uidDeadline) snapshot() (time.Time, <-chan struct{}, <-chan time.Time) { + d.mu.Lock() + value, changed := d.value, d.changed + var timeout <-chan time.Time + if !value.IsZero() { + timeout = d.timer.C + } + d.mu.Unlock() + return value, changed, timeout +} diff --git a/pkg/baichuan/uid_discovery.go b/pkg/baichuan/uid_discovery.go new file mode 100644 index 000000000..7e3f5d4d9 --- /dev/null +++ b/pkg/baichuan/uid_discovery.go @@ -0,0 +1,278 @@ +package baichuan + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/xml" + "fmt" + "net" + "net/netip" + "strings" + "time" +) + +const ( + uidMTU = 1350 + uidDiscoveryInterval = 500 * time.Millisecond +) + +var uidDiscoveryPorts = [...]uint16{2015, 2018} + +type uidEnvelope struct { + XMLName xml.Name `xml:"P2P"` + Connect *uidConnect `xml:"C2D_C,omitempty"` + Response *uidResponse `xml:"D2C_C_R,omitempty"` +} + +type uidConnect struct { + UID string `xml:"uid"` + Client uidPortList `xml:"cli"` + CID int32 `xml:"cid"` + MTU int `xml:"mtu"` + Debug int `xml:"debug"` + OS string `xml:"p"` +} + +type uidPortList struct { + Port int `xml:"port"` +} + +type uidResponse struct { + CID int32 `xml:"cid"` + DID int32 `xml:"did"` + Rsp int `xml:"rsp"` + Timer string `xml:"timer"` +} + +type uidDiscovery struct { + conn *net.UDPConn + remote netip.AddrPort + clientID int32 + cameraID int32 +} + +func discoverUID(ctx context.Context, uid, local, broadcast string, timeout time.Duration) (*uidDiscovery, error) { + if !validUID(uid) { + return nil, fmt.Errorf("baichuan: invalid UID") + } + if timeout <= 0 { + return nil, fmt.Errorf("baichuan: UID discovery timeout must be positive") + } + var err error + var localAddr netip.Addr + if local != "" { + localAddr, err = netip.ParseAddr(strings.TrimSpace(local)) + if err != nil || !uidUnicastAddr(localAddr) { + return nil, fmt.Errorf("baichuan: invalid UID local address") + } + } + var broadcastAddr netip.Addr + if broadcast != "" { + broadcastAddr, err = netip.ParseAddr(strings.TrimSpace(broadcast)) + if err != nil || !uidDiscoveryAddr(broadcastAddr) { + return nil, fmt.Errorf("baichuan: invalid UID broadcast address") + } + } + broadcasts, err := uidBroadcasts(localAddr, broadcastAddr) + if err != nil { + return nil, err + } + + listenIP := net.IPv4zero + if localAddr.IsValid() { + listenIP = net.IP(localAddr.AsSlice()) + } + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: listenIP}) + if err != nil { + return nil, fmt.Errorf("baichuan: listen for UID discovery: %w", err) + } + defer interruptDeadline(ctx, conn.SetDeadline)() + if err = enableUIDBroadcast(conn); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("baichuan: enable UID broadcast: %w", err) + } + + clientID, transaction, err := uidRandomIDs() + if err != nil { + _ = conn.Close() + return nil, err + } + payload, err := xml.Marshal(uidEnvelope{Connect: &uidConnect{ + UID: uid, Client: uidPortList{Port: conn.LocalAddr().(*net.UDPAddr).Port}, + CID: clientID, MTU: uidMTU, OS: "MAC", + }}) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("baichuan: marshal UID discovery: %w", err) + } + payload = append([]byte(xml.Header), payload...) + xorUID(payload, payload, transaction) + packet, err := marshalUIDDiscovery(transaction, payload) + if err != nil { + _ = conn.Close() + return nil, err + } + + deadline := time.Now().Add(timeout) + if value, ok := ctx.Deadline(); ok && value.Before(deadline) { + deadline = value + } + if err = conn.SetWriteDeadline(deadline); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("baichuan: set UID discovery write deadline: %w", err) + } + for { + if err = ctx.Err(); err != nil { + _ = conn.Close() + return nil, err + } + if !time.Now().Before(deadline) { + _ = conn.Close() + return nil, fmt.Errorf("baichuan: UID discovery timed out") + } + var sendErr error + sent := false + for _, address := range broadcasts { + for _, port := range uidDiscoveryPorts { + if _, writeErr := conn.WriteToUDPAddrPort(packet, netip.AddrPortFrom(address, port)); writeErr != nil { + sendErr = writeErr + } else { + sent = true + } + } + } + if !sent { + _ = conn.Close() + return nil, fmt.Errorf("baichuan: broadcast UID discovery: %w", sendErr) + } + if discovery, ok := readUIDDiscovery(conn, deadline, clientID, transaction); ok { + return discovery, nil + } + } +} + +func validUID(uid string) bool { + return len(uid) != 0 && len(uid) <= 64 && strings.IndexFunc(uid, func(r rune) bool { + return !('0' <= r && r <= '9' || 'A' <= r && r <= 'Z' || 'a' <= r && r <= 'z') + }) < 0 +} + +func uidUnicastAddr(address netip.Addr) bool { + return address.Is4() && !address.IsLoopback() && + (address.IsGlobalUnicast() || address.IsLinkLocalUnicast()) +} + +func uidDiscoveryAddr(address netip.Addr) bool { + return address.Is4() && !address.IsUnspecified() && !address.IsLoopback() && !address.IsMulticast() +} + +func readUIDDiscovery(conn *net.UDPConn, deadline time.Time, clientID int32, transaction uint32) (*uidDiscovery, bool) { + readDeadline := time.Now().Add(uidDiscoveryInterval) + if deadline.Before(readDeadline) { + readDeadline = deadline + } + _ = conn.SetReadDeadline(readDeadline) + b := make([]byte, uidMTU) + for { + n, remote, err := conn.ReadFromUDPAddrPort(b) + if err != nil { + return nil, false + } + cameraID, ok := parseUIDResponse(b[:n], remote, clientID, transaction) + if !ok { + continue + } + return &uidDiscovery{ + conn: conn, remote: remote, clientID: clientID, cameraID: cameraID, + }, true + } +} + +func parseUIDResponse(b []byte, remote netip.AddrPort, clientID int32, transaction uint32) (int32, bool) { + packet, err := parseUIDPacket(b, uidMTU-uidDiscoveryHeader) + if err != nil || packet.magic != uidMagicDiscovery || packet.transaction != transaction || + !uidUnicastAddr(remote.Addr()) || remote.Port() == 0 { + return 0, false + } + xorUID(packet.payload, packet.payload, packet.transaction) + var envelope uidEnvelope + if xml.Unmarshal(packet.payload, &envelope) != nil || envelope.Response == nil || + envelope.Response.CID != clientID || envelope.Response.DID <= 0 || envelope.Response.Rsp != 0 { + return 0, false + } + return envelope.Response.DID, true +} + +func uidBroadcasts(local, broadcast netip.Addr) ([]netip.Addr, error) { + if broadcast.IsValid() { + return []netip.Addr{broadcast}, nil + } + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("baichuan: list UID interfaces: %w", err) + } + set := make(map[netip.Addr]struct{}) + for _, iface := range interfaces { + if iface.Flags&(net.FlagUp|net.FlagBroadcast) != net.FlagUp|net.FlagBroadcast || + iface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := iface.Addrs() + if err != nil { + return nil, fmt.Errorf("baichuan: list addresses for UID interface: %w", err) + } + for _, address := range addresses { + prefix, err := netip.ParsePrefix(address.String()) + if err != nil || !prefix.Addr().Is4() || prefix.Bits() >= 31 { + continue + } + if local.IsValid() && prefix.Addr() != local { + continue + } + base := prefix.Masked().Addr().As4() + bits := prefix.Bits() + value := binary.BigEndian.Uint32(base[:]) | uint32(1<<(32-bits)-1) + var broadcast [4]byte + binary.BigEndian.PutUint32(broadcast[:], value) + set[netip.AddrFrom4(broadcast)] = struct{}{} + } + } + if len(set) == 0 { + if local.IsValid() { + return nil, fmt.Errorf("baichuan: UID local address has no broadcast interface") + } + return nil, fmt.Errorf("baichuan: no IPv4 broadcast interface") + } + broadcasts := make([]netip.Addr, 0, len(set)) + for address := range set { + broadcasts = append(broadcasts, address) + } + return broadcasts, nil +} + +func uidRandomIDs() (int32, uint32, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, 0, fmt.Errorf("baichuan: generate UID identifiers: %w", err) + } + clientID := int32(binary.LittleEndian.Uint32(b[:4]) & 0x7fffffff) + if clientID == 0 { + clientID = 1 + } + return clientID, binary.LittleEndian.Uint32(b[4:]), nil +} + +func enableUIDBroadcast(conn *net.UDPConn) error { + raw, err := conn.SyscallConn() + if err != nil { + return err + } + var optionErr error + if err = raw.Control(func(fd uintptr) { + optionErr = setUIDBroadcast(fd) + }); err != nil { + return err + } + return optionErr +} diff --git a/pkg/baichuan/uid_discovery_test.go b/pkg/baichuan/uid_discovery_test.go new file mode 100644 index 000000000..614d43f6b --- /dev/null +++ b/pkg/baichuan/uid_discovery_test.go @@ -0,0 +1,78 @@ +package baichuan + +import ( + "context" + "encoding/xml" + "net" + "net/netip" + "testing" + "time" +) + +func TestUIDDiscoveryRejectsInvalidConfig(t *testing.T) { + for _, uid := range []string{"", "bad uid", string(make([]byte, 65))} { + if _, err := discoverUID(context.Background(), uid, "", "", time.Second); err == nil { + t.Fatalf("accepted UID of length %d", len(uid)) + } + } + if _, err := discoverUID(context.Background(), "valid", "", "", 0); err == nil { + t.Fatal("accepted zero timeout") + } + if _, err := discoverUID(context.Background(), "valid", "not-an-address", "", time.Second); err == nil { + t.Fatal("accepted invalid local address") + } + if _, err := discoverUID(context.Background(), "valid", "", "127.0.0.1", time.Second); err == nil { + t.Fatal("accepted invalid broadcast address") + } +} + +func TestUIDBroadcastSocketOption(t *testing.T) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err = enableUIDBroadcast(conn); err != nil { + t.Fatal(err) + } +} + +func TestUIDBroadcastSelectionRejectsInactiveAddress(t *testing.T) { + if _, err := uidBroadcasts(netip.MustParseAddr("192.0.2.1"), netip.Addr{}); err == nil { + t.Fatal("accepted inactive UID local address") + } +} + +func TestUIDRoutedBroadcastOverride(t *testing.T) { + local := netip.MustParseAddr("192.0.2.211") + want := netip.MustParseAddr("198.51.100.255") + broadcasts, err := uidBroadcasts(local, want) + if err != nil || len(broadcasts) != 1 || broadcasts[0] != want { + t.Fatalf("unexpected UID broadcast override: %v %v", broadcasts, err) + } +} + +func TestUIDDiscoveryResponseMatchesTransaction(t *testing.T) { + const clientID int32 = 71 + const transaction uint32 = 29 + payload, err := xml.Marshal(uidEnvelope{Response: &uidResponse{CID: clientID, DID: 83, Rsp: 0}}) + if err != nil { + t.Fatal(err) + } + payload = append([]byte(xml.Header), payload...) + xorUID(payload, payload, transaction) + packet, err := marshalUIDDiscovery(transaction, payload) + if err != nil { + t.Fatal(err) + } + remote := netip.MustParseAddrPort("203.0.113.67:2015") + if _, ok := parseUIDResponse(append([]byte(nil), packet...), remote, clientID, transaction+1); ok { + t.Fatal("accepted UID discovery response for another transaction") + } + if _, ok := parseUIDResponse(append([]byte(nil), packet...), remote, clientID+1, transaction); ok { + t.Fatal("accepted UID discovery response for another client") + } + if cameraID, ok := parseUIDResponse(packet, remote, clientID, transaction); !ok || cameraID != 83 { + t.Fatalf("valid UID response = %d, %t", cameraID, ok) + } +} diff --git a/pkg/baichuan/uid_loop.go b/pkg/baichuan/uid_loop.go new file mode 100644 index 000000000..d478e0299 --- /dev/null +++ b/pkg/baichuan/uid_loop.go @@ -0,0 +1,210 @@ +package baichuan + +import ( + "encoding/xml" + "fmt" + "time" +) + +func (s *uidConn) readLoop() { + defer s.wg.Done() + for { + b := s.getBuffer() + n, remote, err := s.conn.ReadFromUDPAddrPort(b[:]) + if err != nil { + s.putBuffer(b) + select { + case <-s.done: + return + default: + s.shutdown(fmt.Errorf("baichuan: read UID transport: %w", err)) + return + } + } + if remote != s.remote { + s.putBuffer(b) + continue + } + packet, err := parseUIDPacket(b[:n], uidMTU-uidDataHeader) + if err != nil { + s.putBuffer(b) + continue + } + switch packet.magic { + case uidMagicAck: + s.handleUIDAck(packet) + s.putBuffer(b) + case uidMagicData: + if packet.connectionID != s.clientID { + s.putBuffer(b) + } else if !s.handleUIDData(packet, b) { + s.putBuffer(b) + } + case uidMagicDiscovery: + s.handleUIDControl(packet) + s.putBuffer(b) + default: + s.putBuffer(b) + } + } +} + +func (s *uidConn) handleUIDAck(packet uidPacket) { + if packet.connectionID != s.clientID || len(packet.payload) > uidSendWindow { + return + } + s.sendMu.Lock() + distance := uint32(s.nextSend - packet.packetID) + if distance == 0 || distance > uidSendWindow { + s.sendMu.Unlock() + return + } + freed := false + for i := range s.sendSlots { + slot := &s.sendSlots[i] + if !slot.used { + continue + } + acked := int32(slot.packetID-packet.packetID) <= 0 + if !acked { + distance := uint32(slot.packetID - packet.packetID - 1) + acked = distance < uint32(len(packet.payload)) && packet.payload[distance] != 0 + } + if acked { + s.putBuffer(slot.buffer) + *slot = uidSendSlot{} + s.sendCount-- + freed = true + } + } + if freed { + s.signalSend() + } + s.sendMu.Unlock() +} + +func (s *uidConn) handleUIDData(packet uidPacket, b *uidBuffer) bool { + s.receiveMu.Lock() + distance := int32(packet.packetID - s.nextReceive) + if distance < 0 { + s.ackDirty = s.received + s.receiveMu.Unlock() + return false + } + if distance >= uidReceiveWindow { + s.ackDirty = s.received + s.receiveMu.Unlock() + return false + } + index := packet.packetID % uidReceiveWindow + slot := &s.receiveSlots[index] + if slot.used { + s.ackDirty = s.received + s.receiveMu.Unlock() + return false + } + *slot = uidReceiveSlot{ + packetID: packet.packetID, + chunk: uidChunk{buffer: b, data: packet.payload}, + used: true, + } + s.ackDirty = true + for { + slot = &s.receiveSlots[s.nextReceive%uidReceiveWindow] + if !slot.used || slot.packetID != s.nextReceive { + break + } + chunk := slot.chunk + *slot = uidReceiveSlot{} + s.nextReceive++ + s.received = true + if len(chunk.data) == 0 { + s.putBuffer(chunk.buffer) + continue + } + select { + case s.readQueue <- chunk: + case <-s.done: + s.putBuffer(chunk.buffer) + s.receiveMu.Unlock() + return true + default: + s.putBuffer(chunk.buffer) + s.receiveMu.Unlock() + s.shutdown(errUIDReadOverflow) + return true + } + } + s.receiveMu.Unlock() + return true +} + +func (s *uidConn) handleUIDControl(packet uidPacket) { + xorUID(packet.payload, packet.payload, packet.transaction) + var envelope struct { + Disconnect *struct { + CID int32 `xml:"cid"` + DID int32 `xml:"did"` + } `xml:"D2C_DISC"` + } + if xml.Unmarshal(packet.payload, &envelope) != nil { + return + } + if envelope.Disconnect != nil && + envelope.Disconnect.CID == s.clientID && envelope.Disconnect.DID == s.cameraID { + s.shutdown(fmt.Errorf("baichuan: camera closed UID transport")) + } +} + +func (s *uidConn) maintenanceLoop() { + defer s.wg.Done() + ticker := time.NewTicker(uidMaintenance) + defer ticker.Stop() + for { + select { + case <-s.done: + return + case now := <-ticker.C: + if err := s.retransmitUID(now); err != nil { + s.shutdown(err) + return + } + if err := s.writeUIDAck(); err != nil { + s.shutdown(err) + return + } + } + } +} + +func (s *uidConn) writeUIDAck() error { + s.receiveMu.Lock() + if !s.received || !s.ackDirty { + s.receiveMu.Unlock() + return nil + } + packetID := s.nextReceive - 1 + last := -1 + var payload [uidReceiveWindow]byte + for distance := 0; distance < uidReceiveWindow; distance++ { + id := s.nextReceive + uint32(distance) + slot := &s.receiveSlots[id%uidReceiveWindow] + if slot.used && slot.packetID == id { + payload[distance] = 1 + last = distance + } + } + s.ackDirty = false + s.receiveMu.Unlock() + + b := s.getBuffer() + packet, err := encodeUIDAck(b[:], s.cameraID, packetID, payload[:last+1], uidReceiveWindow) + if err == nil { + err = s.writeDatagram(packet) + } + s.putBuffer(b) + if err != nil { + return fmt.Errorf("baichuan: write UID acknowledgement: %w", err) + } + return nil +} diff --git a/pkg/baichuan/uid_reliability_test.go b/pkg/baichuan/uid_reliability_test.go new file mode 100644 index 000000000..73c635f0b --- /dev/null +++ b/pkg/baichuan/uid_reliability_test.go @@ -0,0 +1,168 @@ +package baichuan + +import ( + "bytes" + "encoding/xml" + "os" + "strings" + "testing" + "time" +) + +func TestUIDConnRetransmitsUntilAcknowledged(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + if _, err := conn.Write([]byte("request")); err != nil { + t.Fatal(err) + } + first := readUIDPacket(t, camera, time.Second) + second := readUIDPacket(t, camera, time.Second) + if first.magic != uidMagicData || second.magic != uidMagicData || + first.packetID != 0 || second.packetID != 0 || + !bytes.Equal(first.payload, second.payload) { + t.Fatalf("unexpected retransmission: first=%+v second=%+v", first, second) + } + ack, err := marshalUIDAck(conn.clientID, 0, nil, uidSendWindow) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, ack) + waitFor(t, time.Second, func() bool { + conn.sendMu.Lock() + count := conn.sendCount + conn.sendMu.Unlock() + return count == 0 + }) +} + +func TestUIDConnSelectiveAcknowledgement(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + payload := make([]byte, (uidMTU-uidDataHeader)*2+1) + if _, err := conn.Write(payload); err != nil { + t.Fatal(err) + } + for range 3 { + _ = readUIDPacket(t, camera, time.Second) + } + ack, err := marshalUIDAck(conn.clientID, 0, []byte{0, 1}, uidSendWindow) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, ack) + waitFor(t, time.Second, func() bool { + conn.sendMu.Lock() + defer conn.sendMu.Unlock() + return conn.sendCount == 1 && conn.sendSlots[1].used + }) + ack, err = marshalUIDAck(conn.clientID, 1, nil, uidSendWindow) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, ack) + waitFor(t, time.Second, func() bool { + conn.sendMu.Lock() + defer conn.sendMu.Unlock() + return conn.sendCount == 0 + }) +} + +func TestUIDConnSendWindowPreservesUnacknowledgedSlot(t *testing.T) { + conn := &uidConn{done: make(chan struct{}), sendWake: make(chan struct{}, 1)} + conn.writeDeadline.init() + conn.nextSend = uidSendWindow + 1 + conn.sendCount = 1 + conn.sendSlots[1] = uidSendSlot{packetID: 1, used: true} + conn.writeDeadline.set(time.Now().Add(-time.Second)) + if packetID, err := conn.reserveSend(); err != os.ErrDeadlineExceeded { + t.Fatalf("reserved packet %d over unacknowledged slot: %v", packetID, err) + } + if conn.nextSend != uidSendWindow+1 || conn.sendCount != 1 { + t.Fatalf("send window changed: next=%d pending=%d", conn.nextSend, conn.sendCount) + } + conn.writeDeadline.set(time.Time{}) + conn.releaseSend(1) + packetID, err := conn.reserveSend() + if err != nil || packetID != uidSendWindow+1 { + t.Fatalf("reservation after release = %d, %v", packetID, err) + } + conn.cancelSendReservation() +} + +func TestUIDConnRejectsFutureAcknowledgement(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + if _, err := conn.Write([]byte("request")); err != nil { + t.Fatal(err) + } + _ = readUIDPacket(t, camera, time.Second) + ack, err := marshalUIDAck(conn.clientID, 1, nil, uidSendWindow) + if err != nil { + t.Fatal(err) + } + parsed, err := parseUIDPacket(ack, uidSendWindow) + if err != nil { + t.Fatal(err) + } + conn.handleUIDAck(parsed) + conn.sendMu.Lock() + pending := conn.sendCount + conn.sendMu.Unlock() + if pending != 1 { + t.Fatalf("future ACK changed send window: pending=%d", pending) + } + ack, err = marshalUIDAck(conn.clientID, 0, nil, uidSendWindow) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, ack) + waitFor(t, time.Second, func() bool { + conn.sendMu.Lock() + defer conn.sendMu.Unlock() + return conn.sendCount == 0 + }) +} + +func TestUIDConnAcknowledgementTimeoutIsTerminal(t *testing.T) { + conn, _ := newTestUIDConn(t, 120*time.Millisecond) + if _, err := conn.Write([]byte("request")); err != nil { + t.Fatal(err) + } + select { + case <-conn.done: + case <-time.After(time.Second): + t.Fatal("missing acknowledgement did not close transport") + } + if err := conn.readError(); err == nil || !strings.Contains(err.Error(), "acknowledgement timed out") { + t.Fatalf("unexpected terminal error: %v", err) + } +} + +func TestUIDConnCameraDisconnectIsTerminal(t *testing.T) { + conn, camera := newTestUIDConn(t, time.Second) + payload, err := xml.Marshal(struct { + XMLName xml.Name `xml:"P2P"` + Disconnect struct { + CID int32 `xml:"cid"` + DID int32 `xml:"did"` + } `xml:"D2C_DISC"` + }{Disconnect: struct { + CID int32 `xml:"cid"` + DID int32 `xml:"did"` + }{conn.clientID, conn.cameraID}}) + if err != nil { + t.Fatal(err) + } + const transaction = 17 + xorUID(payload, payload, transaction) + packet, err := marshalUIDDiscovery(transaction, payload) + if err != nil { + t.Fatal(err) + } + writeUIDPacket(t, camera, conn, packet) + select { + case <-conn.done: + case <-time.After(time.Second): + t.Fatal("camera disconnect did not close transport") + } + if err = conn.readError(); err == nil || !strings.Contains(err.Error(), "camera closed") { + t.Fatalf("unexpected terminal error: %v", err) + } +} diff --git a/pkg/baichuan/uid_send.go b/pkg/baichuan/uid_send.go new file mode 100644 index 000000000..1ff7d6283 --- /dev/null +++ b/pkg/baichuan/uid_send.go @@ -0,0 +1,118 @@ +package baichuan + +import ( + "fmt" + "net" + "os" + "time" +) + +func (s *uidConn) writeChunk(payload []byte) error { + packetID, err := s.reserveSend() + if err != nil { + return err + } + b := s.getBuffer() + packet, err := encodeUIDData(b[:], s.cameraID, packetID, payload, uidMTU-uidDataHeader) + if err != nil { + s.putBuffer(b) + s.cancelSendReservation() + return err + } + now := time.Now() + s.sendMu.Lock() + slot := &s.sendSlots[packetID%uidSendWindow] + *slot = uidSendSlot{ + buffer: b, packet: packet, packetID: packetID, firstSend: now, lastSend: now, + interval: uidRetransmit, used: true, + } + s.sendMu.Unlock() + if err = s.writeDatagram(packet); err != nil { + s.releaseSend(packetID) + select { + case <-s.done: + return s.writeError() + default: + } + s.shutdown(err) + return err + } + return nil +} + +func (s *uidConn) cancelSendReservation() { + s.sendMu.Lock() + s.sendCount-- + s.signalSend() + s.sendMu.Unlock() +} + +func (s *uidConn) reserveSend() (uint32, error) { + for { + select { + case <-s.done: + return 0, s.writeError() + default: + } + s.sendMu.Lock() + if s.sendCount < uidSendWindow && !s.sendSlots[s.nextSend%uidSendWindow].used { + packetID := s.nextSend + s.nextSend++ + s.sendCount++ + s.sendMu.Unlock() + return packetID, nil + } + s.sendMu.Unlock() + + deadline, changed, timeout := s.writeDeadline.snapshot() + if !deadline.IsZero() && !time.Now().Before(deadline) { + return 0, os.ErrDeadlineExceeded + } + select { + case <-s.sendWake: + case <-changed: + case <-s.done: + return 0, s.writeError() + case <-timeout: + return 0, os.ErrDeadlineExceeded + } + } +} + +func (s *uidConn) releaseSend(packetID uint32) { + s.sendMu.Lock() + slot := &s.sendSlots[packetID%uidSendWindow] + if slot.used && slot.packetID == packetID { + s.putBuffer(slot.buffer) + *slot = uidSendSlot{} + s.sendCount-- + s.signalSend() + } + s.sendMu.Unlock() +} + +func (s *uidConn) retransmitUID(now time.Time) error { + s.sendMu.Lock() + defer s.sendMu.Unlock() + for i := range s.sendSlots { + slot := &s.sendSlots[i] + if !slot.used || now.Sub(slot.lastSend) < slot.interval { + continue + } + if now.Sub(slot.firstSend) >= s.timeout { + return fmt.Errorf("baichuan: UID acknowledgement timed out") + } + if err := s.writeDatagram(slot.packet); err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return fmt.Errorf("baichuan: retransmit UID packet: %w", err) + } + slot.lastSend = now + slot.interval *= 2 + if slot.interval > uidMaxRetransmit { + slot.interval = uidMaxRetransmit + } + } + return nil +} diff --git a/pkg/baichuan/uid_sockopt.go b/pkg/baichuan/uid_sockopt.go new file mode 100644 index 000000000..eada994a9 --- /dev/null +++ b/pkg/baichuan/uid_sockopt.go @@ -0,0 +1,9 @@ +//go:build !windows + +package baichuan + +import "syscall" + +func setUIDBroadcast(fd uintptr) error { + return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1) +} diff --git a/pkg/baichuan/uid_sockopt_windows.go b/pkg/baichuan/uid_sockopt_windows.go new file mode 100644 index 000000000..bad4eac18 --- /dev/null +++ b/pkg/baichuan/uid_sockopt_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package baichuan + +import "syscall" + +func setUIDBroadcast(fd uintptr) error { + return syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1) +} diff --git a/pkg/baichuan/xml.go b/pkg/baichuan/xml.go new file mode 100644 index 000000000..e3a9febb1 --- /dev/null +++ b/pkg/baichuan/xml.go @@ -0,0 +1,134 @@ +package baichuan + +import ( + "encoding/xml" + "fmt" +) + +const ( + maxNonceBody = 4 << 10 + maxNonceSize = 128 + binaryExtensionXML = "1" +) + +var binaryExtensionValue = 1 + +type loginEnvelope struct { + XMLName xml.Name `xml:"body"` + User loginUser `xml:"LoginUser"` + Net loginNet `xml:"LoginNet"` +} + +type loginUser struct { + Version string `xml:"version,attr"` + Username string `xml:"userName"` + Password string `xml:"password"` + UserVer int `xml:"userVer"` +} + +type loginNet struct { + Version string `xml:"version,attr"` + Type string `xml:"type"` + UDPPort int `xml:"udpPort"` +} + +type nonceEnvelope struct { + Encryption *struct { + Nonce string `xml:"nonce"` + } `xml:"Encryption"` +} + +type extension struct { + BinaryData *int `xml:"binaryData"` + EncryptLen *int `xml:"encryptLen"` + CheckPos *int `xml:"checkPos"` +} + +type previewEnvelope struct { + XMLName xml.Name `xml:"body"` + Preview struct { + Version string `xml:"version,attr"` + Channel uint8 `xml:"channelId"` + Handle uint32 `xml:"handle"` + Stream Stream `xml:"streamType"` + } `xml:"Preview"` +} + +type stopPreviewEnvelope struct { + XMLName xml.Name `xml:"body"` + Preview struct { + Version string `xml:"version,attr"` + Channel uint8 `xml:"channelId"` + Handle uint32 `xml:"handle"` + } `xml:"Preview"` +} + +func marshalDocument(value any) ([]byte, error) { + body, err := xml.Marshal(value) + if err != nil { + return nil, err + } + doc := make([]byte, 0, len(xml.Header)+len(body)) + doc = append(doc, xml.Header...) + return append(doc, body...), nil +} + +func buildLogin(username, password, nonce string) ([]byte, error) { + return marshalDocument(loginEnvelope{ + User: loginUser{ + Version: "1.1", + Username: modernMD5(username + nonce), + Password: modernMD5(password + nonce), + UserVer: 1, + }, + Net: loginNet{Version: "1.1", Type: "LAN"}, + }) +} + +func parseNonce(body []byte) (string, error) { + if len(body) > maxNonceBody { + return "", fmt.Errorf("login nonce XML exceeds %d bytes", maxNonceBody) + } + var value nonceEnvelope + if err := xml.Unmarshal(body, &value); err != nil { + return "", fmt.Errorf("decode nonce XML: %w", err) + } + if value.Encryption == nil || value.Encryption.Nonce == "" { + return "", fmt.Errorf("nonce missing from login response") + } + if len(value.Encryption.Nonce) > maxNonceSize { + return "", fmt.Errorf("login nonce exceeds %d bytes", maxNonceSize) + } + return value.Encryption.Nonce, nil +} + +func parseExtension(body []byte) (extension, error) { + if len(body) == 0 { + return extension{}, nil + } + if string(body) == binaryExtensionXML { + return extension{BinaryData: &binaryExtensionValue}, nil + } + var value extension + if err := xml.Unmarshal(body, &value); err != nil { + return value, fmt.Errorf("decode extension XML: %w", err) + } + return value, nil +} + +func buildPreview(channel uint8, stream Stream, handle uint32) ([]byte, error) { + value := previewEnvelope{} + value.Preview.Version = "1.1" + value.Preview.Channel = channel + value.Preview.Handle = handle + value.Preview.Stream = stream + return marshalDocument(value) +} + +func buildStopPreview(channel uint8, handle uint32) ([]byte, error) { + value := stopPreviewEnvelope{} + value.Preview.Version = "1.1" + value.Preview.Channel = channel + value.Preview.Handle = handle + return marshalDocument(value) +} diff --git a/pkg/baichuan/xml_test.go b/pkg/baichuan/xml_test.go new file mode 100644 index 000000000..ac897cb55 --- /dev/null +++ b/pkg/baichuan/xml_test.go @@ -0,0 +1,32 @@ +package baichuan + +import ( + "strings" + "testing" +) + +func TestParseNonceLimits(t *testing.T) { + for _, body := range [][]byte{ + []byte(strings.Repeat(" ", maxNonceBody+1)), + []byte(`` + strings.Repeat("n", maxNonceSize+1) + + ``), + } { + if _, err := parseNonce(body); err == nil { + t.Fatal("accepted oversized login nonce") + } + } +} + +func FuzzXMLParsers(f *testing.F) { + f.Add([]byte(`nonce`)) + f.Add([]byte(`18`)) + f.Add([]byte(`fullDuplex`)) + f.Add([]byte(`version_ro`)) + f.Fuzz(func(t *testing.T, body []byte) { + _, _ = parseNonce(body) + _, _ = parseExtension(body) + _, _ = decodeTalkAbility(body) + _, _ = parseCapabilities(body) + _, _ = parseDeviceInfo(body) + }) +} diff --git a/pkg/reolink/backchannel.go b/pkg/reolink/backchannel.go new file mode 100644 index 000000000..b235a4616 --- /dev/null +++ b/pkg/reolink/backchannel.go @@ -0,0 +1,270 @@ +package reolink + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +const talkIdleTimeout = 5 * time.Second + +type talkSession interface { + Format() baichuan.TalkFormat + WriteBlock(context.Context, []byte) error + Close(context.Context) error +} + +type talkDialer func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) + +type backchannel struct { + producer *Producer + format baichuan.TalkFormat + dial talkDialer + + mu sync.Mutex + closed bool + client io.Closer + talk talkSession + encoder baichuan.ADPCMEncoder + codec core.Codec + transcode func([]byte) []byte + samples []int16 + idle *time.Timer + idleAt time.Time + sender *core.Sender +} + +func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if p.talk == nil || media.Kind != core.KindAudio || media.Direction != core.DirectionSendonly || + codec.Channels > 1 || !talkCodecSupported(codec) { + return fmt.Errorf("reolink: unsupported talkback track") + } + + p.talkMu.Lock() + defer p.talkMu.Unlock() + if p.ctx.Err() != nil { + return p.ctx.Err() + } + if p.talk.sender != nil { + p.talk.sender.Close() + p.talk.sender.Wait() + } + if err := p.talk.setCodec(codec); err != nil { + return err + } + sender := core.NewSender(media, codec) + sender.Handler = func(packet *rtp.Packet) { + if err := p.talk.Write(packet.Payload); err != nil && p.ctx.Err() == nil { + p.fail(fmt.Errorf("reolink: write talkback: %w", err)) + } + } + sender.HandleRTP(track) + p.talk.sender = sender + p.Senders = []*core.Sender{sender} + return nil +} + +func talkCodecs() []*core.Codec { + codecs := []*core.Codec{ + {Name: core.CodecPCMA, ClockRate: 8000, Channels: 1, PayloadType: 8}, + {Name: core.CodecPCMU, ClockRate: 8000, Channels: 1, PayloadType: 0}, + } + for _, codec := range pcm.ProducerCodecs() { + if codec.Name != core.CodecPCM && codec.Name != core.CodecPCML || + codec.ClockRate != 8000 && codec.ClockRate != 16000 { + continue + } + codec.Channels = 1 + codec.PayloadType = core.PayloadTypeRAW + codecs = append(codecs, codec) + } + return codecs +} + +func talkCodecSupported(codec *core.Codec) bool { + if codec.ClockRate == 0 { + return false + } + for _, candidate := range talkCodecs() { + if candidate.Name == codec.Name && candidate.ClockRate == codec.ClockRate { + return true + } + } + return false +} + +func (b *backchannel) setCodec(codec *core.Codec) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return fmt.Errorf("reolink: talkback closed") + } + b.codec = *codec + target := &core.Codec{Name: core.CodecPCML, ClockRate: b.format.SampleRate, Channels: 1} + b.transcode = pcm.Transcode(target, &b.codec) + b.samples = b.samples[:0] + return nil +} + +func (b *backchannel) Write(payload []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return fmt.Errorf("reolink: talkback closed") + } + blockSamples := uint64(b.format.SamplesPerBlock) + inputSamples := uint64(len(payload)) + if b.codec.Name == core.CodecPCM || b.codec.Name == core.CodecPCML { + if len(payload)&1 != 0 { + return fmt.Errorf("reolink: talkback PCM payload has odd length %d", len(payload)) + } + inputSamples /= 2 + } + expanded := (inputSamples*uint64(b.format.SampleRate) + uint64(b.codec.ClockRate) - 1) / + uint64(b.codec.ClockRate) + if expanded > blockSamples*4 { + return fmt.Errorf("reolink: talkback packet exceeds sample limit") + } + if err := b.open(); err != nil { + return err + } + pcmBytes := b.transcode(payload) + if len(pcmBytes)&1 != 0 { + return fmt.Errorf("reolink: talkback produced odd PCM length %d", len(pcmBytes)) + } + blockSize := int(blockSamples) + for i := 0; i < len(pcmBytes); i += 2 { + b.samples = append(b.samples, int16(binary.LittleEndian.Uint16(pcmBytes[i:]))) + } + for len(b.samples) >= blockSize { + block, err := b.encoder.EncodeBlock(b.samples[:blockSize]) + if err != nil { + return err + } + if err = b.talk.WriteBlock(b.producer.ctx, block); err != nil { + return err + } + b.producer.addSend(len(block)) + if len(b.samples) == blockSize { + b.samples = b.samples[:0] + } else { + b.samples = b.samples[blockSize:] + } + } + b.idleAt = time.Now().Add(talkIdleTimeout) + if b.idle == nil { + b.idle = time.AfterFunc(talkIdleTimeout, b.closeIdle) + } else { + b.idle.Reset(talkIdleTimeout) + } + return nil +} + +func (b *backchannel) open() error { + if b.talk != nil { + return nil + } + ctx, cancel := context.WithTimeout(b.producer.ctx, baichuan.DefaultTimeout) + dial := b.dial + if dial == nil { + dial = dialTalk + } + talk, client, err := dial(ctx, b.producer.config, b.producer.channel) + if err == nil { + if format := talk.Format(); format != b.format { + err = fmt.Errorf("reolink: talkback format changed from %+v to %+v", b.format, format) + } else { + b.client = client + b.talk = talk + b.encoder = baichuan.ADPCMEncoder{} + } + } + cancel() + if err != nil { + var cleanup error + if talk != nil { + closeCtx, closeCancel := context.WithTimeout(context.WithoutCancel(b.producer.ctx), baichuan.DefaultTimeout) + cleanup = errors.Join(cleanup, talk.Close(closeCtx)) + closeCancel() + } + if client != nil { + cleanup = errors.Join(cleanup, client.Close()) + } + return fmt.Errorf("reolink: start talkback: %w", errors.Join(err, cleanup)) + } + return nil +} + +func dialTalk(ctx context.Context, config baichuan.Config, channel uint8) (talkSession, io.Closer, error) { + client, err := baichuan.Dial(ctx, config) + if err != nil { + return nil, nil, err + } + talk, err := client.StartTalk(ctx, channel) + if err != nil { + return nil, client, err + } + return talk, client, nil +} + +func (b *backchannel) closeIdle() { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return + } + if wait := time.Until(b.idleAt); wait > 0 { + b.idle.Reset(wait) + b.mu.Unlock() + return + } + err := b.closeSession() + b.mu.Unlock() + if err != nil && b.producer.ctx.Err() == nil { + b.producer.fail(fmt.Errorf("reolink: stop idle talkback: %w", err)) + } +} + +func (b *backchannel) closeSession() error { + if b.talk == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(b.producer.ctx), baichuan.DefaultTimeout) + err := b.talk.Close(ctx) + cancel() + err = errors.Join(err, b.client.Close()) + b.client = nil + b.talk = nil + b.samples = b.samples[:0] + return err +} + +func (b *backchannel) Close() error { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return nil + } + b.closed = true + if b.idle != nil { + b.idle.Stop() + } + b.mu.Unlock() + + if b.sender != nil { + b.sender.Close() + b.sender.Wait() + } + b.mu.Lock() + defer b.mu.Unlock() + return b.closeSession() +} diff --git a/pkg/reolink/backchannel_test.go b/pkg/reolink/backchannel_test.go new file mode 100644 index 000000000..26f62580f --- /dev/null +++ b/pkg/reolink/backchannel_test.go @@ -0,0 +1,252 @@ +package reolink + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type fakeTalk struct { + format baichuan.TalkFormat + blocks chan []byte + writeErr error + closes int +} + +func (t *fakeTalk) Format() baichuan.TalkFormat { return t.format } +func (t *fakeTalk) WriteBlock(_ context.Context, block []byte) error { + if t.writeErr != nil { + return t.writeErr + } + t.blocks <- append([]byte(nil), block...) + return nil +} +func (t *fakeTalk) Close(context.Context) error { + t.closes++ + return nil +} + +type fakeTalkClient struct { + closes int +} + +type fakeTalkPair struct { + talk *fakeTalk + client *fakeTalkClient +} + +func (c *fakeTalkClient) Close() error { + c.closes++ + return nil +} + +func TestBackchannelAddTrackReplacementAndIdleReopen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + p := &Producer{ctx: ctx, cancel: cancel} + format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4} + opened := make(chan fakeTalkPair, 2) + b := &backchannel{producer: p, format: format} + b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) { + talk := &fakeTalk{format: format, blocks: make(chan []byte, 1)} + client := &fakeTalkClient{} + opened <- fakeTalkPair{talk: talk, client: client} + return talk, client, nil + } + p.talk = b + + codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1} + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec}} + first := core.NewReceiver(media, codec) + if err := p.AddTrack(media, codec, first); err != nil { + t.Fatal(err) + } + first.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}}) + firstPair := wantTalkOpen(t, opened) + wantBlock(t, firstPair.talk, format.BytesPerBlock()) + + second := core.NewReceiver(media, codec) + if err := p.AddTrack(media, codec, second); err != nil { + t.Fatal(err) + } + second.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}}) + wantBlock(t, firstPair.talk, format.BytesPerBlock()) + select { + case <-opened: + t.Fatal("sender replacement reopened talk session") + default: + } + + b.mu.Lock() + b.idleAt = time.Now().Add(-time.Second) + b.mu.Unlock() + b.closeIdle() + if firstPair.talk.closes != 1 || firstPair.client.closes != 1 { + t.Fatalf("idle close did not release session: talk=%d client=%d", firstPair.talk.closes, firstPair.client.closes) + } + second.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}}) + secondPair := wantTalkOpen(t, opened) + wantBlock(t, secondPair.talk, format.BytesPerBlock()) + + cancel() + if err := b.Close(); err != nil { + t.Fatal(err) + } + first.Close() + second.Close() + if secondPair.talk.closes != 1 || secondPair.client.closes != 1 { + t.Fatalf("close did not release reopened session: talk=%d client=%d", secondPair.talk.closes, secondPair.client.closes) + } +} + +func TestBackchannelWriteFailureCancelsProducer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p := &Producer{ctx: ctx, cancel: cancel} + format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4} + b := &backchannel{producer: p, format: format} + b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) { + return &fakeTalk{ + format: format, blocks: make(chan []byte, 1), writeErr: errors.New("sentinel write"), + }, &fakeTalkClient{}, nil + } + p.talk = b + codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1} + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec}} + track := core.NewReceiver(media, codec) + if err := p.AddTrack(media, codec, track); err != nil { + t.Fatal(err) + } + track.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}}) + select { + case <-p.ctx.Done(): + case <-time.After(time.Second): + t.Fatal("talk write failure did not cancel producer") + } + if err := p.terminalError(context.Canceled); err == nil || !strings.Contains(err.Error(), "sentinel write") { + t.Fatalf("unexpected terminal error: %v", err) + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + track.Close() +} + +func TestBackchannelDialFailureClosesClient(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p := &Producer{ctx: ctx, cancel: cancel} + format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4} + client := &fakeTalkClient{} + b := &backchannel{producer: p, format: format} + b.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) { + return nil, client, errors.New("sentinel dial") + } + if err := b.setCodec(&core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}); err != nil { + t.Fatal(err) + } + if err := b.Write([]byte{0xd5, 0xd5}); err == nil || !strings.Contains(err.Error(), "sentinel dial") { + t.Fatalf("unexpected dial error: %v", err) + } + if client.closes != 1 { + t.Fatalf("failed dial client close count: %d", client.closes) + } +} + +func TestBackchannelLinearPCM(t *testing.T) { + for _, codec := range talkCodecs() { + if codec.Name != core.CodecPCM && codec.Name != core.CodecPCML { + continue + } + t.Run(codec.String(), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4} + talk := &fakeTalk{format: format, blocks: make(chan []byte, 1)} + client := &fakeTalkClient{} + b := &backchannel{ + producer: &Producer{ctx: ctx, cancel: cancel}, format: format, talk: talk, client: client, + } + if err := b.setCodec(codec); err != nil { + t.Fatal(err) + } + inputSamples := (uint64(format.SamplesPerBlock)*uint64(codec.ClockRate) + + uint64(format.SampleRate) - 1) / uint64(format.SampleRate) + if err := b.Write(make([]byte, int(inputSamples)*2)); err != nil { + t.Fatal(err) + } + wantBlock(t, talk, format.BytesPerBlock()) + if err := b.Close(); err != nil { + t.Fatal(err) + } + if talk.closes != 1 || client.closes != 1 { + t.Fatalf("session wasn't closed: talk=%d client=%d", talk.closes, client.closes) + } + }) + } +} + +func TestTalkCodecSupport(t *testing.T) { + tests := []struct { + name string + rate uint32 + }{ + {core.CodecPCMA, 8000}, {core.CodecPCMU, 8000}, + {core.CodecPCML, 8000}, {core.CodecPCM, 8000}, + {core.CodecPCML, 16000}, {core.CodecPCM, 16000}, + } + for _, test := range tests { + codec := &core.Codec{Name: test.name, ClockRate: test.rate, Channels: 1} + if !talkCodecSupported(codec) { + t.Fatalf("unsupported parent PCM codec %s", codec) + } + } + if talkCodecSupported(&core.Codec{Name: core.CodecPCML, ClockRate: 44100, Channels: 1}) { + t.Fatal("accepted unadvertised PCM clock rate") + } +} + +func TestBackchannelRejectsInvalidPCM(t *testing.T) { + b := &backchannel{ + producer: &Producer{ctx: context.Background()}, + format: baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 4}, + } + if err := b.setCodec(&core.Codec{Name: core.CodecPCML, ClockRate: 16000, Channels: 1}); err != nil { + t.Fatal(err) + } + if err := b.Write([]byte{0}); err == nil || !strings.Contains(err.Error(), "odd length") { + t.Fatalf("unexpected odd PCM result: %v", err) + } + if talkCodecSupported(&core.Codec{Name: core.CodecPCML}) { + t.Fatal("accepted PCM without a clock rate") + } +} + +func wantTalkOpen(t *testing.T, opened <-chan fakeTalkPair) fakeTalkPair { + t.Helper() + select { + case pair := <-opened: + return pair + case <-time.After(time.Second): + t.Fatal("talk session was not opened") + return fakeTalkPair{} + } +} + +func wantBlock(t *testing.T, talk *fakeTalk, size int) { + t.Helper() + select { + case block := <-talk.blocks: + if len(block) != size { + t.Fatalf("unexpected ADPCM block size: %d", len(block)) + } + case <-time.After(time.Second): + t.Fatal("talk block was not written") + } +} diff --git a/pkg/reolink/camera.go b/pkg/reolink/camera.go new file mode 100644 index 000000000..f4f4c47a4 --- /dev/null +++ b/pkg/reolink/camera.go @@ -0,0 +1,92 @@ +package reolink + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" +) + +type ProfileKey struct { + Channel uint8 + Stream baichuan.Stream +} + +type Camera struct { + registry *Registry + key cameraKey + remote string + profiles map[*Profile]struct{} + epochs map[ProfileKey]mediaEpoch + refs int + generation uint64 + mainSessions int + otherSessions int +} + +type mediaEpoch struct { + video uint32 + audio uint32 + audioRate uint32 + videoSet bool + audioSet bool +} + +func (c *Camera) nextEpoch(key ProfileKey, now uint32) mediaEpoch { + epoch := c.epochs[key] + epoch.video = nextTimestampEpoch(now, epoch.video, epoch.videoSet) + epoch.videoSet = true + return epoch +} + +func (c *Camera) recordEpoch(key ProfileKey, value mediaEpoch) { + previous := c.epochs[key] + if value.videoSet && (!previous.videoSet || timestampAfter(value.video, previous.video)) { + previous.video = value.video + previous.videoSet = true + } + if value.audioSet && (!previous.audioSet || value.audioRate != previous.audioRate || + timestampAfter(value.audio, previous.audio)) { + previous.audio = value.audio + previous.audioRate = value.audioRate + previous.audioSet = true + } + c.epochs[key] = previous +} + +func timestampAfter(value, previous uint32) bool { + delta := value - previous + return delta != 0 && delta < 1<<31 +} + +func nextTimestampEpoch(now, previous uint32, set bool) uint32 { + if set && !timestampAfter(now, previous) { + return previous + 1 + } + return now +} + +func (c *Camera) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprintf(state, "reolink.Camera{Remote:%q}", c.remote) +} + +func (c *Camera) addSession(stream baichuan.Stream) { + if stream == baichuan.StreamMain { + c.mainSessions++ + return + } + c.otherSessions++ +} + +func (c *Camera) removeSession(stream baichuan.Stream) { + if stream == baichuan.StreamMain { + if c.mainSessions == 0 { + panic("reolink: main session accounting underflow") + } + c.mainSessions-- + return + } + if c.otherSessions == 0 { + panic("reolink: other session accounting underflow") + } + c.otherSessions-- +} diff --git a/pkg/reolink/config.go b/pkg/reolink/config.go new file mode 100644 index 000000000..22c170b86 --- /dev/null +++ b/pkg/reolink/config.go @@ -0,0 +1,193 @@ +package reolink + +import ( + "fmt" + "net" + "net/netip" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/creds" +) + +type source struct { + config baichuan.Config + channel uint8 + stream baichuan.Stream + remote string + backchannel bool + suppressVideo bool + suppressAudio bool + username string + password string + identity string + protocol string +} + +func (s source) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprintf(state, "reolink.source{Remote:%q, Channel:%d, Stream:%q, Backchannel:%t}", + s.remote, s.channel, s.stream, s.backchannel) +} + +func parseURL(rawURL string) (source, error) { + u, err := url.Parse(rawURL) + if err != nil { + return source{}, fmt.Errorf("reolink: invalid source URL") + } + if u.Scheme != "reolink" || u.Hostname() == "" || u.Fragment != "" || u.User == nil || u.User.Username() == "" { + return source{}, fmt.Errorf("reolink: invalid source") + } + username := u.User.Username() + password, _ := u.User.Password() + creds.AddSecret(username) + creds.AddSecret(password) + + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return source{}, fmt.Errorf("reolink: invalid query") + } + if query.Get("transport") == "uid" { + creds.AddSecret(u.Hostname()) + } + for key, values := range query { + if key != "channel" && key != "stream" && key != "backchannel" && key != "transport" && + key != "local" && key != "broadcast" && key != "video" && key != "audio" || len(values) != 1 { + return source{}, fmt.Errorf("reolink: invalid query parameter") + } + } + + s := source{ + username: username, password: password, + stream: baichuan.StreamMain, backchannel: true, + } + switch query.Get("transport") { + case "", "tcp": + if query.Get("local") != "" || query.Get("broadcast") != "" { + return source{}, fmt.Errorf("reolink: discovery addresses require UID transport") + } + host := canonicalHost(u.Hostname()) + s.config = baichuan.NewConfig(host, username, password) + if value := u.Port(); value != "" { + port, err := strconv.ParseUint(value, 10, 16) + if err != nil || port == 0 { + return source{}, fmt.Errorf("reolink: invalid port") + } + s.config.Port = uint16(port) + } + port := s.config.Port + if port == 0 { + port = baichuan.DefaultPort + s.config.Port = port + } + s.remote = net.JoinHostPort(host, strconv.Itoa(int(port))) + s.protocol = "tcp" + case "uid": + if u.Port() != "" { + return source{}, fmt.Errorf("reolink: UID transport does not use a port") + } + uid := u.Hostname() + s.config = baichuan.NewUIDConfig(uid, username, password) + s.config.UIDLocalAddr = query.Get("local") + if s.config.UIDLocalAddr != "" { + address, err := netip.ParseAddr(s.config.UIDLocalAddr) + if err != nil || !address.Is4() || address.IsLoopback() || + !address.IsGlobalUnicast() && !address.IsLinkLocalUnicast() { + return source{}, fmt.Errorf("reolink: invalid local address") + } + s.config.UIDLocalAddr = address.String() + } + s.config.UIDBroadcastAddr = query.Get("broadcast") + if s.config.UIDBroadcastAddr != "" { + address, err := netip.ParseAddr(s.config.UIDBroadcastAddr) + if err != nil || !address.Is4() || address.IsUnspecified() || + address.IsLoopback() || address.IsMulticast() { + return source{}, fmt.Errorf("reolink: invalid broadcast address") + } + s.config.UIDBroadcastAddr = address.String() + } + s.identity = uid + s.remote = "uid" + s.protocol = "udp" + default: + return source{}, fmt.Errorf("reolink: invalid transport") + } + channel, err := strconv.ParseUint(defaultValue(query.Get("channel"), "0"), 10, 8) + if err != nil { + return source{}, fmt.Errorf("reolink: invalid channel") + } + s.channel = uint8(channel) + + path := strings.TrimPrefix(u.Path, "/") + if strings.Contains(path, "/") || path != "" && query.Get("stream") != "" { + return source{}, fmt.Errorf("reolink: invalid stream") + } + name := query.Get("stream") + if path != "" { + name = path + } + s.stream, err = parseStream(name) + if err != nil { + return source{}, err + } + switch query.Get("backchannel") { + case "", "1": + case "0": + s.backchannel = false + default: + return source{}, fmt.Errorf("reolink: invalid backchannel") + } + var enabled bool + if enabled, err = parseEnabled(query.Get("video")); err != nil { + return source{}, fmt.Errorf("reolink: invalid video") + } + s.suppressVideo = !enabled + if enabled, err = parseEnabled(query.Get("audio")); err != nil { + return source{}, fmt.Errorf("reolink: invalid audio") + } + s.suppressAudio = !enabled + if s.suppressVideo && s.suppressAudio && !s.backchannel { + return source{}, fmt.Errorf("reolink: source has no enabled media") + } + + return s, nil +} + +func parseEnabled(value string) (bool, error) { + switch value { + case "", "1", "true": + return true, nil + case "0", "false": + return false, nil + default: + return false, fmt.Errorf("invalid boolean") + } +} + +func canonicalHost(host string) string { + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + return strings.TrimSuffix(strings.ToLower(host), ".") +} + +func parseStream(value string) (baichuan.Stream, error) { + switch strings.ToLower(value) { + case "", "main", strings.ToLower(string(baichuan.StreamMain)): + return baichuan.StreamMain, nil + case "sub", strings.ToLower(string(baichuan.StreamSub)): + return baichuan.StreamSub, nil + case "extern", "ext", strings.ToLower(string(baichuan.StreamExtern)): + return baichuan.StreamExtern, nil + default: + return "", fmt.Errorf("reolink: invalid stream") + } +} + +func defaultValue(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/pkg/reolink/media.go b/pkg/reolink/media.go new file mode 100644 index 000000000..49f8c004e --- /dev/null +++ b/pkg/reolink/media.go @@ -0,0 +1,360 @@ +package reolink + +import ( + "fmt" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +const ( + maxProbeBytes = 32 << 20 + maxProbePackets = 4096 + maxTimestampStep = uint32(30 * time.Second / time.Microsecond) +) + +type mediaError struct { + err error +} + +func (e *mediaError) Error() string { + return e.err.Error() +} + +func (e *mediaError) Unwrap() error { + return e.err +} + +func mediaErrorf(format string, args ...any) error { + return &mediaError{err: fmt.Errorf(format, args...)} +} + +type mediaTrack uint8 + +const ( + trackVideo mediaTrack = iota + trackAudio +) + +type mediaFrame struct { + packet *rtp.Packet + track mediaTrack + samples uint32 +} + +func (f mediaFrame) valid() bool { + return f.packet != nil +} + +type mediaPipeline struct { + medias []*core.Media + epoch mediaEpoch + + videoCodec string + videoConfig string + videoParams string + videoCandidate string + videoResync bool + videoMedia *core.Media + videoTS mediaClock + + audioKind baichuan.MediaKind + audioConfig uint16 + audioMedia *core.Media + audioTS uint32 + audioRate uint32 +} + +func (m *mediaPipeline) configureVideo(packet baichuan.MediaPacket) error { + payload := annexb.EncodeToAVCC(packet.Data) + if !validVideoConfig(packet.Codec, payload) { + return mediaErrorf("reolink: invalid %s keyframe", packet.Codec) + } + return m.configureVideoPayload(packet.Codec, payload) +} + +func (m *mediaPipeline) probeVideo(packet baichuan.MediaPacket) error { + payload := annexb.EncodeToAVCC(packet.Data) + if packet.Codec != "H264" && packet.Codec != "H265" { + return mediaErrorf("reolink: unsupported video codec %q", packet.Codec) + } + if !validVideoConfig(packet.Codec, payload) { + return nil + } + return m.configureVideoPayload(packet.Codec, payload) +} + +func (m *mediaPipeline) configureVideoPayload(name string, payload []byte) error { + var codec *core.Codec + switch name { + case "H264": + params, err := h264ParameterSignature(payload) + if err != nil { + return err + } + m.videoParams = params + codec = &core.Codec{ + Name: core.CodecH264, ClockRate: 90000, PayloadType: core.PayloadTypeRAW, + FmtpLine: h264.GetFmtpLine(payload), + } + case "H265": + codec = h265.AVCCToCodec(payload) + if codec == nil { + return mediaErrorf("reolink: invalid H265 keyframe") + } + default: + return mediaErrorf("reolink: unsupported video codec %q", name) + } + m.videoMedia = &core.Media{ + Kind: core.KindVideo, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, + } + m.videoCodec = name + m.videoConfig = codec.FmtpLine + m.videoTS.value = m.epoch.video + m.medias = append(m.medias, m.videoMedia) + return nil +} + +func (m *mediaPipeline) configureAudio(packet baichuan.MediaPacket) error { + var codec *core.Codec + switch packet.Kind { + case baichuan.MediaAAC: + codec = aac.ADTSToCodec(packet.Data) + config, ok := aacConfig(packet.Data) + if codec == nil || codec.ClockRate == 0 || codec.Channels == 0 || !ok { + return mediaErrorf("reolink: invalid AAC header") + } + codec.PayloadType = core.PayloadTypeRAW + m.audioConfig = config + case baichuan.MediaADPCM: + if _, err := baichuan.DecodeADPCMBlock(packet.Data); err != nil { + return mediaErrorf("reolink: invalid ADPCM audio: %w", err) + } + codec = &core.Codec{ + Name: core.CodecPCMA, ClockRate: 8000, Channels: 1, PayloadType: core.PayloadTypeRAW, + } + default: + return mediaErrorf("reolink: unsupported audio kind %d", packet.Kind) + } + m.audioMedia = &core.Media{ + Kind: core.KindAudio, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, + } + m.audioKind = packet.Kind + m.audioTS = nowTimestamp(codec.ClockRate) + if m.epoch.audioSet && m.epoch.audioRate == codec.ClockRate { + m.audioTS = nextTimestampEpoch(m.audioTS, m.epoch.audio, true) + } + m.audioRate = codec.ClockRate + m.medias = append(m.medias, m.audioMedia) + return nil +} + +func (m *mediaPipeline) frame(packet baichuan.MediaPacket) (mediaFrame, error) { + switch packet.Kind { + case baichuan.MediaVideoI, baichuan.MediaVideoP: + return m.videoFrame(packet) + case baichuan.MediaAAC, baichuan.MediaADPCM: + return m.audioFrame(packet) + default: + return mediaFrame{}, nil + } +} + +func (m *mediaPipeline) videoFrame(packet baichuan.MediaPacket) (mediaFrame, error) { + if packet.Codec != m.videoCodec { + return mediaFrame{}, mediaErrorf("reolink: video codec changed from %s to %s", m.videoCodec, packet.Codec) + } + payload := encodeOwnedToAVCC(packet.Data) + config, err := inspectVideoPayload(packet.Codec, payload) + if err != nil { + m.suppressInvalidVideo() + return mediaFrame{}, nil + } + if packet.Kind == baichuan.MediaVideoI { + if !config { + m.suppressInvalidVideo() + return mediaFrame{}, nil + } + same, candidate, err := m.videoParameters(packet.Codec, payload) + if err != nil { + m.suppressInvalidVideo() + return mediaFrame{}, nil + } + if !same { + if candidate == m.videoCandidate { + return mediaFrame{}, mediaErrorf("reolink: %s parameter sets changed", packet.Codec) + } + m.videoCandidate = candidate + m.videoResync = true + return mediaFrame{}, nil + } + m.videoCandidate = "" + if m.videoResync { + m.videoResync = false + } + } + if m.videoResync { + return mediaFrame{}, nil + } + timestamp, err := m.videoTS.next(packet.Timestamp, 90000) + if err != nil { + return mediaFrame{}, mediaErrorf("reolink: video timestamp: %w", err) + } + return mediaFrame{ + track: trackVideo, + packet: &rtp.Packet{Header: rtp.Header{Timestamp: timestamp}, Payload: payload}, + }, nil +} + +func (m *mediaPipeline) suppressInvalidVideo() { + m.videoCandidate = "" + m.videoResync = true +} + +func (m *mediaPipeline) videoParameters(codec string, payload []byte) (same bool, candidate string, err error) { + if codec == "H264" { + params, sigErr := h264ParameterSignature(payload) + if sigErr != nil { + return false, "", sigErr + } + return params == m.videoParams, params, nil + } + parsed := h265.AVCCToCodec(payload) + if parsed == nil { + return false, "", mediaErrorf("reolink: invalid H265 keyframe") + } + return parsed.FmtpLine == m.videoConfig, parsed.FmtpLine, nil +} + +func (m *mediaPipeline) audioFrame(packet baichuan.MediaPacket) (mediaFrame, error) { + if m.audioKind == 0 { + return mediaFrame{}, nil + } + if packet.Kind != m.audioKind { + return mediaFrame{}, mediaErrorf("reolink: audio kind changed from %d to %d", m.audioKind, packet.Kind) + } + if packet.Kind == baichuan.MediaAAC { + config, ok := aacConfig(packet.Data) + if !ok { + return mediaFrame{}, mediaErrorf("reolink: invalid AAC packet") + } + if config != m.audioConfig { + return mediaFrame{}, mediaErrorf("reolink: AAC configuration changed") + } + } + payload, samples, err := audioPayload(packet) + if err != nil { + return mediaFrame{}, err + } + frame := mediaFrame{ + track: trackAudio, + packet: &rtp.Packet{Header: rtp.Header{Timestamp: m.audioTS}, Payload: payload}, + samples: samples, + } + m.audioTS += samples + return frame, nil +} + +type mediaProbe struct { + packets []baichuan.MediaPacket + bytes int +} + +func (p *mediaProbe) retain(packet baichuan.MediaPacket) error { + if packet.Kind == baichuan.MediaVideoI { + p.reset() + } + if len(packet.Data) > maxProbeBytes-p.bytes { + return mediaErrorf("reolink: codec probe exceeds %d bytes", maxProbeBytes) + } + if len(p.packets) >= maxProbePackets { + return mediaErrorf("reolink: codec probe exceeds %d packets", maxProbePackets) + } + packet.Data = append([]byte(nil), packet.Data...) + p.packets = append(p.packets, packet) + p.bytes += len(packet.Data) + return nil +} + +func (p *mediaProbe) reset() { + clear(p.packets) + p.packets = p.packets[:0] + p.bytes = 0 +} + +func (p *mediaProbe) release() { + p.reset() + p.packets = nil +} + +func aacConfig(data []byte) (uint16, bool) { + if !aac.IsADTS(data) { + return 0, false + } + return uint16(data[2]&0xfd)<<8 | uint16(data[3]&0xc0), true +} + +func audioPayload(packet baichuan.MediaPacket) ([]byte, uint32, error) { + if packet.Kind == baichuan.MediaADPCM { + samples, err := baichuan.DecodeADPCMBlock(packet.Data) + if err != nil { + return nil, 0, mediaErrorf("reolink: invalid ADPCM packet: %w", err) + } + payload := make([]byte, len(samples)) + for i, sample := range samples { + payload[i] = pcm.PCMtoPCMA(sample) + } + return payload, uint32(len(samples)), nil + } + if packet.Kind != baichuan.MediaAAC || !aac.IsADTS(packet.Data) { + return nil, 0, mediaErrorf("reolink: invalid AAC packet") + } + size := int(aac.ReadADTSSize(packet.Data)) + header := aac.ADTSHeaderLen(packet.Data) + if size != len(packet.Data) || header >= size { + return nil, 0, mediaErrorf("reolink: invalid AAC packet size") + } + samples := uint32(packet.Data[6]&3+1) * 1024 + return packet.Data[header:size], samples, nil +} + +type mediaClock struct { + set bool + last uint32 + value uint32 + remainder uint64 +} + +func (c *mediaClock) next(timestamp, rate uint32) (uint32, error) { + if !c.set { + c.set = true + c.last = timestamp + return c.value, nil + } + delta := timestamp - c.last + if delta >= 1<<31 || delta > maxTimestampStep { + return c.value, fmt.Errorf("camera clock discontinuity: %d to %d microseconds", c.last, timestamp) + } + c.last = timestamp + scaled := uint64(delta)*uint64(rate) + c.remainder + c.value += uint32(scaled / 1_000_000) + c.remainder = scaled % 1_000_000 + return c.value, nil +} + +func (c *mediaClock) current() (uint32, bool) { + return c.value, c.set +} + +func nowTimestamp(rate uint32) uint32 { + now := uint64(time.Now().UnixNano()) + return uint32(now/uint64(time.Second)*uint64(rate) + + now%uint64(time.Second)*uint64(rate)/uint64(time.Second)) +} diff --git a/pkg/reolink/producer.go b/pkg/reolink/producer.go new file mode 100644 index 000000000..9ff9aa44d --- /dev/null +++ b/pkg/reolink/producer.go @@ -0,0 +1,198 @@ +package reolink + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Producer struct { + core.Connection + + profile *Profile + config baichuan.Config + channel uint8 + ctx context.Context + cancel context.CancelFunc + stop sync.Once + stopErr error + send atomic.Uint64 + + failureMu sync.Mutex + failure error + talkMu sync.Mutex + talk *backchannel +} + +func (p *Producer) Format(state fmt.State, _ rune) { + profile := p.profile + if profile == nil { + _, _ = fmt.Fprintf(state, "reolink.Producer{ID:%d}", p.ID) + return + } + _, _ = fmt.Fprintf(state, "reolink.Producer{ID:%d, Remote:%q, Channel:%d, Stream:%q, Generation:%d}", + p.ID, p.RemoteAddr, profile.key.Channel, profile.key.Stream, profile.generation) +} + +var _ core.Producer = (*Producer)(nil) +var _ core.Consumer = (*Producer)(nil) + +func newProducer(s source, profile *Profile, talkFormat *baichuan.TalkFormat) *Producer { + ctx, cancel := context.WithCancel(context.Background()) + p := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), FormatName: "baichuan", Protocol: s.protocol, RemoteAddr: s.remote, + }, + profile: profile, config: s.config, channel: s.channel, + ctx: ctx, cancel: cancel, + } + p.Medias = append(p.Medias, profile.pipeline.medias...) + p.Receivers = append(p.Receivers, profile.receivers...) + if talkFormat != nil { + p.talk = &backchannel{producer: p, format: *talkFormat} + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindAudio, Direction: core.DirectionSendonly, + Codecs: talkCodecs(), + }) + } + return p +} + +func (p *Producer) Start() error { + p.profile.startProfile() + select { + case <-p.profile.done: + if err := p.ctx.Err(); err != nil { + return p.terminalError(err) + } + return p.terminalError(p.profile.err()) + case <-p.ctx.Done(): + <-p.profile.done + return p.terminalError(p.ctx.Err()) + } +} + +func (p *Producer) fail(err error) { + if err == nil { + return + } + p.failureMu.Lock() + if p.failure == nil { + p.failure = err + p.cancel() + if p.profile != nil { + p.profile.cancel() + } + } + p.failureMu.Unlock() +} + +func (p *Producer) terminalError(fallback error) error { + p.failureMu.Lock() + defer p.failureMu.Unlock() + if p.failure != nil { + return p.failure + } + if fallback != nil { + return fallback + } + return errors.New("reolink: profile stopped") +} + +func (p *Producer) Stop() error { + p.stop.Do(func() { + p.cancel() + p.profile.camera.registry.release(p.profile) + p.talkMu.Lock() + if p.talk != nil { + p.stopErr = errors.Join(p.stopErr, p.talk.Close()) + } + p.talkMu.Unlock() + <-p.profile.done + p.stopErr = errors.Join(p.stopErr, p.profile.closeError()) + p.profile.closeReceivers() + }) + return p.stopErr +} + +func (p *Producer) addSend(size int) { + p.send.Add(uint64(size)) +} + +type producerDiagnostics struct { + ID uint32 `json:"id,omitempty"` + FormatName string `json:"format_name,omitempty"` + Protocol string `json:"protocol,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + Medias []*core.Media `json:"medias,omitempty"` + Recv uint64 `json:"bytes_recv,omitempty"` + Send uint64 `json:"bytes_send,omitempty"` + Reolink profileDiagnostics `json:"reolink"` +} + +type profileDiagnostics struct { + Channel uint8 `json:"channel"` + Profile string `json:"profile"` + State string `json:"state"` + Generation uint64 `json:"generation"` + Profiles int `json:"profiles"` + MainSessions int `json:"main_sessions"` + OtherSessions int `json:"other_sessions"` + VideoCodec string `json:"video_codec,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + CameraType string `json:"camera_type,omitempty"` + CameraModel string `json:"camera_model,omitempty"` + HardwareVersion string `json:"hardware_version,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + DeviceInfo string `json:"device_info_status,omitempty"` + CapabilityInfo string `json:"capability_status,omitempty"` + Channels []uint16 `json:"observed_channels,omitempty"` + LastError string `json:"last_error_class,omitempty"` +} + +func (p *Producer) diagnostics() profileDiagnostics { + profile := p.profile + registry := profile.camera.registry + p.failureMu.Lock() + failureClass := errorClass(p.failure) + p.failureMu.Unlock() + registry.mu.Lock() + profile.mu.Lock() + channels := make([]uint16, len(profile.discovery.capabilities.ObservedChannels)) + for i, channel := range profile.discovery.capabilities.ObservedChannels { + channels[i] = uint16(channel) + } + d := profileDiagnostics{ + Channel: profile.key.Channel, Profile: string(profile.key.Stream), + State: profile.state.String(), Generation: profile.generation, Profiles: len(profile.camera.profiles), + MainSessions: profile.camera.mainSessions, OtherSessions: profile.camera.otherSessions, + VideoCodec: profile.pipeline.videoCodec, + CameraType: profile.discovery.device.Type, CameraModel: profile.discovery.device.Model, + HardwareVersion: profile.discovery.device.Hardware, FirmwareVersion: profile.discovery.device.Firmware, + DeviceInfo: profile.discovery.deviceStatus, CapabilityInfo: profile.discovery.capabilityStatus, + Channels: channels, + LastError: profile.errClass, + } + if profile.pipeline.audioMedia != nil { + d.AudioCodec = profile.pipeline.audioMedia.Codecs[0].Name + } + if failureClass != "" { + d.LastError = failureClass + } + profile.mu.Unlock() + registry.mu.Unlock() + return d +} + +func (p *Producer) MarshalJSON() ([]byte, error) { + return json.Marshal(&producerDiagnostics{ + ID: p.ID, FormatName: p.FormatName, Protocol: p.Protocol, RemoteAddr: p.RemoteAddr, + Medias: p.Medias, Recv: p.profile.recvBytes.Load(), Send: p.send.Load(), Reolink: p.diagnostics(), + }) +} diff --git a/pkg/reolink/producer_test.go b/pkg/reolink/producer_test.go new file mode 100644 index 000000000..b56b9dca5 --- /dev/null +++ b/pkg/reolink/producer_test.go @@ -0,0 +1,252 @@ +package reolink + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/creds" +) + +func TestParseURL(t *testing.T) { + source, err := parseURL("reolink://admin:p%40ss@[fd00::1]:9001/sub?channel=2&backchannel=0") + if err != nil { + t.Fatal(err) + } + if source.config.Host != "fd00::1" || source.config.Port != 9001 || source.channel != 2 || + source.stream != baichuan.StreamSub || source.remote != "[fd00::1]:9001" || source.backchannel || + source.protocol != "tcp" { + t.Fatalf("unexpected source: %+v", source) + } + text := fmt.Sprint(source.config) + if strings.Contains(text, "admin") || strings.Contains(text, "p@ss") { + t.Fatalf("credentials exposed by config: %s", text) + } +} + +func TestParseUIDURL(t *testing.T) { + const uid = "AbC1234567890XYZ" + source, err := parseURL("reolink://admin:secret@" + uid + + "/sub?transport=uid&channel=1&backchannel=0&local=192.0.2.28&broadcast=198.51.100.255") + if err != nil { + t.Fatal(err) + } + if source.config.Host != "" || source.config.Port != 0 || source.channel != 1 || + source.stream != baichuan.StreamSub || source.remote != "uid" || source.backchannel || + source.config.UIDLocalAddr != "192.0.2.28" || source.config.UIDBroadcastAddr != "198.51.100.255" || + source.protocol != "udp" { + t.Fatalf("unexpected UID source: %+v", source) + } + for _, value := range []string{fmt.Sprint(source), fmt.Sprint(source.config)} { + if strings.Contains(value, uid) || strings.Contains(value, "secret") { + t.Fatalf("UID source leaked: %s", value) + } + } +} + +func TestParseURLRejectsInvalidSource(t *testing.T) { + for _, source := range []string{ + "http://admin:pass@camera", "reolink://camera", "reolink://admin:pass@camera?channel=256", + "reolink://admin:pass@camera?stream=fluent", "reolink://admin:pass@camera/main?stream=sub", + "reolink://admin:pass@camera?unknown=1", "reolink://admin:pass@camera?backchannel=yes", + "reolink://admin:pass@camera?channel=%zz", + "reolink://admin:pass@camera:9000?transport=uid", "reolink://admin:pass@camera?transport=cloud", + "reolink://admin:pass@camera?local=192.0.2.28", + "reolink://admin:pass@camera?transport=uid&local=127.0.0.1", + "reolink://admin:pass@camera?broadcast=198.51.100.255", + "reolink://admin:pass@camera?transport=uid&broadcast=127.0.0.1", + "reolink://admin:pass@camera?transport=uid&broadcast=224.0.0.1", + } { + if _, err := parseURL(source); err == nil { + t.Fatalf("accepted invalid source: %s", source) + } + } +} + +func TestParseURLErrorRedactsMalformedSource(t *testing.T) { + const source = "reolink://sentinel-user:sentinel-pass@camera:%zz" + _, err := parseURL(source) + if err == nil || strings.Contains(err.Error(), "sentinel") { + t.Fatalf("unsafe parse error: %v", err) + } +} + +func TestParseUIDErrorRegistersSecret(t *testing.T) { + const uid = "MalformedUID1234" + const source = "reolink://admin:pass@" + uid + "?transport=uid&unknown=1" + if _, err := parseURL(source); err == nil { + t.Fatal("accepted invalid UID source") + } + if redacted := creds.SecretString(source); strings.Contains(redacted, uid) { + t.Fatalf("UID source leaked: %s", redacted) + } +} + +func TestMediaClock(t *testing.T) { + clock := mediaClock{value: 100} + if value, err := clock.next(0xfffffff0, 90000); err != nil || value != 100 { + t.Fatalf("unexpected initial value: %d, %v", value, err) + } + if value, err := clock.next(0x0000c340, 90000); err != nil || value != 4600 { + t.Fatalf("unexpected wrapped value: %d, %v", value, err) + } + if value, err := clock.next(0x0000c340, 90000); err != nil || value != 4600 { + t.Fatalf("unexpected duplicate value: %d, %v", value, err) + } +} + +func TestMediaClockRejectsDiscontinuity(t *testing.T) { + for _, timestamps := range [][2]uint32{ + {1_000_000, 900_000}, + {1_000_000, 1_000_000 + maxTimestampStep + 1}, + } { + clock := mediaClock{} + if _, err := clock.next(timestamps[0], 90000); err != nil { + t.Fatal(err) + } + if _, err := clock.next(timestamps[1], 90000); err == nil { + t.Fatalf("accepted timestamp discontinuity %v", timestamps) + } + } +} + +func TestNextTimestampEpoch(t *testing.T) { + for _, test := range []struct { + name string + now, previous uint32 + want uint32 + }{ + {name: "Forward", now: 200, previous: 100, want: 200}, + {name: "Backward", now: 90, previous: 100, want: 101}, + {name: "Duplicate", now: 100, previous: 100, want: 101}, + {name: "Wrap", now: 20, previous: 0xfffffff0, want: 20}, + } { + t.Run(test.name, func(t *testing.T) { + if actual := nextTimestampEpoch(test.now, test.previous, true); actual != test.want { + t.Fatalf("unexpected epoch: %d != %d", actual, test.want) + } + }) + } +} + +func TestAudioPayload(t *testing.T) { + header := []byte{0xff, 0xf1, 0x60, 0x40, 0x00, 0x1f, 0xfc} + data := append(header, []byte{1, 2, 3}...) + aac.WriteADTSSize(data, uint16(len(data))) + payload, samples, err := audioPayload(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data}) + if err != nil { + t.Fatal(err) + } + if string(payload) != "\x01\x02\x03" || samples != 1024 { + t.Fatalf("unexpected AAC packet: %x %d", payload, samples) + } +} + +func TestADPCMAudioPayload(t *testing.T) { + payload, samples, err := audioPayload(baichuan.MediaPacket{ + Kind: baichuan.MediaADPCM, Data: []byte{0, 0, 0, 0, 0}, + }) + if err != nil { + t.Fatal(err) + } + if samples != 2 || len(payload) != 2 || payload[0] != 0xd5 || payload[1] != 0xd5 { + t.Fatalf("unexpected PCMA payload: %x samples=%d", payload, samples) + } +} + +func TestAddAudioRejectsInvalidAACConfig(t *testing.T) { + base := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc} + reservedRate := append([]byte(nil), base...) + reservedRate[2] = reservedRate[2]&0xc3 | 0x3c + noChannels := append([]byte(nil), base...) + noChannels[2] &^= 1 + noChannels[3] &^= 0xc0 + for _, data := range [][]byte{reservedRate, noChannels} { + pipeline := &mediaPipeline{} + if err := pipeline.configureAudio(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data}); err == nil { + t.Fatalf("accepted invalid AAC config: %x", data) + } + } +} + +func TestWritePacketRejectsAACConfigChange(t *testing.T) { + data := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc} + pipeline := &mediaPipeline{} + if err := pipeline.configureAudio(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: data}); err != nil { + t.Fatal(err) + } + changed := append([]byte(nil), data...) + changed[2] += 4 + if _, err := pipeline.frame(baichuan.MediaPacket{Kind: baichuan.MediaAAC, Data: changed}); err == nil || + !strings.Contains(err.Error(), "configuration changed") { + t.Fatalf("unexpected AAC configuration error: %v", err) + } +} + +func TestRetainProbePacketKeepsLatestGOP(t *testing.T) { + probe := &mediaProbe{} + for _, packet := range []baichuan.MediaPacket{ + {Kind: baichuan.MediaVideoI, Data: make([]byte, 10)}, + {Kind: baichuan.MediaVideoP, Data: make([]byte, 5)}, + {Kind: baichuan.MediaVideoI, Data: make([]byte, 7)}, + } { + if err := probe.retain(packet); err != nil { + t.Fatal(err) + } + } + if len(probe.packets) != 1 || probe.bytes != 7 || probe.packets[0].Kind != baichuan.MediaVideoI { + t.Fatalf("unexpected retained probe: packets=%d bytes=%d", len(probe.packets), probe.bytes) + } +} + +func TestRetainProbePacketOwnsPayload(t *testing.T) { + backing := make([]byte, 1024) + packet := baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Data: backing[len(backing)-1:]} + packet.Data[0] = 1 + probe := &mediaProbe{} + if err := probe.retain(packet); err != nil { + t.Fatal(err) + } + packet.Data[0] = 2 + if probe.packets[0].Data[0] != 1 { + t.Fatal("probe retained the source payload allocation") + } +} + +func TestRetainProbePacketLimit(t *testing.T) { + probe := &mediaProbe{bytes: maxProbeBytes} + if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Data: []byte{1}}); err == nil { + t.Fatal("accepted probe packet beyond memory limit") + } +} + +func TestRetainProbePacketCountLimitAndRelease(t *testing.T) { + probe := &mediaProbe{} + for range maxProbePackets { + if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaInfo}); err != nil { + t.Fatal(err) + } + } + if err := probe.retain(baichuan.MediaPacket{Kind: baichuan.MediaInfo}); err == nil { + t.Fatal("accepted probe packet beyond count limit") + } + probe.release() + if probe.packets != nil || probe.bytes != 0 { + t.Fatalf("probe retained data after release: packets=%d bytes=%d", len(probe.packets), probe.bytes) + } +} + +func TestBackchannelRejectsOversizedPacketBeforeDial(t *testing.T) { + p := &Producer{ctx: context.Background()} + b := &backchannel{producer: p, format: baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016}} + if err := b.setCodec(&core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}); err != nil { + t.Fatal(err) + } + if err := b.Write(make([]byte, 2033)); err == nil || !strings.Contains(err.Error(), "sample limit") { + t.Fatalf("unexpected oversized packet error: %v", err) + } +} diff --git a/pkg/reolink/profile.go b/pkg/reolink/profile.go new file mode 100644 index 000000000..9fed47241 --- /dev/null +++ b/pkg/reolink/profile.go @@ -0,0 +1,466 @@ +package reolink + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + profileProbeTimeout = 10 * time.Second + profileTrackCheckInterval = time.Second + profileTrackStallTimeout = 15 * time.Second + profileAudioRateWindow = 60 * time.Second +) + +type profileState uint8 + +const ( + profileStarting profileState = iota + profileActive + profileClosing + profileFailed + profileClosed +) + +func (s profileState) String() string { + switch s { + case profileStarting: + return "starting" + case profileActive: + return "active" + case profileClosing: + return "closing" + case profileFailed: + return "failed" + case profileClosed: + return "closed" + default: + return "unknown" + } +} + +type Profile struct { + camera *Camera + key ProfileKey + generation uint64 + source source + open profileOpener + + ctx context.Context + cancel context.CancelFunc + startCtx context.Context + startCancel context.CancelFunc + ready chan struct{} + done chan struct{} + start sync.Once + close sync.Once + + mu sync.Mutex + state profileState + released bool + input profileInput + terminal error + closeErr error + errClass string + pipeline mediaPipeline + receivers []*core.Receiver + video *core.Receiver + audio *core.Receiver + discovery discoverySnapshot + + talkMu sync.Mutex + talkKnown bool + talkSupported bool + talkFormat baichuan.TalkFormat + + recvBytes atomic.Uint64 + videoFrames atomic.Uint64 + audioSamples atomic.Uint64 + + trackCheckInterval time.Duration + trackStallTimeout time.Duration + trackRateWindow time.Duration +} + +func (p *Profile) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprintf(state, "reolink.Profile{Remote:%q, Channel:%d, Stream:%q, Generation:%d}", + p.camera.remote, p.key.Channel, p.key.Stream, p.generation) +} + +func newProfile( + camera *Camera, key ProfileKey, generation uint64, s source, open profileOpener, +) *Profile { + ctx, cancel := context.WithCancel(context.Background()) + startCtx, startCancel := context.WithCancel(ctx) + return &Profile{ + camera: camera, key: key, generation: generation, source: s, open: open, + ctx: ctx, cancel: cancel, startCtx: startCtx, startCancel: startCancel, + ready: make(chan struct{}), done: make(chan struct{}), state: profileStarting, + pipeline: mediaPipeline{epoch: camera.nextEpoch(key, core.Now90000())}, + trackCheckInterval: profileTrackCheckInterval, trackStallTimeout: profileTrackStallTimeout, + trackRateWindow: profileAudioRateWindow, + } +} + +func (p *Profile) run() { + setup, cancel := context.WithTimeout(p.ctx, p.sourceTimeout()) + input, err := p.open(setup, p.camera, p.source) + cancel() + if err != nil { + p.fail(fmt.Errorf("reolink: open profile: %w", err)) + close(p.done) + return + } + p.mu.Lock() + p.input = input + p.discovery = input.Discovery() + p.mu.Unlock() + + var probe mediaProbe + if probe, err = p.probe(input); err == nil { + p.initReceivers() + err = p.markReady() + } + if err == nil { + err = p.prepareStart(input, &probe) + } + probe.release() + if err == nil { + err = p.read(input) + } + failed := p.beginTerminal(err) + var closeErr error + if failed { + closeErr = input.Abort() + } else { + closeErr = input.Close() + } + if failed && closeErr != nil { + p.joinTerminal(closeErr) + } + p.finishSession(closeErr) + close(p.done) +} + +func (p *Profile) sourceTimeout() time.Duration { + if p.source.config.Timeout > 0 { + return p.source.config.Timeout + } + return baichuan.DefaultTimeout +} + +func (p *Profile) probe(input profileInput) (mediaProbe, error) { + ctx, cancel := context.WithTimeout(p.ctx, profileProbeTimeout) + defer cancel() + var probe mediaProbe + for { + videoReady := p.source.suppressVideo || p.pipeline.videoMedia != nil + audioReady := p.source.suppressAudio || p.pipeline.audioMedia != nil + if videoReady && audioReady { + break + } + packet, err := input.Read(ctx) + if err != nil { + return mediaProbe{}, fmt.Errorf("reolink: probe media: %w", err) + } + p.record(packet) + if p.retainPacket(packet) { + if err = p.retainProbe(&probe, packet); err != nil { + return mediaProbe{}, err + } + } + switch packet.Kind { + case baichuan.MediaVideoI: + if !p.source.suppressVideo && p.pipeline.videoMedia == nil { + if err = p.pipeline.probeVideo(packet); err != nil { + return mediaProbe{}, err + } + } + case baichuan.MediaAAC, baichuan.MediaADPCM: + if !p.source.suppressAudio && p.pipeline.audioMedia == nil { + if err = p.pipeline.configureAudio(packet); err != nil { + return mediaProbe{}, err + } + } + } + } + return probe, nil +} + +func (p *Profile) initReceivers() { + for _, media := range p.pipeline.medias { + receiver := core.NewReceiver(media, media.Codecs[0]) + p.receivers = append(p.receivers, receiver) + if media.Kind == core.KindVideo { + p.video = receiver + } else if media.Kind == core.KindAudio { + p.audio = receiver + } + } +} + +func (p *Profile) replay(probe mediaProbe) error { + for _, packet := range probe.packets { + frame, err := p.pipeline.frame(packet) + if err != nil { + return err + } + if frame.valid() { + p.writeFrame(frame) + } + } + return nil +} + +func (p *Profile) read(input profileInput) error { + ctx, cancel := context.WithCancel(p.ctx) + stalled := make(chan error, 1) + done := make(chan struct{}) + go func() { + p.watchTracks(ctx, cancel, stalled) + close(done) + }() + defer func() { + cancel() + <-done + }() + + for { + packet, err := input.Read(ctx) + if err != nil { + select { + case err = <-stalled: + return err + default: + } + return fmt.Errorf("reolink: read media: %w", err) + } + p.record(packet) + if !p.acceptPacket(packet) { + continue + } + frame, err := p.pipeline.frame(packet) + if err != nil { + return err + } + if frame.valid() { + p.writeFrame(frame) + } + } +} + +func (p *Profile) writeFrame(frame mediaFrame) { + if frame.track == trackVideo && p.video != nil { + p.videoFrames.Add(1) + p.video.WriteRTP(frame.packet) + } else if frame.track == trackAudio && p.audio != nil { + p.audioSamples.Add(uint64(frame.samples)) + p.audio.WriteRTP(frame.packet) + } +} + +func (p *Profile) prepareStart(input profileInput, probe *mediaProbe) error { + for { + select { + case <-p.startCtx.Done(): + if err := p.ctx.Err(); err != nil { + return err + } + return p.replay(*probe) + default: + } + packet, err := input.Read(p.startCtx) + if err != nil { + if errors.Is(err, context.Canceled) && p.startCtx.Err() != nil && p.ctx.Err() == nil { + return p.replay(*probe) + } + return fmt.Errorf("reolink: wait for start: %w", err) + } + p.record(packet) + if p.retainPacket(packet) { + err = p.retainProbe(probe, packet) + } + if err != nil { + return err + } + } +} + +func (p *Profile) retainProbe(probe *mediaProbe, packet baichuan.MediaPacket) error { + if p.source.suppressVideo { + probe.reset() + } + return probe.retain(packet) +} + +func (p *Profile) acceptPacket(packet baichuan.MediaPacket) bool { + switch packet.Kind { + case baichuan.MediaVideoI, baichuan.MediaVideoP: + return !p.source.suppressVideo + case baichuan.MediaAAC, baichuan.MediaADPCM: + return !p.source.suppressAudio + default: + return false + } +} + +func (p *Profile) retainPacket(packet baichuan.MediaPacket) bool { + if !p.source.suppressVideo && !p.source.suppressAudio { + return true + } + return p.acceptPacket(packet) +} + +func (p *Profile) startProfile() { + p.start.Do(p.startCancel) +} + +func (p *Profile) closeReceivers() { + p.close.Do(func() { + for _, receiver := range p.receivers { + receiver.Close() + } + }) +} + +func (p *Profile) record(packet baichuan.MediaPacket) { + p.recvBytes.Add(uint64(len(packet.Data))) +} + +func (p *Profile) markReady() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.state != profileStarting { + return p.terminalError() + } + p.state = profileActive + close(p.ready) + return nil +} + +func (p *Profile) waitReady() error { + <-p.ready + p.mu.Lock() + defer p.mu.Unlock() + if p.state != profileActive { + return p.terminalError() + } + return nil +} + +func (p *Profile) terminalError() error { + if p.terminal != nil { + return p.terminal + } + return fmt.Errorf("reolink: profile %s", p.state) +} + +func (p *Profile) beginTerminal(err error) bool { + p.camera.registry.mu.Lock() + defer p.camera.registry.mu.Unlock() + p.mu.Lock() + defer p.mu.Unlock() + if p.state == profileClosing || p.state == profileClosed || p.state == profileFailed { + return false + } + if _, ok := p.camera.profiles[p]; ok { + delete(p.camera.profiles, p) + } + p.state = profileFailed + p.terminal = err + p.errClass = errorClass(err) + p.cancel() + select { + case <-p.ready: + default: + close(p.ready) + } + return true +} + +func (p *Profile) joinTerminal(err error) { + p.mu.Lock() + p.terminal = errors.Join(p.terminal, err) + p.mu.Unlock() +} + +func (p *Profile) err() error { + p.mu.Lock() + defer p.mu.Unlock() + return p.terminalError() +} + +func (p *Profile) closeError() error { + p.mu.Lock() + defer p.mu.Unlock() + return p.closeErr +} + +func (p *Profile) finishSession(closeErr error) { + registry := p.camera.registry + registry.mu.Lock() + p.mu.Lock() + p.closeErr = closeErr + if p.state == profileClosing { + p.state = profileClosed + if closeErr != nil { + p.terminal = closeErr + p.errClass = errorClass(closeErr) + } + } + state := p.state.String() + errClass := p.errClass + epoch := mediaEpoch{ + audio: p.pipeline.audioTS, audioRate: p.pipeline.audioRate, audioSet: p.pipeline.audioMedia != nil, + } + if epoch.video, epoch.videoSet = p.pipeline.videoTS.current(); epoch.videoSet || epoch.audioSet { + p.camera.recordEpoch(p.key, epoch) + } + p.mu.Unlock() + p.camera.removeSession(p.key.Stream) + registry.removeCameraLocked(p.camera) + registry.mu.Unlock() + event := registry.log.Debug().Str("camera", p.camera.remote).Uint8("channel", p.key.Channel). + Str("profile", string(p.key.Stream)).Uint64("generation", p.generation). + Str("state", state).Str("error_class", errClass) + if closeErr != nil { + event = event.Err(closeErr) + } + event.Msg("reolink profile stopped") +} + +func (p *Profile) fail(err error) { + p.beginTerminal(err) + p.finishSession(nil) +} + +func errorClass(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, context.Canceled): + return "canceled" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return "eof" + } + var netErr net.Error + if errors.As(err, &netErr) { + return "network" + } + var mediaErr *mediaError + if errors.As(err, &mediaErr) { + return "media" + } + return "protocol" +} diff --git a/pkg/reolink/profile_input.go b/pkg/reolink/profile_input.go new file mode 100644 index 000000000..667fa80c5 --- /dev/null +++ b/pkg/reolink/profile_input.go @@ -0,0 +1,149 @@ +package reolink + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" +) + +type profileInput interface { + Read(context.Context) (baichuan.MediaPacket, error) + ProbeTalk(context.Context, uint8) (baichuan.TalkFormat, error) + Discovery() discoverySnapshot + Abort() error + Close() error +} + +type profileOpener func(context.Context, *Camera, source) (profileInput, error) + +const capabilityProbeTimeout = 2 * time.Second + +type discoverySnapshot struct { + device baichuan.DeviceInfo + capabilities baichuan.Capabilities + deviceStatus string + capabilityStatus string +} + +func (p *Profile) probeTalk() (baichuan.TalkFormat, bool, error) { + p.talkMu.Lock() + defer p.talkMu.Unlock() + if p.talkKnown { + return p.talkFormat, p.talkSupported, nil + } + p.mu.Lock() + input := p.input + state := p.state + terminal := p.terminal + p.mu.Unlock() + if input == nil || state != profileActive { + if terminal == nil { + terminal = fmt.Errorf("reolink: profile %s", state) + } + return baichuan.TalkFormat{}, false, terminal + } + ctx, cancel := context.WithTimeout(p.ctx, p.sourceTimeout()) + format, err := input.ProbeTalk(ctx, p.key.Channel) + cancel() + if err != nil { + var unsupported *baichuan.UnsupportedTalkError + if !errors.As(err, &unsupported) { + return baichuan.TalkFormat{}, false, fmt.Errorf("reolink: probe talkback: %w", err) + } + p.talkKnown = true + return baichuan.TalkFormat{}, false, nil + } + p.talkKnown = true + p.talkSupported = true + p.talkFormat = format + return format, true, nil +} + +type baichuanInput struct { + client *baichuan.Client + preview *baichuan.Preview + discovery discoverySnapshot + once sync.Once + err error +} + +func openBaichuan(ctx context.Context, _ *Camera, s source) (profileInput, error) { + client, err := baichuan.Dial(ctx, s.config) + if err != nil { + return nil, err + } + if err = client.Login(ctx); err != nil { + return nil, errors.Join(err, client.Close()) + } + discovery := discover(ctx, client, s.channel) + preview, err := client.StartPreview(ctx, s.channel, s.stream) + if err != nil { + return nil, errors.Join(err, client.Close()) + } + return &baichuanInput{client: client, preview: preview, discovery: discovery}, nil +} + +func discover(ctx context.Context, client *baichuan.Client, channel uint8) discoverySnapshot { + ctx, cancel := context.WithTimeout(ctx, capabilityProbeTimeout) + defer cancel() + var device baichuan.DeviceInfo + var capabilities baichuan.Capabilities + var deviceErr, capabilityErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + device, deviceErr = client.DeviceInfo(ctx) + }() + go func() { + defer wg.Done() + capabilities, capabilityErr = client.Capabilities(ctx, channel) + }() + wg.Wait() + return discoverySnapshot{ + device: device, capabilities: capabilities, + deviceStatus: discoveryStatus(deviceErr), capabilityStatus: discoveryStatus(capabilityErr), + } +} + +func discoveryStatus(err error) string { + if err == nil { + return "available" + } + return errorClass(err) +} + +func (i *baichuanInput) Discovery() discoverySnapshot { + return i.discovery +} + +func (i *baichuanInput) Read(ctx context.Context) (baichuan.MediaPacket, error) { + return i.preview.Read(ctx) +} + +func (i *baichuanInput) ProbeTalk(ctx context.Context, channel uint8) (baichuan.TalkFormat, error) { + return i.client.ProbeTalk(ctx, channel) +} + +func (i *baichuanInput) Close() error { + return i.close(true) +} + +func (i *baichuanInput) Abort() error { + return i.close(false) +} + +func (i *baichuanInput) close(graceful bool) error { + i.once.Do(func() { + if graceful { + i.err = errors.Join(i.preview.Close(), i.client.Close()) + } else { + i.err = errors.Join(i.client.Close(), i.preview.Close()) + } + }) + return i.err +} diff --git a/pkg/reolink/profile_liveness.go b/pkg/reolink/profile_liveness.go new file mode 100644 index 000000000..76823bdc8 --- /dev/null +++ b/pkg/reolink/profile_liveness.go @@ -0,0 +1,63 @@ +package reolink + +import ( + "context" + "time" +) + +func (p *Profile) watchTracks(ctx context.Context, cancel context.CancelFunc, stalled chan<- error) { + if p.trackCheckInterval <= 0 || p.trackStallTimeout <= 0 { + return + } + rawOnly := p.video == nil && p.audio == nil + ticker := time.NewTicker(p.trackCheckInterval) + defer ticker.Stop() + video, audio := p.videoFrames.Load(), p.audioSamples.Load() + videoAt, audioAt := time.Now(), time.Now() + received, receivedAt := p.recvBytes.Load(), time.Now() + rateSamples, rateAt := p.audioSamples.Load(), time.Now() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + if frames := p.videoFrames.Load(); frames != video { + video, videoAt = frames, now + } + if samples := p.audioSamples.Load(); samples != audio { + audio, audioAt = samples, now + } + if rawOnly { + if bytes := p.recvBytes.Load(); bytes != received { + received, receivedAt = bytes, now + } + } + var err error + if rawOnly && now.Sub(receivedAt) >= p.trackStallTimeout { + err = mediaErrorf("reolink: media stream stalled") + } else if p.video != nil && now.Sub(videoAt) >= p.trackStallTimeout { + err = mediaErrorf("reolink: video track stalled") + } else if p.audio != nil && now.Sub(audioAt) >= p.trackStallTimeout { + err = mediaErrorf("reolink: audio track stalled") + } else if p.audio != nil && p.pipeline.audioRate > 0 && p.trackRateWindow > 0 && + now.Sub(rateAt) >= p.trackRateWindow { + samples := p.audioSamples.Load() + if audioRateLow(samples-rateSamples, p.pipeline.audioRate, now.Sub(rateAt)) { + err = mediaErrorf("reolink: audio track rate below expected") + } else { + rateSamples, rateAt = samples, now + } + } + if err != nil { + stalled <- err + cancel() + return + } + } + } +} + +func audioRateLow(samples uint64, rate uint32, elapsed time.Duration) bool { + expected := uint64(rate) * uint64(elapsed) / uint64(time.Second) + return samples < expected-expected/4 +} diff --git a/pkg/reolink/profile_liveness_test.go b/pkg/reolink/profile_liveness_test.go new file mode 100644 index 000000000..5f357b16e --- /dev/null +++ b/pkg/reolink/profile_liveness_test.go @@ -0,0 +1,259 @@ +package reolink + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" +) + +const ( + testTrackCheckInterval = 10 * time.Millisecond + testTrackStallTimeout = 100 * time.Millisecond +) + +func TestProfileFailsWhenAdvertisedTrackStalls(t *testing.T) { + for _, test := range []struct { + name string + packet func(uint32) baichuan.MediaPacket + want string + closeErr error + }{ + {name: "video", packet: testAAC, want: "video track stalled"}, + {name: "audio", packet: testH264PFrame, want: "audio track stalled", closeErr: context.Canceled}, + } { + t.Run(test.name, func(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + producer.profile.trackCheckInterval = testTrackCheckInterval + producer.profile.trackStallTimeout = testTrackStallTimeout + done := make(chan error, 1) + go func() { done <- producer.Start() }() + input := opener.input(0) + input.closeErr = test.closeErr + ticker := time.NewTicker(testTrackCheckInterval) + defer ticker.Stop() + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + for timestamp := uint32(1_050_000); ; timestamp += 5_000 { + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("unexpected terminal error: %v", err) + } + if class := errorClass(err); test.closeErr == nil && class != "media" { + t.Fatalf("track stall classified as %q", class) + } + if class := producer.diagnostics().LastError; class != "media" { + t.Fatalf("profile recorded track stall as %q", class) + } + if err = producer.Stop(); !errors.Is(err, test.closeErr) { + t.Fatalf("unexpected stop error: %v", err) + } + return + case <-ticker.C: + input.results <- inputResult{packet: test.packet(timestamp)} + case <-deadline.C: + t.Fatal("track stall did not stop producer") + } + } + }) + } +} + +func TestProfileDoesNotMonitorUnadvertisedAudio(t *testing.T) { + profile := deliveryTestProfile() + profile.trackCheckInterval = testTrackCheckInterval + profile.trackStallTimeout = testTrackStallTimeout + ctx, cancel := context.WithCancel(context.Background()) + stalled := make(chan error, 1) + done := make(chan struct{}) + go func() { + profile.watchTracks(ctx, cancel, stalled) + close(done) + }() + for range 15 { + profile.videoFrames.Add(1) + time.Sleep(testTrackCheckInterval) + } + select { + case err := <-stalled: + t.Fatalf("unadvertised audio stopped video-only profile: %v", err) + default: + } + cancel() + <-done +} + +func TestBackchannelOnlyProfileStallsWithoutMedia(t *testing.T) { + format := baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016} + opener := &fakeProfileOpener{talk: &format} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=0") + if err != nil { + t.Fatal(err) + } + producer.profile.trackCheckInterval = testTrackCheckInterval + producer.profile.trackStallTimeout = testTrackStallTimeout + done := make(chan error, 1) + go func() { done <- producer.Start() }() + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "media stream stalled") { + t.Fatalf("unexpected terminal error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("backchannel-only media stall did not stop producer") + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestProfileReconnectsAfterLowAudioRate(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + const source = "reolink://admin:secret@camera/main?backchannel=0" + first, err := registry.Dial(source) + if err != nil { + t.Fatal(err) + } + setTestTrackHealth(first.profile, 300*time.Millisecond, 500*time.Millisecond) + firstDone := make(chan error, 1) + go func() { firstDone <- first.Start() }() + input := opener.input(0) + err = runProfileTraffic(input, firstDone, 20*time.Millisecond, 200*time.Millisecond, 2*time.Second) + if err == nil || !strings.Contains(err.Error(), "audio track rate below expected") { + t.Fatalf("unexpected under-rate result: %v", err) + } + if class := first.diagnostics().LastError; class != "media" { + t.Fatalf("under-rate failure classified as %q", class) + } + + replacement, err := registry.Dial(source) + if err != nil { + t.Fatalf("replacement profile failed: %v", err) + } + setTestTrackHealth(replacement.profile, 300*time.Millisecond, 300*time.Millisecond) + replacementDone := make(chan error, 1) + go func() { replacementDone <- replacement.Start() }() + input = opener.input(1) + if err = runProfileTraffic(input, replacementDone, 20*time.Millisecond, 50*time.Millisecond, time.Second); err != nil { + t.Fatalf("full-rate replacement stopped: %v", err) + } + if err = first.Stop(); err != nil { + t.Fatalf("failed to stop terminal profile: %v", err) + } + input.results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-replacementDone; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected replacement terminal error: %v", err) + } + if err = replacement.Stop(); err != nil { + t.Fatal(err) + } +} + +func setTestTrackHealth(profile *Profile, stall, rateWindow time.Duration) { + profile.trackCheckInterval = 5 * time.Millisecond + profile.trackStallTimeout = stall + profile.trackRateWindow = rateWindow +} + +func runProfileTraffic( + input *fakeProfileInput, done <-chan error, videoEvery, audioEvery, duration time.Duration, +) error { + video := time.NewTicker(videoEvery) + defer video.Stop() + audio := time.NewTicker(audioEvery) + defer audio.Stop() + deadline := time.NewTimer(duration) + defer deadline.Stop() + var timestamp uint32 = 1_050_000 + for { + select { + case err := <-done: + return err + case <-video.C: + input.results <- inputResult{packet: testH264PFrame(timestamp)} + timestamp += 5_000 + case <-audio.C: + input.results <- inputResult{packet: testAAC(timestamp)} + case <-deadline.C: + return nil + } + } +} + +func TestProfileIgnoresStalledSuppressedTrack(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0") + if err != nil { + t.Fatal(err) + } + producer.profile.trackCheckInterval = testTrackCheckInterval + producer.profile.trackStallTimeout = testTrackStallTimeout + done := make(chan error, 1) + go func() { done <- producer.Start() }() + input := opener.input(0) + ticker := time.NewTicker(testTrackCheckInterval) + defer ticker.Stop() + stable := time.NewTimer(250 * time.Millisecond) + defer stable.Stop() + for timestamp := uint32(1_050_000); ; timestamp += 5_000 { + select { + case err = <-done: + t.Fatalf("suppressed video stall stopped producer: %v", err) + case <-ticker.C: + input.results <- inputResult{packet: testH264PFrame(timestamp)} + input.results <- inputResult{packet: testAAC(timestamp)} + case <-stable.C: + goto passed + } + } +passed: + input.results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestProfileStallsOnEnabledTrackOnly(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0") + if err != nil { + t.Fatal(err) + } + producer.profile.trackCheckInterval = testTrackCheckInterval + producer.profile.trackStallTimeout = testTrackStallTimeout + done := make(chan error, 1) + go func() { done <- producer.Start() }() + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + for { + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "audio track stalled") { + t.Fatalf("unexpected terminal error: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatalf("unexpected stop error: %v", err) + } + return + case <-deadline.C: + t.Fatal("audio stall did not stop producer") + } + } +} diff --git a/pkg/reolink/profile_test.go b/pkg/reolink/profile_test.go new file mode 100644 index 000000000..0238d4813 --- /dev/null +++ b/pkg/reolink/profile_test.go @@ -0,0 +1,382 @@ +package reolink + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func deliveryTestProfile() *Profile { + media := &core.Media{Kind: core.KindVideo, Direction: core.DirectionRecvonly} + codec := &core.Codec{Name: core.CodecH265, ClockRate: 90000} + media.Codecs = []*core.Codec{codec} + receiver := core.NewReceiver(media, codec) + return &Profile{video: receiver, receivers: []*core.Receiver{receiver}} +} + +func TestProducerDiagnosticsAreRedacted(t *testing.T) { + opener := &fakeProfileOpener{discovery: discoverySnapshot{ + device: baichuan.DeviceInfo{ + Type: "E1 Zoom", Model: "E340", Hardware: "IPC_NT14", Firmware: "v3.2.0", + }, + capabilities: baichuan.Capabilities{ + ObservedChannels: []uint8{0}, + }, + deviceStatus: "available", capabilityStatus: "available", + }} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + producer.Source = "reolink://sentinel-user:sentinel-pass@camera" + producer.URL = producer.Source + data, err := json.Marshal(producer) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") { + t.Fatalf("credentials exposed in diagnostics: %s", text) + } + for _, expected := range []string{ + `"remote_addr":"camera:9000"`, `"generation":1`, `"main_sessions":1`, + `"profiles":1`, `"camera_type":"E1 Zoom"`, + `"camera_model":"E340"`, `"hardware_version":"IPC_NT14"`, + `"firmware_version":"v3.2.0"`, `"device_info_status":"available"`, + `"capability_status":"available"`, `"observed_channels":[0]`, + } { + if !strings.Contains(text, expected) { + t.Fatalf("missing %s in diagnostics: %s", expected, text) + } + } + _ = producer.Stop() + waitFor(t, opener.input(0).closed.Load) +} + +func TestProducerDiagnosticsDuringDelivery(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- producer.Start() }() + input := opener.input(0) + go func() { + for i := range 1000 { + input.results <- inputResult{packet: testH264PFrame(uint32(1_050_000 + i*50_000))} + } + input.results <- inputResult{err: io.ErrUnexpectedEOF} + }() + for range 1000 { + if _, err = json.Marshal(producer); err != nil { + t.Fatal(err) + } + } + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + if !input.aborted.Load() { + t.Fatal("terminal failure closed input gracefully") + } + if class := producer.diagnostics().LastError; class != "eof" { + t.Fatalf("truncated network frame classified as %q", class) + } + if video, audio := producer.profile.videoFrames.Load(), producer.profile.audioSamples.Load(); video != 1001 || audio == 0 { + t.Fatalf("unexpected liveness state: video=%d audio_samples=%d", video, audio) + } + _ = producer.Stop() +} + +func TestProducerResyncsWithoutRestartAfterInvalidVideo(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- producer.Start() }() + waitFor(t, func() bool { return producer.profile.startCtx.Err() != nil }) + input := opener.input(0) + frames := producer.profile.videoFrames.Load() + input.results <- inputResult{packet: baichuan.MediaPacket{ + Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: 1_050_000, + Data: []byte{0, 0, 0, 1, 0xc1, 1}, + }} + input.results <- inputResult{packet: testH264PFrame(1_100_000)} + input.results <- inputResult{packet: testH264Keyframe(1_150_000)} + waitFor(t, func() bool { return producer.profile.videoFrames.Load() == frames+1 }) + select { + case err = <-done: + t.Fatalf("video repair stopped producer: %v", err) + default: + } + input.results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestProfileWritesFrameDirectlyToReceiver(t *testing.T) { + profile := deliveryTestProfile() + var received *rtp.Packet + (&core.Node{Input: func(packet *rtp.Packet) { + received = packet + }}).WithParent(&profile.video.Node) + frame := mediaFrame{track: trackVideo, packet: &rtp.Packet{ + Header: rtp.Header{Timestamp: 7}, Payload: []byte{1, 2, 3}, + }} + profile.writeFrame(frame) + if received != frame.packet { + t.Fatal("profile copied the RTP packet") + } +} + +func TestProducerStartReplaysProbeAfterTrackAttachment(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + packets := make(chan *rtp.Packet, 1) + (&core.Node{Input: func(packet *rtp.Packet) { packets <- packet }}).WithParent(&producer.Receivers[0].Node) + done := make(chan error, 1) + go func() { done <- producer.Start() }() + select { + case packet := <-packets: + if packet.Timestamp == 0 || len(packet.Payload) == 0 { + t.Fatalf("unexpected probe frame: timestamp=%d size=%d", packet.Timestamp, len(packet.Payload)) + } + case <-time.After(time.Second): + t.Fatal("probe frame was not replayed after start") + } + select { + case err = <-done: + t.Fatalf("producer stopped before cancellation: %v", err) + default: + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } + if err = <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected stop error: %v", err) + } + input := opener.input(0) + if !input.closed.Load() { + t.Fatal("producer stop returned before its input closed") + } + if input.aborted.Load() { + t.Fatal("producer stop aborted input") + } +} + +func TestProfileProbeSkipsInvalidInitialVideoKeyframe(t *testing.T) { + input := &fakeProfileInput{results: make(chan inputResult, 3), done: make(chan struct{})} + invalid := testVideoKeyframe("H265", 1_000_000) + invalid.Data[4] |= 0x80 + input.results <- inputResult{packet: invalid} + input.results <- inputResult{packet: testVideoKeyframe("H265", 1_050_000)} + input.results <- inputResult{packet: testAAC(1_050_000)} + + profile := &Profile{ctx: context.Background()} + probe, err := profile.probe(input) + if err != nil { + t.Fatal(err) + } + if profile.pipeline.videoMedia == nil || profile.pipeline.videoCodec != "H265" { + t.Fatal("probe did not configure the valid replacement keyframe") + } + if len(probe.packets) != 2 || probe.packets[0].Kind != baichuan.MediaVideoI { + t.Fatalf("unexpected retained probe: packets=%d", len(probe.packets)) + } +} + +func TestProfileProbeRequiresEnabledAudio(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + input := &fakeProfileInput{results: make(chan inputResult, 1), done: make(chan struct{})} + input.results <- inputResult{packet: testH264Keyframe(1_000_000)} + profile := &Profile{ctx: ctx} + if _, err := profile.probe(input); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("missing audio did not fail probe: %v", err) + } +} + +func TestProducerRetainsReceiversForReconnect(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + receiver := producer.Receivers[0] + recording := make(chan *rtp.Packet, 2) + live := make(chan *rtp.Packet, 2) + (&core.Node{Input: func(packet *rtp.Packet) { recording <- packet }}).WithParent(&receiver.Node) + (&core.Node{Input: func(packet *rtp.Packet) { live <- packet }}).WithParent(&receiver.Node) + wantPacket := func(name string, packets <-chan *rtp.Packet) *rtp.Packet { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(time.Second): + t.Fatalf("%s consumer did not receive a packet", name) + return nil + } + } + done := make(chan error, 1) + go func() { done <- producer.Start() }() + wantPacket("recording", recording) + wantPacket("live", live) + opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + + replacement := core.NewReceiver(receiver.Media, receiver.Codec) + receiver.Replace(replacement) + packet := &rtp.Packet{Header: rtp.Header{Timestamp: 42}, Payload: []byte{1}} + replacement.WriteRTP(packet) + for name, packets := range map[string]<-chan *rtp.Packet{"recording": recording, "live": live} { + if got := wantPacket(name, packets); got != packet { + t.Fatalf("replacement copied the RTP packet for %s", name) + } + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestProducerReconnectUsesForwardTimestampEpoch(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + first, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + videoReceiver := first.Receivers[0] + audioReceiver := first.Receivers[1] + videoPackets := make(chan *rtp.Packet, 4) + audioPackets := make(chan *rtp.Packet, 4) + (&core.Node{Input: func(packet *rtp.Packet) { videoPackets <- packet }}).WithParent(&videoReceiver.Node) + (&core.Node{Input: func(packet *rtp.Packet) { audioPackets <- packet }}).WithParent(&audioReceiver.Node) + firstDone := make(chan error, 1) + go func() { firstDone <- first.Start() }() + lastVideo := (<-videoPackets).Timestamp + lastAudio := (<-audioPackets).Timestamp + opener.input(0).results <- inputResult{packet: testH264PFrame(1_050_000)} + opener.input(0).results <- inputResult{packet: testAAC(1_050_000)} + lastVideo = (<-videoPackets).Timestamp + lastAudio = (<-audioPackets).Timestamp + opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-firstDone; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected first terminal error: %v", err) + } + + replacement, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + videoReceiver.Replace(replacement.Receivers[0]) + audioReceiver.Replace(replacement.Receivers[1]) + replacementDone := make(chan error, 1) + go func() { replacementDone <- replacement.Start() }() + nextVideo := (<-videoPackets).Timestamp + nextAudio := (<-audioPackets).Timestamp + if !timestampAfter(nextVideo, lastVideo) || !timestampAfter(nextAudio, lastAudio) { + t.Fatalf("replacement timestamps did not advance: video=%d->%d audio=%d->%d", + lastVideo, nextVideo, lastAudio, nextAudio) + } + if err = first.Stop(); err != nil { + t.Fatal(err) + } + if err = replacement.Stop(); err != nil { + t.Fatal(err) + } + if err = <-replacementDone; !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected replacement terminal error: %v", err) + } +} + +func TestProducerStopPropagatesInputCloseError(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + closeErr := errors.New("sentinel input close") + opener.input(0).closeErr = closeErr + if err = producer.Stop(); !errors.Is(err, closeErr) { + t.Fatalf("input close error was suppressed: %v", err) + } + if err = producer.Stop(); !errors.Is(err, closeErr) { + t.Fatalf("repeated stop lost close error: %v", err) + } +} + +func TestProducerFailureStopsProfileBeforeReconnect(t *testing.T) { + format := baichuan.TalkFormat{SampleRate: 16000, SamplePrecision: 16, SamplesPerBlock: 4} + opener := &fakeProfileOpener{talk: &format} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=1") + if err != nil { + t.Fatal(err) + } + producer.talk.dial = func(context.Context, baichuan.Config, uint8) (talkSession, io.Closer, error) { + return &fakeTalk{ + format: format, blocks: make(chan []byte, 1), writeErr: errors.New("sentinel producer failure"), + }, &fakeTalkClient{}, nil + } + done := make(chan error, 1) + go func() { done <- producer.Start() }() + codec := &core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1} + media := &core.Media{ + Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{codec}, + } + track := core.NewReceiver(media, codec) + if err = producer.AddTrack(media, codec, track); err != nil { + t.Fatal(err) + } + track.WriteRTP(&rtp.Packet{Payload: []byte{0xd5, 0xd5}}) + select { + case err = <-done: + if err == nil || !strings.Contains(err.Error(), "sentinel producer failure") { + t.Fatalf("unexpected terminal error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("talkback failure did not stop the producer") + } + data, err := json.Marshal(producer) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"last_error_class":"protocol"`) { + t.Fatalf("diagnostics lost producer failure class: %s", data) + } + replacement, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=1") + if err != nil { + t.Fatalf("replacement rejected after terminal failure: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } + if err = replacement.Stop(); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/reolink/registry.go b/pkg/reolink/registry.go new file mode 100644 index 000000000..25c3bc9b8 --- /dev/null +++ b/pkg/reolink/registry.go @@ -0,0 +1,160 @@ +package reolink + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "fmt" + "hash" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/rs/zerolog" +) + +type cameraKey struct { + endpoint string + credential [sha256.Size]byte +} + +type Registry struct { + mu sync.Mutex + cameras map[cameraKey]*Camera + secret [sha256.Size]byte + open profileOpener + log zerolog.Logger +} + +func (r *Registry) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprint(state, "reolink.Registry") +} + +func NewRegistry(log zerolog.Logger) *Registry { + var secret [sha256.Size]byte + if _, err := rand.Read(secret[:]); err != nil { + panic(fmt.Errorf("reolink: initialize credential identity: %w", err)) + } + return newRegistry(log, secret, openBaichuan) +} + +func newRegistry(log zerolog.Logger, secret [sha256.Size]byte, open profileOpener) *Registry { + return &Registry{ + cameras: make(map[cameraKey]*Camera), secret: secret, + open: open, log: log, + } +} + +func (r *Registry) Dial(rawURL string) (*Producer, error) { + s, err := parseURL(rawURL) + if err != nil { + return nil, err + } + key := r.cameraKey(s) + s.username, s.password, s.identity = "", "", "" + + profile, err := r.acquire(key, s) + if err != nil { + return nil, err + } + if err = profile.waitReady(); err != nil { + r.abort(profile) + return nil, err + } + + var talk *baichuan.TalkFormat + if s.backchannel { + format, supported, probeErr := profile.probeTalk() + if probeErr != nil { + r.abort(profile) + return nil, probeErr + } + if supported { + talk = &format + } + } + if s.suppressVideo && s.suppressAudio && talk == nil { + r.abort(profile) + return nil, fmt.Errorf("reolink: backchannel-only source requires camera talkback") + } + return newProducer(s, profile, talk), nil +} + +func (r *Registry) abort(profile *Profile) { + r.release(profile) + <-profile.done + profile.closeReceivers() +} + +func (r *Registry) cameraKey(s source) cameraKey { + h := hmac.New(sha256.New, r.secret[:]) + writeIdentityField(h, s.username) + writeIdentityField(h, s.password) + writeIdentityField(h, s.identity) + var credential [sha256.Size]byte + copy(credential[:], h.Sum(nil)) + return cameraKey{endpoint: s.remote, credential: credential} +} + +func writeIdentityField(w hash.Hash, value string) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + _, _ = w.Write(size[:]) + _, _ = w.Write([]byte(value)) +} + +func (r *Registry) acquire(key cameraKey, s source) (*Profile, error) { + r.mu.Lock() + defer r.mu.Unlock() + + camera := r.cameras[key] + if camera == nil { + camera = &Camera{ + registry: r, key: key, remote: s.remote, + profiles: make(map[*Profile]struct{}), epochs: make(map[ProfileKey]mediaEpoch), + } + r.cameras[key] = camera + } + profileKey := ProfileKey{Channel: s.channel, Stream: s.stream} + camera.addSession(s.stream) + camera.generation++ + profile := newProfile(camera, profileKey, camera.generation, s, r.open) + camera.refs++ + camera.profiles[profile] = struct{}{} + r.log.Debug().Str("camera", s.remote).Uint8("channel", s.channel). + Str("profile", string(s.stream)).Uint64("generation", profile.generation). + Msg("reolink profile started") + go profile.run() + return profile, nil +} + +func (r *Registry) release(profile *Profile) { + r.mu.Lock() + defer r.mu.Unlock() + profile.mu.Lock() + if profile.released { + profile.mu.Unlock() + return + } + profile.released = true + if profile.camera.refs == 0 { + panic("reolink: camera reference accounting underflow") + } + profile.camera.refs-- + if _, ok := profile.camera.profiles[profile]; ok { + delete(profile.camera.profiles, profile) + if profile.state == profileStarting || profile.state == profileActive { + profile.state = profileClosing + profile.cancel() + } + } + profile.mu.Unlock() + r.removeCameraLocked(profile.camera) +} + +func (r *Registry) removeCameraLocked(camera *Camera) { + if camera.refs == 0 && len(camera.profiles) == 0 && camera.mainSessions == 0 && camera.otherSessions == 0 && + r.cameras[camera.key] == camera { + delete(r.cameras, camera.key) + } +} diff --git a/pkg/reolink/registry_test.go b/pkg/reolink/registry_test.go new file mode 100644 index 000000000..c2aa2c87f --- /dev/null +++ b/pkg/reolink/registry_test.go @@ -0,0 +1,418 @@ +package reolink + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/rs/zerolog" +) + +type inputResult struct { + packet baichuan.MediaPacket + err error +} + +type fakeProfileInput struct { + results chan inputResult + done chan struct{} + once sync.Once + talk baichuan.TalkFormat + talkErr error + talks atomic.Uint32 + closed atomic.Bool + aborted atomic.Bool + closeErr error + discovery discoverySnapshot +} + +func newFakeProfileInput() *fakeProfileInput { + i := &fakeProfileInput{ + results: make(chan inputResult, 16), done: make(chan struct{}), + talkErr: &baichuan.UnsupportedTalkError{Reason: "test camera"}, + } + i.results <- inputResult{packet: testH264Keyframe(1_000_000)} + i.results <- inputResult{packet: testAAC(1_000_000)} + return i +} + +func (i *fakeProfileInput) Read(ctx context.Context) (baichuan.MediaPacket, error) { + select { + case result := <-i.results: + return result.packet, result.err + case <-i.done: + return baichuan.MediaPacket{}, context.Canceled + case <-ctx.Done(): + return baichuan.MediaPacket{}, ctx.Err() + } +} + +func (i *fakeProfileInput) ProbeTalk(context.Context, uint8) (baichuan.TalkFormat, error) { + i.talks.Add(1) + return i.talk, i.talkErr +} + +func (i *fakeProfileInput) Discovery() discoverySnapshot { + return i.discovery +} + +func (i *fakeProfileInput) Close() error { + return i.close(false) +} + +func (i *fakeProfileInput) Abort() error { + return i.close(true) +} + +func (i *fakeProfileInput) close(aborted bool) error { + i.once.Do(func() { + i.aborted.Store(aborted) + i.closed.Store(true) + close(i.done) + }) + return i.closeErr +} + +type fakeProfileOpener struct { + mu sync.Mutex + inputs []*fakeProfileInput + talk *baichuan.TalkFormat + discovery discoverySnapshot +} + +func (o *fakeProfileOpener) open(context.Context, *Camera, source) (profileInput, error) { + input := newFakeProfileInput() + if o.talk != nil { + input.talk = *o.talk + input.talkErr = nil + } + input.discovery = o.discovery + o.mu.Lock() + o.inputs = append(o.inputs, input) + o.mu.Unlock() + return input, nil +} + +func (o *fakeProfileOpener) count() int { + o.mu.Lock() + defer o.mu.Unlock() + return len(o.inputs) +} + +func (o *fakeProfileOpener) input(index int) *fakeProfileInput { + o.mu.Lock() + defer o.mu.Unlock() + return o.inputs[index] +} + +func testRegistry(open profileOpener) *Registry { + secret := sha256.Sum256([]byte("test registry secret")) + return newRegistry(zerolog.Nop(), secret, open) +} + +func testH264Keyframe(timestamp uint32) baichuan.MediaPacket { + return baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: timestamp, + Data: []byte{ + 0, 0, 0, 1, 0x67, 0x64, 0, 0x29, + 0, 0, 0, 1, 0x68, 0, + 0, 0, 0, 1, 0x65, 0, + }, + } +} + +func testAAC(timestamp uint32) baichuan.MediaPacket { + data := []byte{0xff, 0xf1, 0x60, 0x40, 0, 0, 0xfc, 1, 2, 3} + aac.WriteADTSSize(data, uint16(len(data))) + return baichuan.MediaPacket{Kind: baichuan.MediaAAC, Timestamp: timestamp, Data: data} +} + +func testH264PFrame(timestamp uint32) baichuan.MediaPacket { + return baichuan.MediaPacket{ + Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: timestamp, + Data: []byte{0, 0, 0, 1, 0x41, 1, 2, 3}, + } +} + +func waitFor(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("condition did not become true") + } + time.Sleep(time.Millisecond) + } +} + +func TestRegistryKeepsConfiguredSourcesIndependent(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + p1, err := registry.Dial("reolink://admin:secret@CAMERA.EXAMPLE./main?backchannel=0") + if err != nil { + t.Fatal(err) + } + p2, err := registry.Dial("reolink://admin:secret@camera.example:9000?stream=main&backchannel=0") + if err != nil { + t.Fatal(err) + } + if opener.count() != 2 || p1.profile == p2.profile || p1.profile.camera != p2.profile.camera { + t.Fatalf("sources not isolated within one camera: opens=%d same_profile=%t same_camera=%t", + opener.count(), p1.profile == p2.profile, p1.profile.camera == p2.profile.camera) + } + if p1.Receivers[0] == p2.Receivers[0] { + t.Fatal("configured sources shared a core receiver") + } + if err = p1.Stop(); err != nil { + t.Fatal(err) + } + if !opener.input(0).closed.Load() { + t.Fatal("producer stop returned before its input closed") + } + if opener.input(1).closed.Load() { + t.Fatal("one source closed another source") + } + if err = p2.Stop(); err != nil { + t.Fatal(err) + } + if !opener.input(1).closed.Load() { + t.Fatal("second producer stop returned before its input closed") + } +} + +func TestRegistryIsolatesCredentials(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + p1, err := registry.Dial("reolink://admin:first@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + p2, err := registry.Dial("reolink://admin:second@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + if opener.count() != 2 || p1.profile == p2.profile { + t.Fatal("different credentials shared camera state") + } + _ = p1.Stop() + _ = p2.Stop() + waitFor(t, func() bool { return opener.input(0).closed.Load() && opener.input(1).closed.Load() }) +} + +func TestRegistryCameraRejectionDoesNotDisturbActiveProfile(t *testing.T) { + opener := &fakeProfileOpener{} + rejected := &baichuan.StatusError{Code: 430} + opens := 0 + registry := testRegistry(func(ctx context.Context, camera *Camera, source source) (profileInput, error) { + opens++ + if opens == 2 { + return nil, rejected + } + return opener.open(ctx, camera, source) + }) + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + _, err = registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + var status *baichuan.StatusError + if !errors.As(err, &status) || status.Code != rejected.Code { + t.Fatalf("unexpected camera rejection: %v", err) + } + if opener.input(0).closed.Load() { + t.Fatal("camera rejection closed the active profile") + } + registry.mu.Lock() + mainSessions, profiles := producer.profile.camera.mainSessions, len(producer.profile.camera.profiles) + registry.mu.Unlock() + if mainSessions != 1 || profiles != 1 { + t.Fatalf("active camera state after rejection: sessions=%d profiles=%d", mainSessions, profiles) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + registry.mu.Lock() + defer registry.mu.Unlock() + return len(registry.cameras) == 0 + }) +} + +func TestRegistryFailureCreatesNewGeneration(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + p1, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + startErr := make(chan error, 1) + go func() { startErr <- p1.Start() }() + opener.input(0).results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-startErr; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + registry.mu.Lock() + mainSessions := p1.profile.camera.mainSessions + registry.mu.Unlock() + if mainSessions != 0 { + t.Fatalf("producer start returned with %d main sessions active", mainSessions) + } + p2, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + if p2.profile.generation != p1.profile.generation+1 || opener.count() != 2 { + t.Fatalf("replacement generation=%d opens=%d", p2.profile.generation, opener.count()) + } + if err = p1.Stop(); err != nil { + t.Fatal(err) + } + if opener.input(1).closed.Load() { + t.Fatal("old generation release closed replacement") + } + if err = p2.Stop(); err != nil { + t.Fatal(err) + } + waitFor(t, opener.input(1).closed.Load) +} + +func TestRegistryConcurrentDial(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + const count = 32 + producers := make(chan *Producer, count) + errs := make(chan error, count) + var wg sync.WaitGroup + for range count { + wg.Add(1) + go func() { + defer wg.Done() + producer, err := registry.Dial("reolink://admin:secret@camera/main?backchannel=0") + if err != nil { + errs <- err + return + } + producers <- producer + }() + } + wg.Wait() + close(producers) + close(errs) + accepted := 0 + var camera *Camera + for producer := range producers { + accepted++ + camera = producer.profile.camera + if err := producer.Stop(); err != nil { + t.Error(err) + } + } + for err := range errs { + t.Error(err) + } + if accepted != count || opener.count() != count { + t.Fatalf("accepted=%d opens=%d", accepted, opener.count()) + } + waitFor(t, func() bool { + for i := range count { + if !opener.input(i).closed.Load() { + return false + } + } + registry.mu.Lock() + defer registry.mu.Unlock() + return camera.mainSessions == 0 && camera.otherSessions == 0 && len(registry.cameras) == 0 + }) +} + +func TestRegistryScopesTalkProbeToConfiguredSource(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + const base = "reolink://admin:secret@camera/main" + withoutTalk, err := registry.Dial(base + "?backchannel=0") + if err != nil { + t.Fatal(err) + } + first, err := registry.Dial(base) + if err != nil { + t.Fatal(err) + } + second, err := registry.Dial(base) + if err != nil { + t.Fatal(err) + } + if opener.input(0).talks.Load() != 0 || opener.input(1).talks.Load() != 1 || + opener.input(2).talks.Load() != 1 || withoutTalk.talk != nil || first.talk != nil || second.talk != nil { + t.Fatalf("unexpected talk probes: disabled=%d first=%d second=%d", + opener.input(0).talks.Load(), opener.input(1).talks.Load(), opener.input(2).talks.Load()) + } + _ = withoutTalk.Stop() + _ = first.Stop() + _ = second.Stop() + waitFor(t, func() bool { + return opener.input(0).closed.Load() && opener.input(1).closed.Load() && opener.input(2).closed.Load() + }) +} + +func TestUIDCameraIdentityIsSecretAndRouteIndependent(t *testing.T) { + secret := [sha256.Size]byte{1} + registry := newRegistry(zerolog.Nop(), secret, nil) + first, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid") + if err != nil { + t.Fatal(err) + } + second, err := parseURL("reolink://admin:pass@ABC1234567890002/main?transport=uid") + if err != nil { + t.Fatal(err) + } + firstKey, secondKey := registry.cameraKey(first), registry.cameraKey(second) + if firstKey == secondKey || firstKey.endpoint != "uid" || secondKey.endpoint != "uid" { + t.Fatal("UID camera identities were not isolated") + } + selected, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid&local=192.0.2.28") + if err != nil { + t.Fatal(err) + } + if firstKey != registry.cameraKey(selected) { + t.Fatal("UID interface selection changed camera identity") + } + routed, err := parseURL("reolink://admin:pass@ABC1234567890001/main?transport=uid&broadcast=198.51.100.255") + if err != nil { + t.Fatal(err) + } + if firstKey != registry.cameraKey(routed) { + t.Fatal("UID broadcast selection changed camera identity") + } +} + +func TestAggregateFormattingRedactsCredentials(t *testing.T) { + source, err := parseURL("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://sentinel-user:sentinel-pass@camera/main?backchannel=0") + if err != nil { + t.Fatal(err) + } + defer producer.Stop() + + for _, value := range []any{source, registry, producer.profile.camera, producer.profile, producer} { + for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q"} { + text := fmt.Sprintf(format, value) + if strings.Contains(text, "sentinel-user") || strings.Contains(text, "sentinel-pass") { + t.Fatalf("credentials exposed by %T with %s: %s", value, format, text) + } + } + } +} diff --git a/pkg/reolink/track_test.go b/pkg/reolink/track_test.go new file mode 100644 index 000000000..b183492a7 --- /dev/null +++ b/pkg/reolink/track_test.go @@ -0,0 +1,189 @@ +package reolink + +import ( + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func TestParseURLAcceptsTrackSuppression(t *testing.T) { + for _, tc := range []struct { + name string + url string + wantVideo bool + wantAudio bool + }{ + {name: "default_both", url: "reolink://u:p@cam/main", wantVideo: true, wantAudio: true}, + {name: "video_zero", url: "reolink://u:p@cam/main?video=0", wantAudio: true}, + {name: "audio_zero", url: "reolink://u:p@cam/main?audio=0", wantVideo: true}, + {name: "video_false", url: "reolink://u:p@cam/main?video=false", wantAudio: true}, + {name: "audio_false", url: "reolink://u:p@cam/main?audio=false", wantVideo: true}, + {name: "video_one", url: "reolink://u:p@cam/main?video=1", wantVideo: true, wantAudio: true}, + {name: "audio_one", url: "reolink://u:p@cam/main?audio=1", wantVideo: true, wantAudio: true}, + {name: "video_true", url: "reolink://u:p@cam/main?video=true", wantVideo: true, wantAudio: true}, + {name: "audio_true", url: "reolink://u:p@cam/main?audio=true", wantVideo: true, wantAudio: true}, + {name: "combined", url: "reolink://u:p@cam/main?video=0&audio=1", wantAudio: true}, + {name: "backchannel_only", url: "reolink://u:p@cam/main?video=0&audio=false"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, err := parseURL(tc.url) + if err != nil { + t.Fatal(err) + } + if !s.suppressVideo != tc.wantVideo || !s.suppressAudio != tc.wantAudio { + t.Fatalf("unexpected tracks: video=%t audio=%t", !s.suppressVideo, !s.suppressAudio) + } + }) + } +} + +func TestParseURLRejectsInvalidTrackSelection(t *testing.T) { + for _, source := range []string{ + "reolink://u:p@cam/main?video=0&audio=0&backchannel=0", + "reolink://u:p@cam/main?video=2", + "reolink://u:p@cam/main?audio=yes", + "reolink://u:p@cam/main?video=0&video=1", + } { + if _, err := parseURL(source); err == nil { + t.Fatalf("accepted invalid source: %s", source) + } + } +} + +func TestAudioOnlyProfileRetainsLatestStartupPacket(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?video=0&backchannel=0") + if err != nil { + t.Fatal(err) + } + if len(producer.Medias) != 1 || producer.Medias[0].Kind != core.KindAudio || + len(producer.Receivers) != 1 || producer.profile.video != nil || producer.profile.audio == nil { + t.Fatal("video-suppressed profile advertised unexpected tracks") + } + input := opener.input(0) + bytes := producer.profile.recvBytes.Load() + const packets = maxProbePackets + 16 + for i := range packets { + packet := testAAC(uint32(i) * 64_000) + packet.Data[len(packet.Data)-1] = byte(i) + input.results <- inputResult{packet: packet} + } + waitFor(t, func() bool { + return producer.profile.recvBytes.Load() == bytes+packets*uint64(len(testAAC(0).Data)) + }) + select { + case <-producer.profile.done: + t.Fatal("audio-only profile failed before start") + default: + } + + audio := make(chan *rtp.Packet, 2) + (&core.Node{Input: func(packet *rtp.Packet) { audio <- packet }}).WithParent(&producer.Receivers[0].Node) + done := make(chan error, 1) + go func() { done <- producer.Start() }() + select { + case packet := <-audio: + want := byte((packets - 1) % 256) + if got := packet.Payload[len(packet.Payload)-1]; got != want { + t.Fatalf("replayed audio payload = %d, want %d", got, want) + } + case <-time.After(time.Second): + t.Fatal("latest startup audio packet was not replayed") + } + select { + case <-audio: + t.Fatal("replayed stale startup audio") + case <-time.After(10 * time.Millisecond): + } + input.results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestProfileDeliversOnlyEnabledTracks(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/main?audio=0&backchannel=0") + if err != nil { + t.Fatal(err) + } + if len(producer.Medias) != 1 || producer.Medias[0].Kind != core.KindVideo || + len(producer.Receivers) != 1 || producer.profile.video == nil || producer.profile.audio != nil { + t.Fatal("audio-suppressed profile advertised unexpected tracks") + } + videoPackets := make(chan *rtp.Packet, 11) + (&core.Node{Input: func(packet *rtp.Packet) { videoPackets <- packet }}).WithParent(&producer.Receivers[0].Node) + done := make(chan error, 1) + go func() { done <- producer.Start() }() + input := opener.input(0) + for i := uint32(0); i < 10; i++ { + input.results <- inputResult{packet: testH264PFrame(1_000_000 + i*50_000)} + input.results <- inputResult{packet: testAAC(1_000_000 + i*50_000)} + } + for range 11 { + select { + case <-videoPackets: + case <-time.After(time.Second): + t.Fatal("video packet not delivered") + } + } + if video, audio := producer.profile.videoFrames.Load(), producer.profile.audioSamples.Load(); video != 11 || audio != 0 { + t.Fatalf("unexpected liveness state: video=%d audio_samples=%d", video, audio) + } + input.results <- inputResult{err: io.ErrUnexpectedEOF} + if err = <-done; !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("unexpected terminal error: %v", err) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } +} + +func TestRegistryBackchannelOnlySource(t *testing.T) { + format := baichuan.TalkFormat{SampleRate: 16000, SamplesPerBlock: 1016} + opener := &fakeProfileOpener{talk: &format} + registry := testRegistry(opener.open) + producer, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=false") + if err != nil { + t.Fatal(err) + } + if len(producer.Receivers) != 0 || len(producer.Medias) != 1 || + producer.Medias[0].Direction != core.DirectionSendonly { + t.Fatalf("unexpected backchannel-only medias=%v receivers=%d", producer.Medias, len(producer.Receivers)) + } + if err = producer.Stop(); err != nil { + t.Fatal(err) + } + if !opener.input(0).closed.Load() { + t.Fatal("backchannel-only stop returned before preview closed") + } +} + +func TestRegistryRejectsUnsupportedBackchannelOnlySource(t *testing.T) { + opener := &fakeProfileOpener{} + registry := testRegistry(opener.open) + _, err := registry.Dial("reolink://admin:secret@camera/sub?video=0&audio=0") + if err == nil || !strings.Contains(err.Error(), "requires camera talkback") { + t.Fatalf("unexpected unsupported backchannel result: %v", err) + } + if opener.count() != 1 || !opener.input(0).closed.Load() { + t.Fatal("unsupported backchannel-only source leaked its preview") + } + registry.mu.Lock() + cameras := len(registry.cameras) + registry.mu.Unlock() + if cameras != 0 { + t.Fatalf("unsupported backchannel-only source retained %d cameras", cameras) + } +} diff --git a/pkg/reolink/video.go b/pkg/reolink/video.go new file mode 100644 index 000000000..719629213 --- /dev/null +++ b/pkg/reolink/video.go @@ -0,0 +1,171 @@ +package reolink + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" + "sort" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" +) + +// encodeOwnedToAVCC reuses access units with nonempty NALs, four-byte start codes, and no AUD. +// Preview owns packet data; every other layout uses the parent converter. +func encodeOwnedToAVCC(data []byte) []byte { + if !canEncodeToAVCCInPlace(data) { + return annexb.EncodeToAVCC(data) + } + header, nalu := 0, 4 + for { + offset := bytes.Index(data[nalu:], []byte{0, 0, 1}) + if offset < 0 { + binary.BigEndian.PutUint32(data[header:], uint32(len(data)-nalu)) + return data + } + next := nalu + offset - 1 + binary.BigEndian.PutUint32(data[header:], uint32(next-nalu)) + header, nalu = next, next+4 + } +} + +func canEncodeToAVCCInPlace(data []byte) bool { + if len(data) < 5 || binary.BigEndian.Uint32(data) != 1 { + return false + } + for nalu := 4; ; { + if nalu >= len(data) || data[nalu]&0x1f == 9 || data[nalu]&0x7e == 35<<1 { + return false + } + offset := bytes.Index(data[nalu:], []byte{0, 0, 1}) + if offset < 0 { + return true + } + marker := nalu + offset + if marker <= nalu+1 || data[marker-1] != 0 { + return false + } + nalu = marker + 3 + } +} + +func validVideoConfig(codec string, data []byte) bool { + valid, err := inspectVideoPayload(codec, data) + return err == nil && valid +} + +func inspectVideoPayload(codec string, data []byte) (bool, error) { + minNALU := 1 + if codec == "H265" { + minNALU = 2 + } else if codec != "H264" { + return false, fmt.Errorf("unsupported codec %q", codec) + } + if len(data) < 4+minNALU { + return false, fmt.Errorf("invalid AVCC framing") + } + var first, second, third, keyframe bool + index := 0 + for len(data) != 0 { + if len(data) < 4 { + return false, fmt.Errorf("invalid AVCC framing") + } + size := binary.BigEndian.Uint32(data) + if size < uint32(minNALU) || uint64(size) > uint64(len(data)-4) { + return false, fmt.Errorf("invalid AVCC framing") + } + nalu := data[4 : 4+int(size)] + switch codec { + case "H264": + typeID := nalu[0] & 0x1f + if nalu[0]&0x80 != 0 { + return false, fmt.Errorf("NAL %d has forbidden bit", index) + } + if typeID == 0 || typeID >= 24 { + return false, fmt.Errorf("NAL %d has type %d", index, typeID) + } + switch typeID { + case h264.NALUTypeSPS: + if len(nalu) < 4 { + return false, nil + } + first = true + case h264.NALUTypePPS: + second = true + case h264.NALUTypeIFrame: + keyframe = true + } + case "H265": + typeID := (nalu[0] >> 1) & 0x3f + if nalu[0]&0x80 != 0 { + return false, fmt.Errorf("NAL %d has forbidden bit", index) + } + if typeID >= 48 { + return false, fmt.Errorf("NAL %d has type %d", index, typeID) + } + if nalu[1]&7 == 0 { + return false, fmt.Errorf("NAL %d has temporal ID zero", index) + } + switch typeID { + case h265.NALUTypeVPS: + first = true + case h265.NALUTypeSPS: + second = true + case h265.NALUTypePPS: + third = true + case h265.NALUTypeIFrame, h265.NALUTypeIFrame2, h265.NALUTypeIFrame3: + keyframe = true + } + } + data = data[4+int(size):] + index++ + } + if codec == "H264" { + return first && second && keyframe, nil + } + return first && second && third && keyframe, nil +} + +func h264ParameterSignature(data []byte) (string, error) { + const maxSets = 64 + sets := make([]string, 0, 2) + count := 0 + for len(data) >= 4 { + size := int(binary.BigEndian.Uint32(data)) + if size <= 0 || size > len(data)-4 { + return "", mediaErrorf("reolink: invalid H264 parameter sets") + } + nalu := data[4 : 4+size] + typeID := nalu[0] & 0x1f + if typeID == h264.NALUTypeSPS || typeID == h264.NALUTypePPS { + if count == maxSets { + return "", mediaErrorf("reolink: H264 parameter sets exceed %d", maxSets) + } + count++ + digest := sha256.Sum256(nalu) + var raw [sha256.Size + 1]byte + raw[0] = typeID + copy(raw[1:], digest[:]) + key := string(raw[:]) + duplicate := false + for _, existing := range sets { + if existing == key { + duplicate = true + break + } + } + if !duplicate { + sets = append(sets, key) + } + } + data = data[4+size:] + } + if len(data) != 0 || len(sets) < 2 { + return "", mediaErrorf("reolink: invalid H264 parameter sets") + } + sort.Strings(sets) + return strings.Join(sets, ""), nil +} diff --git a/pkg/reolink/video_test.go b/pkg/reolink/video_test.go new file mode 100644 index 000000000..4232df9ba --- /dev/null +++ b/pkg/reolink/video_test.go @@ -0,0 +1,335 @@ +package reolink + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/baichuan" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" +) + +func TestWritePacketRejectsVideoCodecChange(t *testing.T) { + pipeline := &mediaPipeline{videoCodec: "H265"} + if _, err := pipeline.frame(baichuan.MediaPacket{Kind: baichuan.MediaVideoP, Codec: "H264"}); err == nil { + t.Fatal("accepted video codec change") + } +} + +func TestWritePacketAcceptsDuplicateH264ParameterSets(t *testing.T) { + data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + pipeline := &mediaPipeline{} + if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: data}); err != nil { + t.Fatal(err) + } + config := pipeline.videoConfig + changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 2) + if frame, err := pipeline.frame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Data: changed, + }); err != nil || !frame.valid() { + t.Fatalf("duplicate parameter sets failed: frame=%v err=%v", frame, err) + } + if pipeline.videoConfig != config { + t.Fatalf("parameter variant changed configuration: %q", pipeline.videoConfig) + } + if _, err := pipeline.frame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", + Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 2), + }); err != nil { + t.Fatalf("repeated parameter variant failed: %v", err) + } +} + +func TestWritePacketAcceptsReorderedH264ParameterSets(t *testing.T) { + sps, err := base64.StdEncoding.DecodeString("Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=") + if err != nil { + t.Fatal(err) + } + pps := []byte{0x68, 0xee, 0x3c, 0x80} + idr := []byte{0x65, 0} + pipeline := &mediaPipeline{} + if err = pipeline.configureVideo(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Data: h264AccessUnit(sps, pps, idr), + }); err != nil { + t.Fatal(err) + } + frame, err := pipeline.frame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Data: h264AccessUnit(pps, sps, idr), + }) + if err != nil || !frame.valid() { + t.Fatalf("reordered parameter sets failed: frame=%v err=%v", frame, err) + } +} + +func TestWritePacketRejectsH264ParameterSetChange(t *testing.T) { + for _, name := range []string{"sps", "pps"} { + t.Run(name, func(t *testing.T) { + pipeline := &mediaPipeline{} + data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: data}); err != nil { + t.Fatal(err) + } + changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + if name == "sps" { + changed[10] ^= 1 + } else { + changed[len(changed)-7] ^= 1 + } + packet := baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Data: changed, + } + if frame, err := pipeline.frame(packet); err != nil || frame.valid() { + t.Fatalf("first parameter-set candidate was not suppressed: frame=%v err=%v", frame, err) + } + packet.Data = h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + if name == "sps" { + packet.Data[10] ^= 1 + } else { + packet.Data[len(packet.Data)-7] ^= 1 + } + if _, err := pipeline.frame(packet); err == nil || !strings.Contains(err.Error(), "parameter sets changed") { + t.Fatalf("unexpected parameter-set error: %v", err) + } + }) + } +} + +func TestWritePacketRejectsH265ParameterSetChange(t *testing.T) { + pipeline := &mediaPipeline{} + if err := pipeline.configureVideo(testVideoKeyframe("H265", 0)); err != nil { + t.Fatal(err) + } + changed := testVideoKeyframe("H265", 1_000_000) + changed.Data[5] ^= 8 // change nuh_layer_id without invalidating the NAL header + if frame, err := pipeline.videoFrame(changed); err != nil || frame.valid() { + t.Fatalf("first parameter-set candidate was not suppressed: frame=%v err=%v", frame, err) + } + changed = testVideoKeyframe("H265", 1_050_000) + changed.Data[5] ^= 8 + if _, err := pipeline.videoFrame(changed); err == nil || !strings.Contains(err.Error(), "parameter sets changed") { + t.Fatalf("unexpected parameter-set error: %v", err) + } +} + +func TestWritePacketRecoversFromSingleH264ParameterCandidate(t *testing.T) { + pipeline := &mediaPipeline{} + base := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: base}); err != nil { + t.Fatal(err) + } + changed := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + changed[10] ^= 1 + if frame, err := pipeline.frame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_000_000, Data: changed, + }); err != nil || frame.valid() { + t.Fatalf("parameter candidate was not suppressed: frame=%v err=%v", frame, err) + } + if frame, err := pipeline.frame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_050_000, + Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1), + }); err != nil || !frame.valid() { + t.Fatalf("original parameters did not recover: frame=%v err=%v", frame, err) + } +} + +func TestWritePacketResyncsAfterExcessH264ParameterSets(t *testing.T) { + pipeline := &mediaPipeline{} + base := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 1) + if err := pipeline.configureVideo(baichuan.MediaPacket{Kind: baichuan.MediaVideoI, Codec: "H264", Data: base}); err != nil { + t.Fatal(err) + } + if frame, err := pipeline.videoFrame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_000_000, + Data: h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 64), + }); err != nil || frame.valid() { + t.Fatalf("excess parameter sets were not suppressed: frame=%v err=%v", frame, err) + } + if frame, err := pipeline.videoFrame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Timestamp: 1_050_000, Data: base, + }); err != nil || !frame.valid() { + t.Fatalf("valid keyframe did not resync: frame=%v err=%v", frame, err) + } +} + +func TestWritePacketInvalidVideoDoesNotPoisonClock(t *testing.T) { + pipeline := &mediaPipeline{} + base := testH264Keyframe(1_000_000) + if err := pipeline.configureVideo(base); err != nil { + t.Fatal(err) + } + if frame, err := pipeline.videoFrame(base); err != nil || !frame.valid() || frame.packet.Timestamp != 0 { + t.Fatalf("unexpected initial frame: frame=%v err=%v", frame, err) + } + if frame, err := pipeline.videoFrame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoP, Codec: "H264", Timestamp: 21_000_000, + Data: []byte{0, 0, 0, 1, 0xc1, 1}, + }); err != nil || frame.valid() { + t.Fatalf("invalid frame was not suppressed: frame=%v err=%v", frame, err) + } + recovery := testH264Keyframe(1_100_000) + frame, err := pipeline.videoFrame(recovery) + if err != nil || !frame.valid() { + t.Fatalf("invalid timestamp poisoned recovery: frame=%v err=%v", frame, err) + } + if frame.packet.Timestamp != 9000 { + t.Fatalf("recovery timestamp = %d, want 9000", frame.packet.Timestamp) + } +} + +func TestConfigureVideoBoundsH264ParameterSets(t *testing.T) { + data := h264Keyframe(t, "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=", 0x80, 64) + err := (&mediaPipeline{}).configureVideo(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H264", Data: data, + }) + if err == nil || !strings.Contains(err.Error(), "parameter sets exceed 64") { + t.Fatalf("unexpected parameter-set limit error: %v", err) + } +} + +func h264Keyframe(t *testing.T, encodedSPS string, ppsTail byte, spsCopies int) []byte { + t.Helper() + sps, err := base64.StdEncoding.DecodeString(encodedSPS) + if err != nil { + t.Fatal(err) + } + nalus := make([][]byte, 0, spsCopies+2) + for range spsCopies { + nalus = append(nalus, sps) + } + nalus = append(nalus, []byte{0x68, 0xee, 0x3c, ppsTail}, []byte{0x65, 0}) + return h264AccessUnit(nalus...) +} + +func h264AccessUnit(nalus ...[]byte) []byte { + var data []byte + for _, nalu := range nalus { + data = append(data, 0, 0, 0, 1) + data = append(data, nalu...) + } + return data +} + +func TestEncodeOwnedToAVCC(t *testing.T) { + for _, input := range [][]byte{ + {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 0, 1, 0x41, 3}, + {0, 0, 1, 0x41, 1, 2, 0, 0, 1, 0x41, 3}, + {0, 0, 0, 1, 0x09, 0xf0, 0, 0, 0, 1, 0x41, 3}, + {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 1, 0x41, 3}, + {0, 0, 0, 1, 0, 0, 0, 1, 0x41, 3}, + } { + original := append([]byte(nil), input...) + want := annexb.EncodeToAVCC(original) + if got := encodeOwnedToAVCC(input); !bytes.Equal(got, want) { + t.Fatalf("unexpected conversion for %x: %x != %x", original, got, want) + } + } + input := []byte{0, 0, 0, 1, 0x41, 1, 2} + if got := encodeOwnedToAVCC(input); &got[0] != &input[0] { + t.Fatal("four-byte access unit was copied") + } +} + +func FuzzEncodeOwnedToAVCC(f *testing.F) { + for _, input := range [][]byte{ + {0, 0, 0, 1, 0x41, 1, 2, 0, 0, 0, 1, 0x41, 3}, + {0, 0, 1, 0x41, 1, 2}, + {0, 0, 0, 1, 0x09, 0xf0, 0, 0, 0, 1, 0x41, 3}, + {0, 0, 0, 1, 0, 0, 0, 1, 0x41, 3}, + } { + f.Add(input) + } + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 1<<20 { + t.Skip() + } + original := append([]byte(nil), input...) + want := annexb.EncodeToAVCC(original) + if got := encodeOwnedToAVCC(input); !bytes.Equal(got, want) { + t.Fatalf("unexpected conversion for %x: %x != %x", original, got, want) + } + }) +} + +func TestAddVideoRejectsMalformedKeyframe(t *testing.T) { + for _, test := range []struct { + codec string + data []byte + }{ + {codec: "H264"}, + {codec: "H264", data: []byte{0, 0, 0, 1, 0x67, 0x64}}, + {codec: "H264", data: []byte{ + 0, 0, 0, 1, 0x67, 0x64, + 0, 0, 0, 1, 0x67, 0x64, 0, 0x29, + 0, 0, 0, 1, 0x68, 0, + 0, 0, 0, 1, 0x65, 0, + }}, + {codec: "H264", data: []byte{0, 0, 0, 1, 0x65, 0}}, + {codec: "H265", data: []byte{0, 0, 0, 1, 0x40}}, + } { + pipeline := &mediaPipeline{} + if err := pipeline.configureVideo(baichuan.MediaPacket{Codec: test.codec, Data: test.data}); err == nil { + t.Fatalf("accepted malformed %s keyframe: %x", test.codec, test.data) + } + } +} + +func TestWritePacketResyncsAfterInvalidNALHeaders(t *testing.T) { + for _, test := range []struct { + codec string + nalu []byte + }{ + {codec: "H264", nalu: []byte{0, 1}}, + {codec: "H264", nalu: []byte{0x78, 1}}, + {codec: "H264", nalu: []byte{0xc1, 1}}, + {codec: "H265", nalu: []byte{48 << 1, 1}}, + {codec: "H265", nalu: []byte{0x80, 1}}, + {codec: "H265", nalu: []byte{1 << 1, 0}}, + } { + t.Run(fmt.Sprintf("%s_%x", test.codec, test.nalu), func(t *testing.T) { + pipeline := &mediaPipeline{} + keyframe := testVideoKeyframe(test.codec, 1_000_000) + if err := pipeline.configureVideo(keyframe); err != nil { + t.Fatal(err) + } + data := append([]byte{0, 0, 0, 1}, test.nalu...) + if frame, err := pipeline.videoFrame(baichuan.MediaPacket{ + Kind: baichuan.MediaVideoP, Codec: test.codec, Timestamp: 1_050_000, Data: data, + }); err != nil || frame.valid() { + t.Fatalf("invalid NAL was not suppressed: frame=%v err=%v", frame, err) + } + if frame, err := pipeline.videoFrame(testVideoPFrame(test.codec, 1_100_000)); err != nil || frame.valid() { + t.Fatalf("intervening frame was not suppressed: frame=%v err=%v", frame, err) + } + if frame, err := pipeline.videoFrame(testVideoKeyframe(test.codec, 1_150_000)); err != nil || !frame.valid() { + t.Fatalf("valid keyframe did not resync: frame=%v err=%v", frame, err) + } + }) + } +} + +func testVideoKeyframe(codec string, timestamp uint32) baichuan.MediaPacket { + if codec == "H264" { + return testH264Keyframe(timestamp) + } + return baichuan.MediaPacket{ + Kind: baichuan.MediaVideoI, Codec: "H265", Timestamp: timestamp, + Data: []byte{ + 0, 0, 0, 1, 0x40, 1, + 0, 0, 0, 1, 0x42, 1, + 0, 0, 0, 1, 0x44, 1, + 0, 0, 0, 1, 0x26, 1, + }, + } +} + +func testVideoPFrame(codec string, timestamp uint32) baichuan.MediaPacket { + if codec == "H264" { + return testH264PFrame(timestamp) + } + return baichuan.MediaPacket{ + Kind: baichuan.MediaVideoP, Codec: "H265", Timestamp: timestamp, + Data: []byte{0, 0, 0, 1, 2, 1, 0}, + } +} diff --git a/website/.vitepress/config.js b/website/.vitepress/config.js index 792f2e75e..357e3d774 100644 --- a/website/.vitepress/config.js +++ b/website/.vitepress/config.js @@ -26,7 +26,7 @@ export default defineConfig({ // second line of Telegram card (black bold), autodetect from site description ['meta', { property: 'og:title', content: 'go2rtc - Ultimate camera streaming application' }], // third line of Telegram card, autodetect from site description - ['meta', { property: 'og:description', content: 'Support alsa, doorbird, dvrip, eseecloud, ffmpeg, gopro, hass, hls, homekit, mjpeg, mp4, mpegts, nest, onvif, ring, roborock, rtmp, rtsp, tapo, vigi, tuya, v4l2, webrtc, wyze, xiaomi.' }], + ['meta', { property: 'og:description', content: 'Support alsa, doorbird, dvrip, eseecloud, ffmpeg, gopro, hass, hls, homekit, mjpeg, mp4, mpegts, nest, onvif, reolink, ring, roborock, rtmp, rtsp, tapo, vigi, tuya, v4l2, webrtc, wyze, xiaomi.' }], ['meta', { property: 'og:url', content: 'https://go2rtc.org/' }], ['meta', { property: 'og:image', content: 'https://go2rtc.org/images/logo.png' }], // important for Telegram - the image will be at the bottom and large @@ -138,6 +138,7 @@ export default defineConfig({ {text: 'mpeg', link: '/internal/mpeg/'}, {text: 'multitrans', link: '/internal/multitrans/'}, {text: 'nest', link: '/internal/nest/'}, + {text: 'reolink', link: '/internal/reolink/'}, {text: 'ring', link: '/internal/ring/'}, {text: 'roborock', link: '/internal/roborock/'}, {text: 'tapo', link: '/internal/tapo/'}, diff --git a/www/schema.json b/www/schema.json index 27fee57d7..17dde385c 100644 --- a/www/schema.json +++ b/www/schema.json @@ -174,6 +174,7 @@ "kasa", "mpeg", "nest", + "reolink", "ring", "roborock", "tapo",