diff --git a/.github/build-config.json b/.github/build-config.json index 967855e666f2..24f351a9a3b9 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -38,10 +38,12 @@ "app", "coreelements", "isomp4", + "jpegformat", "libav", "matroska", "mpegtsdemux", "multifile", + "multipart", "opengl", "openh264", "playback", @@ -49,6 +51,7 @@ "rtpmanager", "rtsp", "sdpelem", + "soup", "tcp", "typefindfunctions", "udp", @@ -58,10 +61,27 @@ "videoconvert", "videoscale" ], - "android": ["androidmedia", "dav1d"], - "apple": ["applemedia", "dav1d"], - "windows": ["d3d", "d3d11", "d3d12", "dav1d", "nvcodec"], - "linux": ["nvcodec", "qsv", "va", "vulkan"] + "android": [ + "androidmedia", + "dav1d" + ], + "apple": [ + "applemedia", + "dav1d" + ], + "windows": [ + "d3d", + "d3d11", + "d3d12", + "dav1d", + "nvcodec" + ], + "linux": [ + "nvcodec", + "qsv", + "va", + "vulkan" + ] }, "checksums": { "1.28.4": { diff --git a/cmake/GStreamer/Components.cmake b/cmake/GStreamer/Components.cmake index 5cd366f2f90e..2d8ba3f3215f 100644 --- a/cmake/GStreamer/Components.cmake +++ b/cmake/GStreamer/Components.cmake @@ -182,12 +182,14 @@ function(gstreamer_build_apis_and_deps APIS_OUT DEPS_OUT) endforeach() # Platform extra deps — kept here as the single registration point. + # GstSourceFactory uses GTlsFileDatabase for custom-CA HTTPS sources on + # desktop. Mobile SDKs provide GIO through their bundled GStreamer target. + if(NOT ANDROID AND NOT IOS) + list(APPEND _deps gio-2.0) + endif() if(WIN32) list(APPEND _deps graphene-1.0) endif() - if(ANDROID OR IOS) - list(APPEND _deps gio-2.0) - endif() if(ANDROID) list(APPEND _deps gmodule-2.0 zlib) endif() diff --git a/cmake/GStreamer/PluginPolicy.cmake b/cmake/GStreamer/PluginPolicy.cmake index 7c4d00876c6b..993fef342543 100644 --- a/cmake/GStreamer/PluginPolicy.cmake +++ b/cmake/GStreamer/PluginPolicy.cmake @@ -24,7 +24,8 @@ set(GSTREAMER_PLUGIN_ALTERNATES # multifile (splitmuxsink), isomp4 (qtmux/mp4mux), and matroska (matroskamux) # are load-bearing for video recording (GstVideoReceiver _kFileMux). set(GSTREAMER_RUNTIME_REQUIRED_PLUGINS - coreelements isomp4 matroska multifile opengl playback rtsp rtp rtpmanager tcp udp videoconvertscale + app coreelements isomp4 jpegformat matroska multifile multipart opengl playback rtsp rtp + rtpmanager soup tcp udp videoconvertscale ) # iOS xcframework: plugins whose dependent static libs aren't bundled in the diff --git a/cmake/GStreamer/tests/test_components.cmake b/cmake/GStreamer/tests/test_components.cmake index db042e7ba52f..15f6e6be09cf 100644 --- a/cmake/GStreamer/tests/test_components.cmake +++ b/cmake/GStreamer/tests/test_components.cmake @@ -70,6 +70,7 @@ qgc_test_assert_in_list("seed: api_gl_prototypes" api_gl_prototypes _apis) qgc_test_assert_in_list("seed: api_rtsp" api_rtsp _apis) qgc_test_assert_in_list("seed: api_video" api_video _apis) qgc_test_assert_in_list("seed deps: gstreamer-base-1.0" gstreamer-base-1.0 _deps) +qgc_test_assert_in_list("desktop TLS dependency: gio-2.0" gio-2.0 _deps) qgc_test_pass("build_apis_and_deps Core seed") gstreamer_build_apis_and_deps(_apis2 _deps2 Core App) @@ -94,7 +95,7 @@ qgc_test_pass("WIN32 platform extras") set(ANDROID ON) gstreamer_build_apis_and_deps(_apis_a _deps_a Core) -qgc_test_assert_in_list("ANDROID -> gio-2.0" gio-2.0 _deps_a) +qgc_test_assert_not_in_list("ANDROID uses bundled gio-2.0" gio-2.0 _deps_a) qgc_test_assert_in_list("ANDROID -> gmodule-2.0" gmodule-2.0 _deps_a) qgc_test_assert_in_list("ANDROID -> zlib" zlib _deps_a) set(ANDROID OFF) diff --git a/cmake/GStreamer/tests/test_plugin_policy.cmake b/cmake/GStreamer/tests/test_plugin_policy.cmake index 66bd4541d7ef..7a83543b17f9 100644 --- a/cmake/GStreamer/tests/test_plugin_policy.cmake +++ b/cmake/GStreamer/tests/test_plugin_policy.cmake @@ -32,6 +32,7 @@ qgc_test_assert_in_list("apple has vtdec" vtdec _plugins_apple) qgc_test_pass("plugins_for apple addenda") file(READ "${CMAKE_CURRENT_LIST_DIR}/../../../.github/build-config.json" QGC_BUILD_CONFIG_CONTENT) +gstreamer_plugins_for(PLATFORM "" OUT_VAR _plugins_common_real) gstreamer_plugins_for(PLATFORM windows OUT_VAR _plugins_windows_real) qgc_test_assert_in_list("windows has d3d11" d3d11 _plugins_windows_real) qgc_test_assert_in_list("windows has d3d12" d3d12 _plugins_windows_real) @@ -72,8 +73,11 @@ qgc_test_assert_in_list("partial: x264enc retained" x264enc qgc_test_pass("filter_alternates partial-pair unsatisfied") gstreamer_runtime_required_plugins(_required) -foreach(_p IN ITEMS coreelements isomp4 matroska multifile opengl playback rtsp rtp rtpmanager tcp udp videoconvertscale) +foreach(_p IN ITEMS + app coreelements isomp4 jpegformat matroska multifile multipart opengl playback + rtsp rtp rtpmanager soup tcp udp videoconvertscale) qgc_test_assert_in_list("runtime required: ${_p}" "${_p}" _required) + qgc_test_assert_in_list("common packaged runtime requirement: ${_p}" "${_p}" _plugins_common_real) endforeach() qgc_test_assert_not_in_list("runtime required: openh264 is optional codec implementation" openh264 _required) qgc_test_pass("runtime_required_plugins") diff --git a/cmake/find-modules/FindGStreamer.cmake b/cmake/find-modules/FindGStreamer.cmake index 37ab9e624a8f..04e80a2aba12 100644 --- a/cmake/find-modules/FindGStreamer.cmake +++ b/cmake/find-modules/FindGStreamer.cmake @@ -17,6 +17,8 @@ # components without editing this file. # 4. Hash parsing moved out — qgc_parse_expected_hash lives in # cmake/modules/Download.cmake; this module no longer parses hashes. +# 5. Desktop GIO is resolved through the same repaired pkg-config path as +# GStreamer components for custom-CA HTTPS source support. # When syncing from upstream, re-apply each listed patch and update this # block. Do NOT remove this block during sync. @@ -57,6 +59,9 @@ Configuration Variables # new GStreamer::NewOne target instead of silently inheriting the cached _FOUND. if (GStreamer_FOUND) set(_gst_all_present TRUE) + if("gio-2.0" IN_LIST GSTREAMER_EXTRA_DEPS AND NOT TARGET GStreamer::gio) + set(_gst_all_present FALSE) + endif() foreach(_gst_c IN LISTS GStreamer_FIND_COMPONENTS) if (NOT TARGET GStreamer::${_gst_c} AND NOT _gst_c IN_LIST GStreamer_ABSENT_COMPONENTS) set(_gst_all_present FALSE) @@ -294,6 +299,16 @@ foreach(_gst_PLUGIN IN LISTS GSTREAMER_APIS) _gst_create_component_target(${_gst_PLUGIN} "gstreamer-${_gst_PLUGIN_PC}-1.0") endforeach() +# GIO is not a GStreamer API component, but GstSourceFactory calls it directly +# for a custom TLS trust database. Reuse the component target path so Windows +# pkg-config paths with spaces receive QGC's normal recovery and static/shared +# linkage remains consistent with the selected GStreamer SDK. +if("gio-2.0" IN_LIST GSTREAMER_EXTRA_DEPS) + set(GStreamer_FIND_REQUIRED_gio TRUE) + _gst_create_component_target(gio "gio-2.0") + unset(GStreamer_FIND_REQUIRED_gio) +endif() + # Link API component targets into the umbrella so consumers get the full set # of includes and libraries (rtsp, video, gl, etc.) transitively. if(TARGET GStreamer::GStreamer) diff --git a/docs/en/qgc-user-guide/settings_view/video.md b/docs/en/qgc-user-guide/settings_view/video.md index 6287717c14e0..1a706b603b0a 100644 --- a/docs/en/qgc-user-guide/settings_view/video.md +++ b/docs/en/qgc-user-guide/settings_view/video.md @@ -4,7 +4,7 @@ Configure video streaming and recording settings. ## Video Source -- **Source** — Video Stream Disabled / RTSP Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera +- **Source** — Video Stream Disabled / RTSP Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / HTTP MJPEG / WebSocket JPEG / Integrated Camera ## Connection @@ -13,6 +13,47 @@ Connection settings vary by source type: - **RTSP URL** — full RTSP stream address - **TCP URL** — TCP stream address - **UDP URL** — UDP stream address and port (default: `0.0.0.0:5600`) +- **HTTP MJPEG URL** — full `http://` or `https://` URL for a multipart MJPEG stream +- **WebSocket JPEG URL** — full `ws://` or `wss://` URL for a source that sends each complete JPEG frame as one binary WebSocket message + +## Network Video Security + +HTTP MJPEG and WebSocket JPEG sources can be anonymous for local labs and trusted test networks. If you select Basic or Bearer authentication, use `https://` or `wss://`; QGC rejects credentials on plaintext HTTP or WS. + +- **Authentication** — None, Basic, or Bearer token +- **Username** — Basic authentication username +- **Session credential** — password or token retained only in memory until QGC exits or you clear it +- **Credential file** — optional owner-only file containing one password or token for unattended Unix-like desktop use; other platforms use the in-memory session credential +- **Origin** — optional HTTP/WebSocket Origin header for servers that require one +- **CA certificate file** — optional PEM trust file for HTTPS or WSS validation. + For HTTP MJPEG, this file replaces the system trust store for that source, so + include every issuing CA needed by the endpoint. + +Do not put passwords, bearer tokens, or API keys in the video URL. QGC rejects URL user-info and common token query parameters and removes user-info, query, and fragment data from video URL logs. +HTTP MJPEG redirects are disabled whenever authentication, Origin, or a custom CA is configured so credentials and trust policy cannot be silently forwarded to another endpoint. WebSocket handshake redirects are not followed. + +HTTP MJPEG and WebSocket JPEG streams can be recorded as MKV or MOV. MP4 does +not accept the parsed JPEG stream, so QGC rejects that combination before +recording instead of creating an unusable file. + +For resource safety, each JPEG message is limited to 16 MiB, each dimension to +8192 pixels, and the decoded image to 7680 x 4320 pixels. Malformed or oversized +frames are rejected before decoding. + +## Test Sources + +Synthetic HTTP MJPEG and WebSocket JPEG servers are available in `test/VideoStreaming/`. +They generate moving JPEG frames and do not require a camera. + +```bash +cd test/VideoStreaming +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +python http_mjpeg_server.py --host 127.0.0.1 --port 5077 +``` + +Use `http://127.0.0.1:5077/video_feed` with the HTTP MJPEG source. For WebSocket JPEG, run `python websocket_jpeg_server.py --host 127.0.0.1 --port 5078` and use `ws://127.0.0.1:5078/ws/video_feed`. ## Settings diff --git a/src/AppSettings/CMakeLists.txt b/src/AppSettings/CMakeLists.txt index 4ea264462fbe..9a13d65bbc68 100644 --- a/src/AppSettings/CMakeLists.txt +++ b/src/AppSettings/CMakeLists.txt @@ -91,6 +91,7 @@ qt_add_qml_module(AppSettingsModule SettingsPage.qml TcpSettings.qml UdpSettings.qml + VideoNetworkSecuritySettings.qml NO_PLUGIN ) diff --git a/src/AppSettings/VideoNetworkSecuritySettings.qml b/src/AppSettings/VideoNetworkSecuritySettings.qml new file mode 100644 index 000000000000..8ff57e381b54 --- /dev/null +++ b/src/AppSettings/VideoNetworkSecuritySettings.qml @@ -0,0 +1,121 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import QGroundControl +import QGroundControl.Controls +import QGroundControl.FactControls + +SettingsGroupLayout { + id: root + + Layout.fillWidth: true + heading: qsTr("Network Video Security") + + property var _settings: QGroundControl.settingsManager.videoSettings + property bool _usesAuthentication: _settings.networkVideoAuthType.rawValue !== VideoSettings.NetworkVideoAuthNone + property bool _usesBasicAuthentication: ( + _settings.networkVideoAuthType.rawValue === VideoSettings.NetworkVideoAuthBasic + ) + property string _credentialMessage: "" + property string _configurationError: _settings.networkVideoConfigurationError + + QGCPalette { + id: qgcPal + colorGroupEnabled: root.enabled + } + + LabelledFactComboBox { + Layout.fillWidth: true + fact: root._settings.networkVideoAuthType + indexModel: false + } + + LabelledFactTextField { + Layout.fillWidth: true + textFieldPreferredWidth: ScreenTools.defaultFontPixelWidth * 40 + fact: root._settings.networkVideoUsername + visible: root._usesBasicAuthentication + } + + ColumnLayout { + Layout.fillWidth: true + visible: root._usesAuthentication + spacing: ScreenTools.defaultFontPixelHeight / 2 + + QGCLabel { + Layout.fillWidth: true + text: root._usesBasicAuthentication ? qsTr("Session password") : qsTr("Session bearer token") + } + + QGCTextField { + id: sessionSecret + Layout.fillWidth: true + echoMode: TextInput.Password + maximumLength: 4096 + placeholderText: root._settings.networkVideoSessionSecretConfigured + ? qsTr("Credential configured") + : qsTr("Enter credential") + } + + RowLayout { + Layout.fillWidth: true + spacing: ScreenTools.defaultFontPixelWidth + + QGCButton { + text: qsTr("Use") + enabled: sessionSecret.text.length > 0 + onClicked: { + root._credentialMessage = root._settings.setNetworkVideoSecret(sessionSecret.text) + if (root._credentialMessage.length === 0) { + sessionSecret.clear() + } + } + } + + QGCButton { + text: qsTr("Clear") + enabled: root._settings.networkVideoSessionSecretConfigured + onClicked: { + root._settings.clearNetworkVideoSecret() + sessionSecret.clear() + root._credentialMessage = "" + } + } + + Item { + Layout.fillWidth: true + } + } + } + + LabelledFactBrowse { + Layout.fillWidth: true + fact: root._settings.networkVideoSecretFile + selectFolder: false + showClearButton: true + visible: root._usesAuthentication && root._settings.networkVideoCredentialFileSupported + } + + LabelledFactTextField { + Layout.fillWidth: true + textFieldPreferredWidth: ScreenTools.defaultFontPixelWidth * 40 + fact: root._settings.networkVideoOrigin + } + + LabelledFactBrowse { + Layout.fillWidth: true + fact: root._settings.networkVideoCaCertificateFile + selectFolder: false + showClearButton: true + visible: !ScreenTools.isMobile + } + + QGCLabel { + Layout.fillWidth: true + color: qgcPal.warningText + text: root._credentialMessage.length > 0 ? root._credentialMessage : root._configurationError + visible: text.length > 0 + wrapMode: Text.WordWrap + } +} diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index f2f5837398e3..5ef8c9ab9a8e 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -6,7 +6,10 @@ "autoStreamConfig": "QGroundControl.videoManager.autoStreamConfigured", "sourceDisabled": "videoSource === QGroundControl.settingsManager.videoSettings.disabledVideoSource", "isStreamSource": "QGroundControl.videoManager.isStreamSource", - "rtpLatencyVisible": "!QGroundControl.settingsManager.videoSettings.lowLatencyMode.rawValue" + "rtpLatencyVisible": "!QGroundControl.settingsManager.videoSettings.lowLatencyMode.rawValue", + "isHttpMjpeg": "videoSource === QGroundControl.settingsManager.videoSettings.httpMjpegVideoSource", + "isWebsocketJpeg": "videoSource === QGroundControl.settingsManager.videoSettings.websocketJpegVideoSource", + "isNetworkJpeg": "isHttpMjpeg || isWebsocketJpeg" }, "groups": [ { @@ -37,9 +40,23 @@ { "setting": "videoSettings.udpUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.udp264VideoSource || videoSource === QGroundControl.settingsManager.videoSettings.udp265VideoSource || videoSource === QGroundControl.settingsManager.videoSettings.mpegtsVideoSource" + }, + { + "setting": "videoSettings.httpMjpegUrl", + "showWhen": "isHttpMjpeg" + }, + { + "setting": "videoSettings.websocketJpegUrl", + "showWhen": "isWebsocketJpeg" } ] }, + { + "component": "VideoNetworkSecuritySettings", + "sectionName": "Network Video Security", + "keywords": ["authentication", "basic", "bearer", "token", "password", "origin", "tls", "certificate"], + "showWhen": "isNetworkJpeg && !autoStreamConfig" + }, { "heading": "Settings", "keywords": ["aspect ratio", "low latency", "decoder", "hardware decode", "disable when disarmed", "gpu", "zero-copy"], diff --git a/src/FactSystem/FactControls/LabelledFactBrowse.qml b/src/FactSystem/FactControls/LabelledFactBrowse.qml index 18d6f1ec52d9..2c7f816b346e 100644 --- a/src/FactSystem/FactControls/LabelledFactBrowse.qml +++ b/src/FactSystem/FactControls/LabelledFactBrowse.qml @@ -15,6 +15,7 @@ import QGroundControl.FactControls /// dialogTitle - Title for the file dialog (defaults to label). /// selectFolder - true to browse folders, false for files (default true). /// defaultText - Placeholder shown when the Fact value is empty. +/// showClearButton - show a button that restores an empty path (default false). RowLayout { id: root @@ -23,6 +24,7 @@ RowLayout { property string dialogTitle: label property bool selectFolder: true property string defaultText: qsTr("") + property bool showClearButton: false spacing: ScreenTools.defaultFontPixelWidth * 2 @@ -56,4 +58,11 @@ RowLayout { onAcceptedForLoad: (file) => root.fact.rawValue = file } } + + QGCButton { + text: qsTr("Clear") + visible: root.showClearButton + enabled: root.fact.rawValue !== "" + onClicked: root.fact.rawValue = "" + } } diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 9a9c0cd4c213..26e1175db1e7 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -4,8 +4,8 @@ "QGC.MetaData.Facts": [ { "name": "videoSource", - "shortDesc": "Source for video stream (UDP, TCP, RTSP, or connected USB camera).", - "longDesc": "Source for video. UDP, TCP, RTSP and UVC Cameras may be supported depending on Vehicle and ground station version.", + "shortDesc": "Source for video stream.", + "longDesc": "Source for video. UDP, TCP, RTSP, HTTP MJPEG, WebSocket JPEG, and UVC cameras may be supported depending on vehicle and ground station build.", "type": "string", "default": "", "label": "Source", @@ -38,6 +38,76 @@ "label": "TCP URL", "keywords": "tcp,video url,stream url" }, + { + "name": "httpMjpegUrl", + "shortDesc": "HTTP or HTTPS URL for a multipart MJPEG stream.", + "longDesc": "URL for a standard multipart/x-mixed-replace MJPEG source. Anonymous HTTP is intended for local or trusted test networks. Credentials require HTTPS.", + "type": "string", + "maxStringLength": 2048, + "default": "", + "label": "HTTP MJPEG URL", + "keywords": "http,https,mjpeg,video url,stream url" + }, + { + "name": "websocketJpegUrl", + "shortDesc": "WebSocket URL that sends each JPEG image as one binary message.", + "longDesc": "URL for a WebSocket JPEG source. Text messages are ignored. Anonymous WS is intended for local or trusted test networks. Credentials require WSS.", + "type": "string", + "maxStringLength": 2048, + "default": "", + "label": "WebSocket JPEG URL", + "keywords": "websocket,ws,wss,jpeg,video url,stream url" + }, + { + "name": "networkVideoAuthType", + "shortDesc": "Authentication method for HTTP MJPEG and WebSocket JPEG sources.", + "longDesc": "Authentication is optional. Basic and Bearer credentials are accepted only with HTTPS or WSS and are never stored directly in QGroundControl settings.", + "type": "uint32", + "enumStrings": "None,Basic,Bearer token", + "enumValues": "0,1,2", + "default": 0, + "label": "Authentication", + "keywords": "authentication,basic,bearer,token,password" + }, + { + "name": "networkVideoUsername", + "shortDesc": "Username used with Basic authentication.", + "type": "string", + "maxStringLength": 256, + "default": "", + "label": "Username", + "keywords": "authentication,basic,username" + }, + { + "name": "networkVideoSecretFile", + "shortDesc": "Optional file containing one password or token.", + "longDesc": "On Unix-like desktop systems, load the credential from a regular owner-only file containing a single line. A session credential entered in the UI takes precedence. Other platforms use a session credential.", + "type": "string", + "maxStringLength": 4096, + "default": "", + "label": "Credential file", + "keywords": "authentication,credential file,secret file,token,password" + }, + { + "name": "networkVideoOrigin", + "shortDesc": "Optional Origin value required by some HTTP or WebSocket servers.", + "longDesc": "Sets the Origin request header. Leave blank unless the source server requires an exact Origin.", + "type": "string", + "maxStringLength": 512, + "default": "", + "label": "Origin", + "keywords": "origin,websocket,http,security" + }, + { + "name": "networkVideoCaCertificateFile", + "shortDesc": "Optional PEM trust file for HTTPS or WSS certificate validation.", + "longDesc": "WSS adds these authorities to the system trust store. For HTTP MJPEG, the PEM file is the complete trust database and must include every required root. Strict certificate validation remains enabled.", + "type": "string", + "maxStringLength": 4096, + "default": "", + "label": "CA certificate file", + "keywords": "tls,ssl,certificate,ca,https,wss" + }, { "name": "videoSavePath", "shortDesc": "Video save directory", diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index ae43d03c6c1a..c2945634ad6e 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -2,9 +2,21 @@ #include "VideoManager.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" +#include "SecureMemory.h" +#include #include +#include +#include #include +#if defined(Q_OS_UNIX) && !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#include +#include +#include +#include +#endif + QGC_LOGGING_CATEGORY(VideoSettingsLog, "Settings.VideoSettings") #ifdef QGC_GST_STREAMING @@ -24,6 +36,12 @@ DECLARE_SETTINGGROUP(Video, "Video") videoSourceList.append(videoSourceUDPH265); videoSourceList.append(videoSourceTCP); videoSourceList.append(videoSourceMPEGTS); +#ifdef QGC_GST_STREAMING + videoSourceList.append(videoSourceHTTPMJPEG); +#ifdef QGC_HAS_WEBSOCKET_VIDEO + videoSourceList.append(videoSourceWebSocketJPEG); +#endif +#endif videoSourceList.append(videoSource3DRSolo); videoSourceList.append(videoSourceParrotDiscovery); videoSourceList.append(videoSourceYuneecMantisG); @@ -75,6 +93,12 @@ DECLARE_SETTINGGROUP(Video, "Video") _setDefaults(); } +VideoSettings::~VideoSettings() +{ + _networkVideoSecret.detach(); + QGC::secureZero(_networkVideoSecret); +} + void VideoSettings::_setDefaults() { if (_noVideo) { @@ -229,6 +253,337 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, tcpUrl) return _tcpUrlFact; } +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, httpMjpegUrl) +{ + if (!_httpMjpegUrlFact) { + _httpMjpegUrlFact = _createSettingsFact(httpMjpegUrlName); + connect(_httpMjpegUrlFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _httpMjpegUrlFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, websocketJpegUrl) +{ + if (!_websocketJpegUrlFact) { + _websocketJpegUrlFact = _createSettingsFact(websocketJpegUrlName); + connect(_websocketJpegUrlFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _websocketJpegUrlFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, networkVideoAuthType) +{ + if (!_networkVideoAuthTypeFact) { + _networkVideoAuthTypeFact = _createSettingsFact(networkVideoAuthTypeName); + connect(_networkVideoAuthTypeFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _networkVideoAuthTypeFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, networkVideoUsername) +{ + if (!_networkVideoUsernameFact) { + _networkVideoUsernameFact = _createSettingsFact(networkVideoUsernameName); + connect(_networkVideoUsernameFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _networkVideoUsernameFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, networkVideoSecretFile) +{ + if (!_networkVideoSecretFileFact) { + _networkVideoSecretFileFact = _createSettingsFact(networkVideoSecretFileName); + connect(_networkVideoSecretFileFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _networkVideoSecretFileFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, networkVideoOrigin) +{ + if (!_networkVideoOriginFact) { + _networkVideoOriginFact = _createSettingsFact(networkVideoOriginName); + connect(_networkVideoOriginFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _networkVideoOriginFact; +} + +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, networkVideoCaCertificateFile) +{ + if (!_networkVideoCaCertificateFileFact) { + _networkVideoCaCertificateFileFact = _createSettingsFact(networkVideoCaCertificateFileName); + connect(_networkVideoCaCertificateFileFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _networkVideoCaCertificateFileFact; +} + +bool VideoSettings::validateNetworkVideoUrl(const QString& value, const QStringList& allowedSchemes, QString& error) +{ + const QUrl url(value, QUrl::StrictMode); + if (value.isEmpty() || !url.isValid() || url.host().isEmpty()) { + error = tr("Enter a valid network video URL with a host."); + return false; + } + + const QString scheme = url.scheme().toLower(); + if (!allowedSchemes.contains(scheme)) { + error = tr("The selected video source does not support the '%1' URL scheme.").arg(scheme); + return false; + } + if (!url.userInfo().isEmpty()) { + error = tr("Credentials in video URLs are not supported. Use the security settings instead."); + return false; + } + if (url.hasFragment()) { + error = tr("Video URLs must not contain a fragment."); + return false; + } + + static const QStringList sensitiveQueryKeys = { + QStringLiteral("access_token"), + QStringLiteral("api_key"), + QStringLiteral("apikey"), + QStringLiteral("auth"), + QStringLiteral("authorization"), + QStringLiteral("key"), + QStringLiteral("passwd"), + QStringLiteral("password"), + QStringLiteral("secret"), + QStringLiteral("token"), + }; + const QUrlQuery query(url); + for (const auto& [key, unusedValue] : query.queryItems(QUrl::FullyDecoded)) { + Q_UNUSED(unusedValue) + if (sensitiveQueryKeys.contains(key.toLower())) { + error = tr("Credentials in video URL query parameters are not supported."); + return false; + } + } + + error.clear(); + return true; +} + +bool VideoSettings::networkVideoSessionSecretConfigured() +{ + return !_networkVideoSecret.isEmpty(); +} + +bool VideoSettings::networkVideoCredentialFileSupported() const +{ +#if defined(Q_OS_UNIX) && !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + return true; +#else + return false; +#endif +} + +QString VideoSettings::setNetworkVideoSecret(const QString& secret) +{ + QByteArray encoded = secret.toUtf8(); + if (encoded.isEmpty()) { + return tr("Credential cannot be empty."); + } + if (encoded.size() > 4096) { + QGC::secureZero(encoded); + return tr("Credential exceeds the 4096 byte limit."); + } + if (encoded.contains('\0') || encoded.contains('\r') || encoded.contains('\n')) { + QGC::secureZero(encoded); + return tr("Credential must be a single line without NUL characters."); + } + + _networkVideoSecret.detach(); + QGC::secureZero(_networkVideoSecret); + _networkVideoSecret = encoded; + QGC::secureZero(encoded); + emit networkVideoSecretChanged(); + emit networkVideoConfigurationErrorChanged(); + emit streamConfiguredChanged(streamConfigured()); + return QString(); +} + +void VideoSettings::clearNetworkVideoSecret() +{ + if (_networkVideoSecret.isEmpty()) { + return; + } + + _networkVideoSecret.detach(); + QGC::secureZero(_networkVideoSecret); + emit networkVideoSecretChanged(); + emit networkVideoConfigurationErrorChanged(); + emit streamConfiguredChanged(streamConfigured()); +} + +bool VideoSettings::resolveNetworkVideoSecret(QByteArray& secret, QString& error) const +{ + secret.clear(); + error.clear(); + + if (!_networkVideoSecret.isEmpty()) { + secret = _networkVideoSecret; + return true; + } + + const QString filePath = _networkVideoSecretFileFact + ? _networkVideoSecretFileFact->rawValue().toString() + : const_cast(this)->networkVideoSecretFile()->rawValue().toString(); + if (filePath.isEmpty()) { + error = tr("Enter a session credential or select a credential file."); + return false; + } + +#if !defined(Q_OS_UNIX) || defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + error = tr("Credential files are supported only on Unix-like systems. Enter a session credential instead."); + return false; +#else + int openFlags = O_RDONLY | O_NONBLOCK | O_NOFOLLOW; +#ifdef O_CLOEXEC + openFlags |= O_CLOEXEC; +#endif + const QByteArray encodedPath = QFile::encodeName(filePath); + const int fileDescriptor = ::open(encodedPath.constData(), openFlags); + if (fileDescriptor < 0) { + error = tr("Credential file could not be opened as a non-symbolic-link file."); + return false; + } + + struct stat fileStatus {}; + if (::fstat(fileDescriptor, &fileStatus) != 0 || !S_ISREG(fileStatus.st_mode)) { + ::close(fileDescriptor); + error = tr("Credential file must be a regular file."); + return false; + } + if (fileStatus.st_uid != ::geteuid()) { + ::close(fileDescriptor); + error = tr("Credential file must be owned by the QGroundControl process user."); + return false; + } + if ((fileStatus.st_mode & S_IRUSR) == 0 || (fileStatus.st_mode & (S_IRWXG | S_IRWXO)) != 0) { + ::close(fileDescriptor); + error = tr("Credential file permissions must allow owner read and deny all group/other access."); + return false; + } + if (fileStatus.st_nlink != 1) { + ::close(fileDescriptor); + error = tr("Credential file must not have multiple hard links."); + return false; + } + + secret.resize(4097); + qsizetype bytesRead = 0; + while (bytesRead < secret.size()) { + const ssize_t result = + ::read(fileDescriptor, secret.data() + bytesRead, static_cast(secret.size() - bytesRead)); + if (result == 0) { + break; + } + if (result < 0) { + if (errno == EINTR) { + continue; + } + QGC::secureZero(secret); + ::close(fileDescriptor); + error = tr("Credential file could not be read."); + return false; + } + bytesRead += static_cast(result); + } + ::close(fileDescriptor); + secret.resize(bytesRead); + if (secret.size() > 4096) { + QGC::secureZero(secret); + error = tr("Credential file exceeds the 4096 byte limit."); + return false; + } + if (secret.endsWith("\r\n")) { + secret.chop(2); + } else if (secret.endsWith('\n') || secret.endsWith('\r')) { + secret.chop(1); + } + if (secret.isEmpty() || secret.contains('\0') || secret.contains('\r') || secret.contains('\n')) { + QGC::secureZero(secret); + error = tr("Credential file must contain exactly one non-empty line."); + return false; + } + + return true; +#endif +} + +QString VideoSettings::networkVideoConfigurationError() +{ + const QString source = videoSource()->rawValue().toString(); + QString urlValue; + QStringList schemes; + if (source == videoSourceHTTPMJPEG) { + urlValue = httpMjpegUrl()->rawValue().toString(); + schemes = {QStringLiteral("http"), QStringLiteral("https")}; + } else if (source == videoSourceWebSocketJPEG) { + urlValue = websocketJpegUrl()->rawValue().toString(); + schemes = {QStringLiteral("ws"), QStringLiteral("wss")}; + } else { + return QString(); + } + + QString error; + if (!validateNetworkVideoUrl(urlValue, schemes, error)) { + return error; + } + + const int authType = networkVideoAuthType()->rawValue().toInt(); + if (authType < NetworkVideoAuthNone || authType > NetworkVideoAuthBearer) { + return tr("Unsupported network video authentication method."); + } + + const QUrl url(urlValue, QUrl::StrictMode); + const bool secureTransport = (url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("wss")); + if (authType != NetworkVideoAuthNone && !secureTransport) { + return tr("Credentials require HTTPS or WSS. Anonymous HTTP or WS remains available for local testing."); + } + if (authType == NetworkVideoAuthBasic) { + const QString username = networkVideoUsername()->rawValue().toString(); + if (username.isEmpty()) { + return tr("Basic authentication requires a username."); + } + if (username.contains(QLatin1Char(':')) || username.contains(QChar::Null) || + username.contains(QLatin1Char('\r')) || username.contains(QLatin1Char('\n'))) { + return tr("Basic authentication username must not contain colon, NUL, CR, or LF characters."); + } + } + + const QString origin = networkVideoOrigin()->rawValue().toString(); + if (!origin.isEmpty()) { + const QUrl originUrl(origin, QUrl::StrictMode); + const QString originScheme = originUrl.scheme().toLower(); + if (!originUrl.isValid() || originUrl.host().isEmpty() || + (originScheme != QStringLiteral("http") && originScheme != QStringLiteral("https")) || + !originUrl.userInfo().isEmpty() || originUrl.hasQuery() || originUrl.hasFragment() || + (!originUrl.path().isEmpty() && originUrl.path() != QStringLiteral("/"))) { + return tr("Origin must be an HTTP or HTTPS origin containing only scheme, host, and optional port."); + } + } + if (authType != NetworkVideoAuthNone) { + QByteArray resolvedSecret; + if (!resolveNetworkVideoSecret(resolvedSecret, error)) { + return error; + } + QGC::secureZero(resolvedSecret); + } + + const QString caFile = networkVideoCaCertificateFile()->rawValue().toString(); + if (!caFile.isEmpty()) { + if (!secureTransport) { + return tr("A custom CA certificate can be used only with HTTPS or WSS."); + } + if (QGCNetworkHelper::loadCaCertificates(caFile, &error).isEmpty()) { + return error; + } + } + + return QString(); +} + bool VideoSettings::streamConfigured(void) { //-- First, check if it's autoconfigured @@ -248,7 +603,8 @@ bool VideoSettings::streamConfigured(void) } //-- If RTSP, check for URL if(vSource == videoSourceRTSP) { - qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream:" << rtspUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream:" + << QGCNetworkHelper::redactedUrlForLogging(rtspUrl()->rawValue().toString()); return !rtspUrl()->rawValue().toString().isEmpty(); } //-- If TCP, check for URL @@ -261,6 +617,14 @@ bool VideoSettings::streamConfigured(void) qCDebug(VideoSettingsLog) << "Testing configuration for MPEG-TS Stream:" << udpUrl()->rawValue().toString(); return !udpUrl()->rawValue().toString().isEmpty(); } + if (vSource == videoSourceHTTPMJPEG || vSource == videoSourceWebSocketJPEG) { + const QString error = networkVideoConfigurationError(); + if (!error.isEmpty()) { + qCDebug(VideoSettingsLog) << "Network video configuration is incomplete:" << error; + return false; + } + return true; + } //-- If Herelink Air unit, good to go if(vSource == videoSourceHerelinkAirUnit) { qCDebug(VideoSettingsLog) << "Stream configured for Herelink Air Unit"; @@ -280,6 +644,7 @@ bool VideoSettings::streamConfigured(void) void VideoSettings::_configChanged(QVariant) { + emit networkVideoConfigurationErrorChanged(); emit streamConfiguredChanged(streamConfigured()); } diff --git a/src/Settings/VideoSettings.h b/src/Settings/VideoSettings.h index 6bd21c19b1b5..c9a00e722240 100644 --- a/src/Settings/VideoSettings.h +++ b/src/Settings/VideoSettings.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "SettingsGroup.h" @@ -11,12 +13,20 @@ class VideoSettings : public SettingsGroup QML_UNCREATABLE("") public: VideoSettings(QObject* parent = nullptr); + ~VideoSettings() override; DEFINE_SETTING_NAME_GROUP() DEFINE_SETTINGFACT(videoSource) DEFINE_SETTINGFACT(udpUrl) DEFINE_SETTINGFACT(tcpUrl) DEFINE_SETTINGFACT(rtspUrl) + DEFINE_SETTINGFACT(httpMjpegUrl) + DEFINE_SETTINGFACT(websocketJpegUrl) + DEFINE_SETTINGFACT(networkVideoAuthType) + DEFINE_SETTINGFACT(networkVideoUsername) + DEFINE_SETTINGFACT(networkVideoSecretFile) + DEFINE_SETTINGFACT(networkVideoOrigin) + DEFINE_SETTINGFACT(networkVideoCaCertificateFile) DEFINE_SETTINGFACT(aspectRatio) DEFINE_SETTINGFACT(videoFit) DEFINE_SETTINGFACT(gridLines) @@ -41,7 +51,26 @@ class VideoSettings : public SettingsGroup Q_PROPERTY(QString udp265VideoSource READ udp265VideoSource CONSTANT) Q_PROPERTY(QString tcpVideoSource READ tcpVideoSource CONSTANT) Q_PROPERTY(QString mpegtsVideoSource READ mpegtsVideoSource CONSTANT) + Q_PROPERTY(QString httpMjpegVideoSource READ httpMjpegVideoSource CONSTANT) + Q_PROPERTY(QString websocketJpegVideoSource READ websocketJpegVideoSource CONSTANT) Q_PROPERTY(QString disabledVideoSource READ disabledVideoSource CONSTANT) + Q_PROPERTY(bool networkVideoSessionSecretConfigured + READ networkVideoSessionSecretConfigured + NOTIFY networkVideoSecretChanged) + Q_PROPERTY(bool networkVideoCredentialFileSupported + READ networkVideoCredentialFileSupported + CONSTANT) + Q_PROPERTY(QString networkVideoConfigurationError + READ networkVideoConfigurationError + NOTIFY networkVideoConfigurationErrorChanged) + + enum NetworkVideoAuthentication + { + NetworkVideoAuthNone = 0, + NetworkVideoAuthBasic, + NetworkVideoAuthBearer, + }; + Q_ENUM(NetworkVideoAuthentication) bool streamConfigured (); QString rtspVideoSource () { return videoSourceRTSP; } @@ -49,8 +78,20 @@ class VideoSettings : public SettingsGroup QString udp265VideoSource () { return videoSourceUDPH265; } QString tcpVideoSource () { return videoSourceTCP; } QString mpegtsVideoSource () { return videoSourceMPEGTS; } + QString httpMjpegVideoSource () { return videoSourceHTTPMJPEG; } + QString websocketJpegVideoSource() { return videoSourceWebSocketJPEG; } QString disabledVideoSource () { return videoDisabled; } + bool networkVideoSessionSecretConfigured(); + bool networkVideoCredentialFileSupported() const; + QString networkVideoConfigurationError(); + bool resolveNetworkVideoSecret(QByteArray& secret, QString& error) const; + + Q_INVOKABLE QString setNetworkVideoSecret(const QString& secret); + Q_INVOKABLE void clearNetworkVideoSecret(); + + static bool validateNetworkVideoUrl(const QString& value, const QStringList& allowedSchemes, QString& error); + /// Remove hardware forced-decoder options absent from the running GStreamer registry, and /// reset the active choice to Default if it was pruned. Call after the video backend has /// initialized (the registry is empty until then). @@ -63,6 +104,10 @@ class VideoSettings : public SettingsGroup static constexpr const char* videoSourceUDPH265 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.265 Video Stream"); static constexpr const char* videoSourceTCP = QT_TRANSLATE_NOOP("VideoSettings", "TCP-MPEG2 Video Stream"); static constexpr const char* videoSourceMPEGTS = QT_TRANSLATE_NOOP("VideoSettings", "MPEG-TS Video Stream"); + static constexpr const char* videoSourceHTTPMJPEG = + QT_TRANSLATE_NOOP("VideoSettings", "HTTP MJPEG Video Stream"); + static constexpr const char* videoSourceWebSocketJPEG = + QT_TRANSLATE_NOOP("VideoSettings", "WebSocket JPEG Video Stream"); static constexpr const char* videoSource3DRSolo = QT_TRANSLATE_NOOP("VideoSettings", "3DR Solo (requires restart)"); static constexpr const char* videoSourceParrotDiscovery = QT_TRANSLATE_NOOP("VideoSettings", "Parrot Discovery"); static constexpr const char* videoSourceYuneecMantisG = QT_TRANSLATE_NOOP("VideoSettings", "Yuneec Mantis G"); @@ -71,6 +116,8 @@ class VideoSettings : public SettingsGroup signals: void streamConfiguredChanged (bool configured); + void networkVideoSecretChanged(); + void networkVideoConfigurationErrorChanged(); private slots: void _configChanged (QVariant value); @@ -81,5 +128,6 @@ private slots: private: bool _noVideo = false; + QByteArray _networkVideoSecret; }; diff --git a/src/Utilities/Network/QGCNetworkHelper.cc b/src/Utilities/Network/QGCNetworkHelper.cc index 5545ee2cc389..b4bfbfc8f9dc 100644 --- a/src/Utilities/Network/QGCNetworkHelper.cc +++ b/src/Utilities/Network/QGCNetworkHelper.cc @@ -342,6 +342,22 @@ QUrl urlWithoutQuery(const QUrl& url) return url.adjusted(QUrl::RemoveQuery | QUrl::RemoveFragment); } +QString redactedUrlForLogging(const QUrl& url) +{ + if (!url.isValid() || url.scheme().isEmpty()) { + return QStringLiteral(""); + } + + QUrl result = url.adjusted(QUrl::RemoveQuery | QUrl::RemoveFragment); + result.setUserInfo(QString()); + return result.toDisplayString(QUrl::FullyEncoded); +} + +QString redactedUrlForLogging(const QString& url) +{ + return redactedUrlForLogging(QUrl(url, QUrl::StrictMode)); +} + // ============================================================================ // Request Configuration // ============================================================================ diff --git a/src/Utilities/Network/QGCNetworkHelper.h b/src/Utilities/Network/QGCNetworkHelper.h index cb92f2d03a3d..eb98d19f0ba5 100644 --- a/src/Utilities/Network/QGCNetworkHelper.h +++ b/src/Utilities/Network/QGCNetworkHelper.h @@ -139,6 +139,10 @@ QUrl buildUrl(const QString& baseUrl, const QList>& para /// Get URL without query string and fragment QUrl urlWithoutQuery(const QUrl& url); +/// Return a URL safe for logs by removing user info, query, and fragment. +QString redactedUrlForLogging(const QUrl& url); +QString redactedUrlForLogging(const QString& url); + // ============================================================================ // Request Configuration // ============================================================================ diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index 72d47e207f7d..f27311c03cd7 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -7,6 +7,7 @@ #include "QGCCameraManager.h" #include "QGCCorePlugin.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" #include "QGCVideoStreamInfo.h" #include "SettingsManager.h" #include "SubtitleWriter.h" @@ -178,6 +179,20 @@ void VideoManager::init(QQuickWindow *mainWindow) (void) connect(_videoSettings->udpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->rtspUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->tcpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->httpMjpegUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->websocketJpegUrl(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->networkVideoAuthType(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->networkVideoUsername(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->networkVideoSecretFile(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->networkVideoOrigin(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->networkVideoCaCertificateFile(), &Fact::rawValueChanged, this, + &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings, &VideoSettings::networkVideoSecretChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->aspectRatio(), &Fact::rawValueChanged, this, &VideoManager::aspectRatioChanged); (void) connect(_videoSettings->lowLatencyMode(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _restartAllVideos(); }); // rtpJitterLatencyMs needs a pipeline restart; route through _videoSourceChanged so _updateSettings @@ -357,6 +372,14 @@ void VideoManager::startRecording(const QString &videoFile) return; } + const QString source = _videoSettings->videoSource()->rawValue().toString(); + if (!_isRecordingFormatSupported(source, fileFormat)) { + QGC::showAppMessage( + tr("HTTP MJPEG and WebSocket JPEG streams can be recorded as MKV or MOV. Select one of those formats in " + "Video settings before recording.")); + return; + } + _cleanupOldVideos(); const QString savePath = SettingsManager::instance()->appSettings()->videoSavePath(); @@ -381,6 +404,13 @@ void VideoManager::startRecording(const QString &videoFile) } } +bool VideoManager::_isRecordingFormatSupported(const QString &source, int fileFormat) +{ + const bool jpegNetworkSource = source == VideoSettings::videoSourceHTTPMJPEG || + source == VideoSettings::videoSourceWebSocketJPEG; + return !jpegNetworkSource || fileFormat != VideoReceiver::FILE_FORMAT_MP4; +} + void VideoManager::stopRecording() { for (VideoReceiver *receiver : std::as_const(_videoReceivers)) { @@ -502,6 +532,8 @@ bool VideoManager::isStreamSource() const VideoSettings::videoSourceRTSP, VideoSettings::videoSourceTCP, VideoSettings::videoSourceMPEGTS, + VideoSettings::videoSourceHTTPMJPEG, + VideoSettings::videoSourceWebSocketJPEG, VideoSettings::videoSource3DRSolo, VideoSettings::videoSourceParrotDiscovery, VideoSettings::videoSourceYuneecMantisG, @@ -593,7 +625,8 @@ bool VideoManager::_updateAutoStream(VideoReceiver *receiver) return false; } - qCDebug(VideoManagerLog) << QString("Configure stream (%1):").arg(receiver->name()) << pInfo->uri(); + qCDebug(VideoManagerLog) << QString("Configure stream (%1):").arg(receiver->name()) + << QGCNetworkHelper::redactedUrlForLogging(pInfo->uri()); QString source, url; switch (pInfo->type()) { @@ -651,7 +684,8 @@ bool VideoManager::_updateVideoUri(VideoReceiver *receiver, const QString &uri) return false; } - qCDebug(VideoManagerLog) << "New Video URI" << uri; + qCDebug(VideoManagerLog) << "New Video URI" + << (uri.isEmpty() ? QString() : QGCNetworkHelper::redactedUrlForLogging(uri)); receiver->setUri(uri); @@ -693,6 +727,48 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged |= _updateAutoStream(receiver); const QString source = _videoSettings->videoSource()->rawValue().toString(); + VideoReceiver::NetworkSourceConfig networkConfig; + QString networkVideoUri; + if (source == VideoSettings::videoSourceHTTPMJPEG) { + networkVideoUri = _videoSettings->httpMjpegUrl()->rawValue().toString(); + } else if (source == VideoSettings::videoSourceWebSocketJPEG) { + networkVideoUri = _videoSettings->websocketJpegUrl()->rawValue().toString(); + } + + if (!networkVideoUri.isEmpty()) { + const QString configurationError = _videoSettings->networkVideoConfigurationError(); + if (configurationError.isEmpty()) { + switch (_videoSettings->networkVideoAuthType()->rawValue().toInt()) { + case VideoSettings::NetworkVideoAuthBasic: + networkConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Basic; + break; + case VideoSettings::NetworkVideoAuthBearer: + networkConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + break; + default: + networkConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::None; + break; + } + networkConfig.username = _videoSettings->networkVideoUsername()->rawValue().toString(); + networkConfig.origin = _videoSettings->networkVideoOrigin()->rawValue().toString(); + networkConfig.caCertificateFile = _videoSettings->networkVideoCaCertificateFile()->rawValue().toString(); + + if (networkConfig.hasAuthentication()) { + QString secretError; + if (!_videoSettings->resolveNetworkVideoSecret(networkConfig.secret, secretError)) { + qCWarning(VideoManagerLog) << "Network video credential unavailable:" << secretError; + networkVideoUri.clear(); + } + } + } else { + qCWarning(VideoManagerLog) << "Network video configuration rejected:" << configurationError; + networkVideoUri.clear(); + } + } + + settingsChanged |= receiver->setNetworkSourceConfig(networkConfig); + networkConfig.clearSecret(); + if (source == VideoSettings::videoSourceUDPH264) { settingsChanged |= _updateVideoUri(receiver, QStringLiteral("udp://%1").arg(_videoSettings->udpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSourceUDPH265) { @@ -713,6 +789,8 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged |= _updateVideoUri(receiver, QStringLiteral("rtsp://192.168.0.10:8554/H264Video")); } else if (source == VideoSettings::videoSourceHerelinkHotspot) { settingsChanged |= _updateVideoUri(receiver, QStringLiteral("rtsp://192.168.43.1:8554/fpv_stream")); + } else if (source == VideoSettings::videoSourceHTTPMJPEG || source == VideoSettings::videoSourceWebSocketJPEG) { + settingsChanged |= _updateVideoUri(receiver, networkVideoUri); } else if ((source == VideoSettings::videoDisabled) || (source == VideoSettings::videoSourceNoVideo)) { settingsChanged |= _updateVideoUri(receiver, QString()); } else { @@ -899,13 +977,15 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win }); (void) connect(receiver, &VideoReceiver::onStopComplete, this, [this, receiver](VideoReceiver::STATUS status) { - qCDebug(VideoManagerLog) << "Stop complete" << receiver->name() << receiver->uri() << ", status:" << status; + qCDebug(VideoManagerLog) << "Stop complete" << receiver->name() + << QGCNetworkHelper::redactedUrlForLogging(receiver->uri()) << ", status:" << status; receiver->setStarted(false); if (status == VideoReceiver::STATUS_INVALID_URL) { qCDebug(VideoManagerLog) << "Invalid video URL. Not restarting"; } else { QTimer::singleShot(1000, receiver, [this, receiver]() { - qCDebug(VideoManagerLog) << "Restarting video receiver" << receiver->name() << receiver->uri(); + qCDebug(VideoManagerLog) << "Restarting video receiver" << receiver->name() + << QGCNetworkHelper::redactedUrlForLogging(receiver->uri()); _startReceiver(receiver); }); } diff --git a/src/VideoManager/VideoManager.h b/src/VideoManager/VideoManager.h index 52a66b15d852..86fe99da5316 100644 --- a/src/VideoManager/VideoManager.h +++ b/src/VideoManager/VideoManager.h @@ -123,6 +123,7 @@ private slots: void _restartVideo(VideoReceiver *receiver); void _startReceiver(VideoReceiver *receiver); void _stopReceiver(VideoReceiver *receiver); + static bool _isRecordingFormatSupported(const QString &source, int fileFormat); static void _cleanupOldVideos(); QList _videoReceivers; diff --git a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt index 5e7573b3a7de..2b1a61a017ac 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt +++ b/src/VideoManager/VideoReceiver/GStreamer/CMakeLists.txt @@ -10,6 +10,24 @@ endif() set(QGCGStreamer_FIND_COMPONENTS ${_qgc_gst_components}) include(GStreamer/Orchestrator) +# Generate the runtime verifier from PluginPolicy.cmake so installation and +# startup checks cannot drift as source capabilities change. +gstreamer_runtime_required_plugins(_qgc_gst_runtime_required_plugins) +list(LENGTH _qgc_gst_runtime_required_plugins _qgc_gst_runtime_required_plugin_count) +set(_qgc_gst_runtime_required_plugin_entries "") +foreach(_plugin IN LISTS _qgc_gst_runtime_required_plugins) + string(APPEND _qgc_gst_runtime_required_plugin_entries " \"${_plugin}\",\n") +endforeach() +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/GStreamerPluginPolicy.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/GStreamerPluginPolicy.h" + @ONLY +) +unset(_plugin) +unset(_qgc_gst_runtime_required_plugin_entries) +unset(_qgc_gst_runtime_required_plugin_count) +unset(_qgc_gst_runtime_required_plugins) + # QGCGStreamer: one static lib for the self-contained HwBuffers + gstqgc leaf layer (no app deps). # PUBLIC usage reqs flow to the app via the link below; facade sources stay app-side (need app headers). qt_add_library(QGCGStreamer STATIC) @@ -59,10 +77,27 @@ else() message(WARNING "QGCGStreamer: Qt6::MultimediaQuickPrivate not found - GStreamer video sink integration may not build") endif() +find_package(Qt6 COMPONENTS WebSockets QUIET) +if(TARGET Qt6::WebSockets) + target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE QGC_HAS_WEBSOCKET_VIDEO) + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE Qt6::WebSockets) + target_sources(${CMAKE_PROJECT_NAME} + PRIVATE + QGCWebSocketVideoSource.cc + QGCWebSocketVideoSource.h + ) +else() + message(STATUS "Qt6::WebSockets not found - WebSocket JPEG video source disabled") +endif() + add_subdirectory(HwBuffers) add_subdirectory(gstqgc) target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE QGCGStreamer) +if(TARGET GStreamer::gio) + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE GStreamer::gio) +endif() +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") # GStreamer:: facade: app-facing API + source/receiver glue. App-coupled, so it stays on the app # target and inherits QGCGStreamer's PUBLIC deps/defs/includes via the link above. @@ -76,11 +111,14 @@ target_sources(${CMAKE_PROJECT_NAME} GStreamerHelpers.h GStreamerLogging.cc GStreamerLogging.h + GStreamerPluginPolicy.h.in GstScoped.h GstSourceFactory.cc GstSourceFactory.h GstVideoReceiver.cc GstVideoReceiver.h + QGCJpegStreamGuard.cc + QGCJpegStreamGuard.h QGCQVideoSinkController.cc QGCQVideoSinkController.h ) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc index 6e3a6ab1e70d..46ad35aad73d 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamer.cc @@ -30,6 +30,7 @@ #include "GStreamerEnvironment.h" #include "GStreamerHelpers.h" #include "GStreamerLogging.h" +#include "GStreamerPluginPolicy.h" #include "GstScoped.h" #include "GstVideoReceiver.h" #include "HwBuffers/common/HwBuffers.h" @@ -117,13 +118,14 @@ bool _verifyPlugins() const GstObjectPtr plugin(GST_OBJECT(gst_registry_find_plugin(registry, name))); return plugin != nullptr; }; - // Mirrors GSTREAMER_RUNTIME_REQUIRED_PLUGINS (PluginPolicy.cmake) plus qgc, - // so a stripped registry fails loudly instead of at first stream attempt. - static constexpr std::array kRequiredPlugins = { - "qgc", "coreelements", "isomp4", "matroska", "multifile", "opengl", - "playback", "rtp", "rtpmanager", "rtsp", "tcp", "udp", - }; - for (const char* name : kRequiredPlugins) { + if (!hasPlugin("qgc")) { + qCCritical(GStreamerLog) << "Required GStreamer plugin not found: qgc"; + result = false; + } + for (const char* name : kRuntimeRequiredPlugins) { + if (g_str_equal(name, "videoconvertscale")) { + continue; // Alternate-group policy is checked below. + } if (!hasPlugin(name)) { qCCritical(GStreamerLog) << "Required GStreamer plugin not found:" << name; result = false; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc index 6176704bfd69..d56ca8ec7e13 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc @@ -82,7 +82,7 @@ QString writePipelineDot(GstElement* pipeline, const char* tag) QFile::remove(existing.takeFirst().absoluteFilePath()); } - gchar* data = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL); + gchar* data = gst_debug_bin_to_dot_data(GST_BIN(pipeline), kSafePipelineGraphDetails); if (!data) return {}; const QString fileName = QStringLiteral("%1-%2.dot") diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h index 568884a98985..60b618243ae5 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h @@ -11,6 +11,12 @@ namespace GStreamer { bool isValidRtspUri(const gchar* uri_str); +/// Pipeline graph detail mask safe for persisted diagnostics. Element property +/// values are intentionally excluded because network source credentials can be +/// stored in non-default GStreamer properties. +inline constexpr GstDebugGraphDetails kSafePipelineGraphDetails = static_cast( + GST_DEBUG_GRAPH_SHOW_MEDIA_TYPE | GST_DEBUG_GRAPH_SHOW_CAPS_DETAILS | GST_DEBUG_GRAPH_SHOW_STATES); + /// Dump @p pipeline's graph as a rotating .dot under CacheLocation/qgc-pipeline-dot/ for field reports. /// Returns empty (no-op) when GST_DEBUG_DUMP_DOT_DIR is set or on I/O failure. QString writePipelineDot(GstElement* pipeline, const char* tag); diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerPluginPolicy.h.in b/src/VideoManager/VideoReceiver/GStreamer/GStreamerPluginPolicy.h.in new file mode 100644 index 000000000000..8b18be777ebd --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerPluginPolicy.h.in @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace GStreamer { + +inline constexpr std::array kRuntimeRequiredPlugins = { +@_qgc_gst_runtime_required_plugin_entries@}; + +} // namespace GStreamer diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 9a61cdacb3f1..cf0d2e9b0228 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -1,12 +1,30 @@ #include "GstSourceFactory.h" #include +#include +#include +#include +#include #include + +#pragma push_macro("signals") +#undef signals +#include +#pragma pop_macro("signals") + +#include #include #include +#include #include "GStreamerHelpers.h" +#include "QGCJpegStreamGuard.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" +#include "SecureMemory.h" +#ifdef QGC_HAS_WEBSOCKET_VIDEO +#include "QGCWebSocketVideoSource.h" +#endif QGC_LOGGING_CATEGORY(GstSourceFactoryLog, "Video.GStreamer.GstSourceFactory") @@ -15,6 +33,129 @@ namespace { constexpr guint64 kRtspTcpTimeoutUs = G_GUINT64_CONSTANT(5000000); constexpr int kRtspRetry = 3; constexpr int kUdpBufferSizeBytes = 8 * 1024 * 1024; +constexpr int kWebSocketThreadOperationTimeoutMs = 5000; + +struct HttpMultipartProbeContext +{ + QGCJpegStreamGuard::MultipartGuard guard; + bool failed = false; +}; + +void postNetworkJpegError(GstPad* pad, const QString& reason) +{ + GstElement* element = gst_pad_get_parent_element(pad); + if (!element) { + return; + } + + const QByteArray detail = reason.toUtf8(); + GST_ELEMENT_ERROR(element, STREAM, DECODE, ("Network JPEG stream was rejected"), ("%s", detail.constData())); + gst_object_unref(element); +} + +GstPadProbeReturn guardHttpMultipart(GstPad* pad, GstPadProbeInfo* info, gpointer userData) +{ + auto* context = static_cast(userData); + if (!context || context->failed) { + return GST_PAD_PROBE_DROP; + } + + QString error; + if ((GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM) != 0) { + GstEvent* event = GST_PAD_PROBE_INFO_EVENT(info); + if (event && GST_EVENT_TYPE(event) == GST_EVENT_CAPS) { + GstCaps* caps = nullptr; + gst_event_parse_caps(event, &caps); + if (caps && !gst_caps_is_empty(caps) && !gst_caps_is_any(caps)) { + const GstStructure* structure = gst_caps_get_structure(caps, 0); + if (const gchar* boundary = gst_structure_get_string(structure, "boundary"); + boundary && !context->guard.setBoundary( + QByteArrayView(boundary, static_cast(qstrlen(boundary))), &error)) { + context->failed = true; + postNetworkJpegError(pad, error); + return GST_PAD_PROBE_DROP; + } + } + } + return GST_PAD_PROBE_OK; + } + + GstBuffer* buffer = GST_PAD_PROBE_INFO_BUFFER(info); + if (!buffer) { + return GST_PAD_PROBE_OK; + } + + GstMapInfo map = GST_MAP_INFO_INIT; + if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) { + context->failed = true; + postNetworkJpegError(pad, QStringLiteral("Multipart input buffer could not be mapped.")); + return GST_PAD_PROBE_DROP; + } + const bool accepted = context->guard.consume( + QByteArrayView(reinterpret_cast(map.data), static_cast(map.size)), &error); + gst_buffer_unmap(buffer, &map); + if (!accepted) { + context->failed = true; + postNetworkJpegError(pad, error); + return GST_PAD_PROBE_DROP; + } + return GST_PAD_PROBE_OK; +} + +GstPadProbeReturn validateJpegBuffer(GstPad* pad, GstPadProbeInfo* info, gpointer) +{ + GstBuffer* buffer = GST_PAD_PROBE_INFO_BUFFER(info); + if (!buffer) { + return GST_PAD_PROBE_OK; + } + + GstMapInfo map = GST_MAP_INFO_INIT; + if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) { + postNetworkJpegError(pad, QStringLiteral("Parsed JPEG buffer could not be mapped.")); + return GST_PAD_PROBE_DROP; + } + QString error; + const bool accepted = QGCJpegStreamGuard::validateJpeg( + QByteArrayView(reinterpret_cast(map.data), static_cast(map.size)), &error); + gst_buffer_unmap(buffer, &map); + if (!accepted) { + postNetworkJpegError(pad, error); + return GST_PAD_PROBE_DROP; + } + return GST_PAD_PROBE_OK; +} + +bool installHttpJpegGuards(GstElement* demux, GstElement* parser) +{ + GstPad* demuxSink = gst_element_get_static_pad(demux, "sink"); + GstPad* parserSource = gst_element_get_static_pad(parser, "src"); + if (!demuxSink || !parserSource) { + qCWarning(GstSourceFactoryLog) << "Required HTTP JPEG guard pad is unavailable"; + gst_clear_object(&demuxSink); + gst_clear_object(&parserSource); + return false; + } + + auto* context = new HttpMultipartProbeContext; + const gulong multipartProbe = gst_pad_add_probe( + demuxSink, static_cast(GST_PAD_PROBE_TYPE_BUFFER | GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM), + guardHttpMultipart, context, [](gpointer data) { delete static_cast(data); }); + if (multipartProbe == 0) { + delete context; + } + const gulong jpegProbe = + gst_pad_add_probe(parserSource, GST_PAD_PROBE_TYPE_BUFFER, validateJpegBuffer, nullptr, nullptr); + if (multipartProbe != 0 && jpegProbe == 0) { + gst_pad_remove_probe(demuxSink, multipartProbe); + } + gst_object_unref(demuxSink); + gst_object_unref(parserSource); + if (multipartProbe == 0 || jpegProbe == 0) { + qCWarning(GstSourceFactoryLog) << "Failed to install HTTP JPEG stream guards"; + return false; + } + return true; +} // Older Linux/system GStreamer needs an autoplug-query caps filter to keep parsebin on byte-stream output. #if defined(QGC_GST_ENABLE_LEGACY_PARSEBIN_CAPS_FILTER) @@ -273,7 +414,7 @@ void linkPad(GstElement* element, GstPad* pad, gpointer data) GstElement* buildRtspSource(const QString& uri, const QUrl& sourceUrl, const Config& config, guint latencyMs) { if (!GStreamer::isValidRtspUri(uri.toUtf8().constData())) { - qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -313,12 +454,14 @@ GstElement* buildTcpSource(const QUrl& sourceUrl) { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } const QString host = sourceUrl.host(); if (host.isEmpty()) { - qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -336,7 +479,8 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -398,6 +542,315 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 return source; } +void linkPadToElement(GstElement* /*element*/, GstPad* pad, gpointer data) +{ + auto* downstream = static_cast(data); + if (!downstream || (GST_PAD_DIRECTION(pad) != GST_PAD_SRC)) { + return; + } + + GstPad* sinkPad = gst_element_get_static_pad(downstream, "sink"); + if (!sinkPad) { + qCWarning(GstSourceFactoryLog) << "gst_element_get_static_pad('sink') failed"; + return; + } + + if (!gst_pad_is_linked(sinkPad)) { + const GstPadLinkReturn result = gst_pad_link(pad, sinkPad); + if (result != GST_PAD_LINK_OK) { + qCWarning(GstSourceFactoryLog) << "gst_pad_link() failed:" << result; + } + } + gst_object_unref(sinkPad); +} + +GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) +{ + const VideoReceiver::NetworkSourceConfig& networkConfig = config.networkSourceConfig; + if (networkConfig.hasAuthentication() && sourceUrl.scheme() != QStringLiteral("https")) { + qCWarning(GstSourceFactoryLog) << "Authenticated HTTP MJPEG video requires HTTPS"; + return nullptr; + } + if (!networkConfig.caCertificateFile.isEmpty() && sourceUrl.scheme() != QStringLiteral("https")) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG custom CA configuration requires HTTPS"; + return nullptr; + } + if (!networkConfig.caCertificateFile.isEmpty()) { + QString certificateError; + if (QGCNetworkHelper::loadCaCertificates(networkConfig.caCertificateFile, &certificateError).isEmpty()) { + qCWarning(GstSourceFactoryLog) << "Invalid HTTP MJPEG custom CA trust file:" << certificateError; + return nullptr; + } + } + + GstElement* source = gst_element_factory_make("souphttpsrc", "source"); + GstElement* demux = gst_element_factory_make("multipartdemux", "multipart-demux"); + GstElement* parser = gst_element_factory_make("jpegparse", "jpeg-parser"); + GstElement* bin = gst_bin_new("sourcebin"); + if (!source || !demux || !parser || !bin) { + qCWarning(GstSourceFactoryLog) << "Required HTTP MJPEG GStreamer element is unavailable"; + gst_clear_object(&source); + gst_clear_object(&demux); + gst_clear_object(&parser); + gst_clear_object(&bin); + return nullptr; + } + gst_bin_add_many(GST_BIN(bin), source, demux, parser, nullptr); + + QUrl cleanUrl(sourceUrl); + cleanUrl.setUserInfo(QString()); + const QByteArray location = cleanUrl.toEncoded(QUrl::FullyEncoded); + const QByteArray userAgent = QGCNetworkHelper::defaultUserAgent().toUtf8(); + const bool securitySensitiveRequest = networkConfig.hasAuthentication() || !networkConfig.origin.isEmpty() || + !networkConfig.caCertificateFile.isEmpty(); + g_object_set(source, "location", location.constData(), "is-live", TRUE, "do-timestamp", TRUE, "timeout", + config.timeoutS, "retries", securitySensitiveRequest ? 0 : 3, "keep-alive", TRUE, "automatic-redirect", + securitySensitiveRequest ? FALSE : TRUE, "ssl-strict", TRUE, "ssl-use-system-ca-file", + networkConfig.caCertificateFile.isEmpty() ? TRUE : FALSE, "user-agent", userAgent.constData(), + "http-log-level", 0, nullptr); + + if (!networkConfig.caCertificateFile.isEmpty()) { + const QByteArray caFileUtf8 = networkConfig.caCertificateFile.toUtf8(); + GError* filenameError = nullptr; + gchar* caFile = g_filename_from_utf8(caFileUtf8.constData(), static_cast(caFileUtf8.size()), nullptr, + nullptr, &filenameError); + if (!caFile) { + qCWarning(GstSourceFactoryLog) + << "The HTTP MJPEG custom CA path could not be converted to the platform filename encoding. " + "Domain/code:" + << (filenameError ? filenameError->domain : 0) << (filenameError ? filenameError->code : 0); + g_clear_error(&filenameError); + gst_clear_object(&bin); + return nullptr; + } + GError* databaseError = nullptr; + GTlsDatabase* tlsDatabase = g_tls_file_database_new(caFile, &databaseError); + g_free(caFile); + if (!tlsDatabase) { + qCWarning(GstSourceFactoryLog) << "Failed to create the HTTP MJPEG custom CA trust database. Domain/code:" + << (databaseError ? databaseError->domain : 0) + << (databaseError ? databaseError->code : 0); + g_clear_error(&databaseError); + gst_clear_object(&bin); + return nullptr; + } + g_object_set(source, "tls-database", tlsDatabase, nullptr); + g_object_unref(tlsDatabase); + } + + if (networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::Basic) { + const QByteArray username = networkConfig.username.toUtf8(); + g_object_set(source, "user-id", username.constData(), "user-pw", networkConfig.secret.constData(), nullptr); + } + + GstStructure* headers = nullptr; + QByteArray authorization; + if (networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::Bearer || + !networkConfig.origin.isEmpty()) { + headers = gst_structure_new_empty("extra-headers"); + } + if (networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::Bearer) { + authorization = QByteArrayLiteral("Bearer ") + networkConfig.secret; + gst_structure_set(headers, "Authorization", G_TYPE_STRING, authorization.constData(), nullptr); + } + if (!networkConfig.origin.isEmpty()) { + const QByteArray origin = networkConfig.origin.toUtf8(); + gst_structure_set(headers, "Origin", G_TYPE_STRING, origin.constData(), nullptr); + } + if (headers) { + g_object_set(source, "extra-headers", headers, nullptr); + gst_structure_free(headers); + } + QGC::secureZero(authorization); + + g_object_set(demux, "single-stream", TRUE, nullptr); + if (!installHttpJpegGuards(demux, parser)) { + gst_clear_object(&bin); + return nullptr; + } + if (!gst_element_link(source, demux)) { + qCWarning(GstSourceFactoryLog) << "Failed to link HTTP MJPEG source"; + gst_clear_object(&bin); + return nullptr; + } + (void) g_signal_connect(demux, "pad-added", G_CALLBACK(linkPadToElement), parser); + if (!addStaticGhostPad(parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to expose HTTP MJPEG source pad"; + gst_clear_object(&bin); + return nullptr; + } + + return bin; +} + +#ifdef QGC_HAS_WEBSOCKET_VIDEO +class WebSocketSourceContext +{ +public: + WebSocketSourceContext(QGCWebSocketVideoSource* source, QThread* thread) : _source(source), _thread(thread) {} + + ~WebSocketSourceContext() { stop(); } + + bool start(QString& error) + { + if (!_source || !_thread) { + error = QStringLiteral("WebSocket source context is not initialized."); + return false; + } + + _thread->setObjectName(QStringLiteral("QGCWebSocketVideo")); + _source->moveToThread(_thread); + QObject::connect(_thread, &QThread::finished, _source.data(), &QObject::deleteLater); + _thread->start(); + + struct StartResult + { + QSemaphore completed; + bool started = false; + QString error; + }; + + const auto result = std::make_shared(); + const bool invoked = QMetaObject::invokeMethod( + _source.data(), + [source = _source, result]() { + if (!source) { + result->error = QStringLiteral("WebSocket source was destroyed before start."); + result->completed.release(); + return; + } + result->started = source->start(result->error); + result->completed.release(); + }, + Qt::QueuedConnection); + if (!invoked) { + error = QStringLiteral("Failed to invoke WebSocket source start."); + stop(); + return false; + } + + const bool completed = result->completed.tryAcquire(1, kWebSocketThreadOperationTimeoutMs); + if (!completed) { + error = QStringLiteral("Timed out starting the WebSocket source."); + stop(); + return false; + } + if (!result->started) { + error = result->error.isEmpty() ? QStringLiteral("Failed to start the WebSocket source.") : result->error; + stop(); + return false; + } + + return true; + } + +private: + void stop() + { + const QPointer source = _source; + QThread* thread = _thread; + _source.clear(); + _thread = nullptr; + + if (thread && thread->isRunning()) { + bool invoked = false; + if (source) { + invoked = QMetaObject::invokeMethod( + source.data(), + [source, thread]() { + if (source) { + source->stop(); + } + thread->quit(); + }, + Qt::QueuedConnection); + } + if (!invoked) { + thread->quit(); + } + if (!thread->wait(kWebSocketThreadOperationTimeoutMs)) { + qCCritical(GstSourceFactoryLog) << "WebSocket video thread did not stop; requesting interruption"; + thread->requestInterruption(); + thread->quit(); + if (!thread->wait(kWebSocketThreadOperationTimeoutMs)) { + qCCritical(GstSourceFactoryLog) + << "WebSocket video thread remained stuck; preserving it rather than terminating unsafely"; + } + } + } + + if (thread && thread->isRunning()) { + qCCritical(GstSourceFactoryLog) << "WebSocket video thread could not be reclaimed; leaking guarded context"; + return; + } + if (source) { + delete source.data(); + } + delete thread; + } + + QPointer _source; + QThread* _thread = nullptr; +}; + +GstElement* buildWebSocketJpegSource(const QUrl& sourceUrl, const Config& config) +{ + const VideoReceiver::NetworkSourceConfig& networkConfig = config.networkSourceConfig; + if (networkConfig.hasAuthentication() && sourceUrl.scheme() != QStringLiteral("wss")) { + qCWarning(GstSourceFactoryLog) << "Authenticated WebSocket JPEG video requires WSS"; + return nullptr; + } + if (!networkConfig.caCertificateFile.isEmpty() && sourceUrl.scheme() != QStringLiteral("wss")) { + qCWarning(GstSourceFactoryLog) << "WebSocket JPEG custom CA configuration requires WSS"; + return nullptr; + } + + GstElement* appsrc = gst_element_factory_make("appsrc", "source"); + GstElement* parser = gst_element_factory_make("jpegparse", "jpeg-parser"); + GstElement* bin = gst_bin_new("sourcebin"); + if (!appsrc || !parser || !bin) { + qCWarning(GstSourceFactoryLog) << "Required WebSocket JPEG GStreamer element is unavailable"; + gst_clear_object(&appsrc); + gst_clear_object(&parser); + gst_clear_object(&bin); + return nullptr; + } + + GstCaps* caps = gst_caps_from_string("image/jpeg"); + g_object_set(appsrc, "caps", caps, "is-live", TRUE, "do-timestamp", TRUE, "format", GST_FORMAT_TIME, "block", FALSE, + "max-buffers", static_cast(4), "max-bytes", + static_cast(QGCJpegStreamGuard::kMaximumEncodedBytes * 4), "leaky-type", + GST_APP_LEAKY_TYPE_DOWNSTREAM, nullptr); + gst_clear_caps(&caps); + + gst_bin_add_many(GST_BIN(bin), appsrc, parser, nullptr); + if (!gst_element_link(appsrc, parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to link WebSocket JPEG source"; + gst_clear_object(&bin); + return nullptr; + } + if (!addStaticGhostPad(parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to expose WebSocket JPEG source pad"; + gst_clear_object(&bin); + return nullptr; + } + + auto* context = + new WebSocketSourceContext(new QGCWebSocketVideoSource(sourceUrl, networkConfig, appsrc), new QThread); + QString startError; + if (!context->start(startError)) { + qCWarning(GstSourceFactoryLog) << "Failed to start WebSocket JPEG source:" << startError; + delete context; + gst_clear_object(&bin); + return nullptr; + } + g_object_set_data_full(G_OBJECT(bin), "qgc-websocket-source-context", context, + [](gpointer p) { delete static_cast(p); }); + + return bin; +} +#endif + // Wire upstream → (optional rtpjitterbuffer) → binParser, topology chosen by RTP probe (MPEG-TS // links via pad-added). Created elements join @p bin; returns false (logged) on failure. bool linkSourceToParser(GstElement* bin, GstElement* upstream, GstElement* binParser, const Config& config, @@ -503,12 +956,27 @@ GstElement* create(const QString& uri, const Config& config) const bool isUdpH265 = (scheme == QLatin1String("udp265")); const bool isUdpMPEGTS = (scheme == QLatin1String("mpegts")); const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); + const bool isHttpMjpeg = (scheme == QLatin1String("http") || scheme == QLatin1String("https")); + const bool isWebSocketJpeg = (scheme == QLatin1String("ws") || scheme == QLatin1String("wss")); - if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS) { - qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg && !isWebSocketJpeg) { + qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } + if (isHttpMjpeg) { + return buildHttpMjpegSource(sourceUrl, config); + } + if (isWebSocketJpeg) { +#ifdef QGC_HAS_WEBSOCKET_VIDEO + return buildWebSocketJpegSource(sourceUrl, config); +#else + qCWarning(GstSourceFactoryLog) << "WebSocket JPEG support is unavailable in this build"; + return nullptr; +#endif + } + // Owning locals until gst_bin_add*, then nulled (non-owning alias used downstream) so the // unconditional gst_clear_object cleanup at the bottom stays safe. GstElement* source = nullptr; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h index 00c12ed127b5..a021f955fe4d 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h @@ -1,8 +1,12 @@ #pragma once +#include + #include #include +#include "VideoReceiver.h" + namespace GStreamer::SourceFactory { /// RTP jitter-buffer policy for sources that produce `application/x-rtp` caps. @@ -22,11 +26,14 @@ struct Config JitterBuffer jitterBuffer = JitterBuffer::DropOnLatency; int latencyMs = 80; bool doRetransmission = true; + uint32_t timeoutS = 0; + VideoReceiver::NetworkSourceConfig networkSourceConfig; }; /// Build a source bin (`source` [+ `tsdemux`] [+ `rtpjitterbuffer`] + `parsebin`) /// for `uri`. Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), -/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP). +/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP), http(s):// (multipart MJPEG), +/// ws(s):// (JPEG binary messages, when Qt WebSockets is available). /// /// Ghost pads on the returned bin are wired lazily; for `rtspsrc`/`tsdemux`/`parsebin` /// they appear only after upstream produces pads, so callers must connect any diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 2b376b42e4ee..224bfa5b989f 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -15,6 +15,7 @@ #include "GStreamerHelpers.h" #include "GstSourceFactory.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" #include "QGCQVideoSinkController.h" #include @@ -32,6 +33,36 @@ QGC_LOGGING_CATEGORY(GstVideoReceiverLog, "Video.GStreamer.GstVideoReceiver") namespace { // kEosTimeoutNs: bus wait budget for EOS/ERROR during stop(); 3 s covers slow hw decoders. constexpr GstClockTime kEosTimeoutNs = 3 * GST_SECOND; +constexpr char kRecordingSinkBinName[] = "recording-sink-bin"; +constexpr char kRecordingSplitMuxName[] = "recording-splitmux"; + +struct PipelineBusContext +{ + PipelineBusContext(GstVideoReceiver* receiver_, const QString& uriForLogging_, bool redactDiagnostics_, + quint64 generation_, GstElement* pipeline_) + : receiver(receiver_) + , uriForLogging(uriForLogging_) + , redactDiagnostics(redactDiagnostics_) + , generation(generation_) + { + g_weak_ref_init(&pipeline, G_OBJECT(pipeline_)); + } + + ~PipelineBusContext() { g_weak_ref_clear(&pipeline); } + + GstElement* acquirePipeline() { return GST_ELEMENT(g_weak_ref_get(&pipeline)); } + + GstVideoReceiver* receiver = nullptr; + QString uriForLogging; + bool redactDiagnostics = false; + quint64 generation = 0; + GWeakRef pipeline = {}; +}; + +void destroyPipelineBusContext(gpointer data, GClosure*) +{ + delete static_cast(data); +} // Refs the element's first src pad into *userData and stops iterating. Resync is handled // internally by gst_element_foreach_src_pad (unlike a bare gst_iterator_next loop). @@ -57,6 +88,23 @@ bool isRecoverableH265PaciError(GstMessage *msg, const GError *error, const gcha return factory && (g_strcmp0(GST_OBJECT_NAME(factory), "rtph265depay") == 0); } +bool isRecordingBranchMessage(GstMessage* message) +{ + GstObject* current = + message && GST_MESSAGE_SRC(message) ? GST_OBJECT(gst_object_ref(GST_MESSAGE_SRC(message))) : nullptr; + while (current) { + const char* name = GST_OBJECT_NAME(current); + if ((g_strcmp0(name, kRecordingSinkBinName) == 0) || (g_strcmp0(name, kRecordingSplitMuxName) == 0)) { + gst_object_unref(current); + return true; + } + GstObject* parent = gst_object_get_parent(current); + gst_object_unref(current); + current = parent; + } + return false; +} + } // namespace GstVideoReceiver::GstVideoReceiver(QObject *parent) @@ -77,6 +125,24 @@ GstVideoReceiver::~GstVideoReceiver() qCDebug(GstVideoReceiverLog) << this; } +QString GstVideoReceiver::_uriForLogging() const +{ + return QGCNetworkHelper::redactedUrlForLogging(uri()); +} + +bool GstVideoReceiver::_isJpegNetworkSource(const QString& uri) +{ + const QString scheme = QUrl(uri).scheme().toLower(); + return scheme == QStringLiteral("http") || scheme == QStringLiteral("https") || + scheme == QStringLiteral("ws") || scheme == QStringLiteral("wss"); +} + +bool GstVideoReceiver::_mustRedactPipelineDiagnostics(const QString& uri) +{ + const QUrl url(uri); + return _isJpegNetworkSource(uri) || !url.userInfo().isEmpty() || url.hasQuery(); +} + void GstVideoReceiver::start(uint32_t timeout) { if (_needDispatch()) { @@ -85,21 +151,27 @@ void GstVideoReceiver::start(uint32_t timeout) } if (_pipeline) { - qCDebug(GstVideoReceiverLog) << "Already running!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already running!" << _uriForLogging(); emit onStartComplete(STATUS_INVALID_STATE); return; } - if (_uri.isEmpty()) { + const QString pipelineUri = uri(); + if (pipelineUri.isEmpty()) { qCDebug(GstVideoReceiverLog) << "Failed because URI is not specified"; emit onStartComplete(STATUS_INVALID_URL); return; } + const QString pipelineUriForLogging = QGCNetworkHelper::redactedUrlForLogging(pipelineUri); + const bool redactPipelineDiagnostics = _mustRedactPipelineDiagnostics(pipelineUri); + const quint64 pipelineGeneration = _pipelineGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; + _recordingEosSeqnum.store(GST_SEQNUM_INVALID, std::memory_order_release); _timeout = timeout; _buffer = lowLatency() ? -1 : 0; - qCDebug(GstVideoReceiverLog) << "Starting" << _uri << ", lowLatency" << lowLatency() << ", timeout" << _timeout; + qCDebug(GstVideoReceiverLog) << "Starting" << pipelineUriForLogging << ", lowLatency" << lowLatency() + << ", timeout" << _timeout; // GST_DEBUG_BIN_TO_DOT_FILE is a no-op unless GST_DEBUG_DUMP_DOT_DIR is set; surface that // once per process so field debugging doesn't require re-reading the source. @@ -111,7 +183,7 @@ void GstVideoReceiver::start(uint32_t timeout) return true; }(); - _endOfStream = false; + _endOfStream.store(false, std::memory_order_release); bool running = false; bool pipelineUp = false; @@ -134,11 +206,17 @@ void GstVideoReceiver::start(uint32_t timeout) _lastSourceFrameTime = 0; - _teeProbeId = gst_pad_add_probe(pad, GST_PAD_PROBE_TYPE_BUFFER, _teeProbe, this, nullptr); + // This mandatory probe belongs to the source pipeline, not either removable branch. + // Besides the frame heartbeat, it marks source-originated EOS before GstBin creates + // aggregate EOS messages whose source and sequence number no longer identify origin. + _sourceProbeId = gst_pad_add_probe( + pad, static_cast(GST_PAD_PROBE_TYPE_BUFFER | GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM), + _sourceProbe, this, nullptr); gst_clear_object(&pad); - if (_teeProbeId == 0) { - // _teeProbe updates _lastSourceFrameTime; without it the watchdog timer fires spuriously instead of reporting a real failure. - qCCritical(GstVideoReceiverLog) << "gst_pad_add_probe(_teeProbe) failed"; + if (_sourceProbeId == 0) { + // Without this probe, the watchdog fires spuriously and aggregate branch EOS + // cannot be distinguished safely from a real source ending. + qCCritical(GstVideoReceiverLog) << "gst_pad_add_probe(_sourceProbe) failed"; break; } @@ -184,11 +262,15 @@ void GstVideoReceiver::start(uint32_t timeout) "drop", TRUE, nullptr); - _pipeline = gst_pipeline_new("receiver"); - if (!_pipeline) { + GstElement* newPipeline = gst_pipeline_new("receiver"); + if (!newPipeline) { qCCritical(GstVideoReceiverLog) << "gst_pipeline_new() failed"; break; } + { + QMutexLocker lock(&_pipelineMutex); + _pipeline = newPipeline; + } g_object_set(_pipeline, "message-forward", TRUE, @@ -204,7 +286,10 @@ void GstVideoReceiver::start(uint32_t timeout) // do-retransmission needs ≥40 ms latency headroom over the default 20 ms rtx-delay; // forcibly disable for sub-frame latency configurations to avoid retransmit storms. sourceConfig.doRetransmission = (_rtpJitterLatencyMs >= 40) && (sourceConfig.jitterBuffer != GStreamer::SourceFactory::JitterBuffer::None); - _source = GStreamer::SourceFactory::create(_uri, sourceConfig); + sourceConfig.timeoutS = _timeout; + sourceConfig.networkSourceConfig = networkSourceConfig(); + _source = GStreamer::SourceFactory::create(pipelineUri, sourceConfig); + sourceConfig.networkSourceConfig.clearSecret(); if (!_source) { qCCritical(GstVideoReceiverLog) << "SourceFactory::create() failed"; break; @@ -218,7 +303,7 @@ void GstVideoReceiver::start(uint32_t timeout) (void) gst_element_foreach_src_pad(_source, grabFirstSrcPad, &srcPad); if (srcPad) { - _onNewSourcePad(srcPad); + _onNewSourcePad(); gst_clear_object(&srcPad); } else { (void) g_signal_connect(_source, "pad-added", G_CALLBACK(_onNewPad), this); @@ -237,25 +322,44 @@ void GstVideoReceiver::start(uint32_t timeout) GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(_pipeline)); if (bus) { gst_bus_enable_sync_message_emission(bus); - (void) g_signal_connect(bus, "sync-message", G_CALLBACK(_onBusMessage), this); + auto* busContext = new PipelineBusContext(this, pipelineUriForLogging, redactPipelineDiagnostics, + pipelineGeneration, _pipeline); + const gulong busHandler = g_signal_connect_data(bus, "sync-message", G_CALLBACK(_onBusMessage), + busContext, destroyPipelineBusContext, + static_cast(0)); + if (busHandler == 0) { + delete busContext; + qCCritical(GstVideoReceiverLog) << "Failed to connect the pipeline bus handler"; + gst_clear_object(&bus); + break; + } + _busHandlerId = busHandler; // HwBuffers facade chains every compiled context bridge so they don't clobber each // other via gst_bus_set_sync_handler. Must run before GST_STATE_PLAYING — upstream // queries context during PAUSED→PLAYING. No-op when no bridge-using GPU path is compiled. gst_bus_set_sync_handler(bus, HwBuffers::onBusSyncMessage, nullptr, nullptr); gst_clear_object(&bus); + } else { + qCCritical(GstVideoReceiverLog) << "gst_pipeline_get_bus() failed"; + break; } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-initial"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-initial"); running = (gst_element_set_state(_pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); } while(0); if (!running) { qCCritical(GstVideoReceiverLog) << "Failed"; + _pipelineGeneration.fetch_add(1, std::memory_order_acq_rel); + _activePipelineIsJpegNetworkSource = false; + _removeSourceProbe(); if (_pipeline) { (void) gst_element_set_state(_pipeline, GST_STATE_NULL); (void) gst_element_get_state(_pipeline, nullptr, nullptr, GST_CLOCK_TIME_NONE); + QMutexLocker lock(&_pipelineMutex); gst_clear_object(&_pipeline); + _busHandlerId = 0; } if (!pipelineUp) { @@ -269,8 +373,9 @@ void GstVideoReceiver::start(uint32_t timeout) emit onStartComplete(STATUS_FAIL); } else { - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-started"); - qCDebug(GstVideoReceiverLog) << "Started" << _uri; + _activePipelineIsJpegNetworkSource = _isJpegNetworkSource(pipelineUri); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-started"); + qCDebug(GstVideoReceiverLog) << "Started" << pipelineUriForLogging; // _watchdogTimer lives on `this` (GUI thread); the emit runs synchronously on the // worker thread, so the timer start has to be queued separately or QObject warns. @@ -286,12 +391,10 @@ void GstVideoReceiver::stop() return; } - if (_uri.isEmpty()) { - qCDebug(GstVideoReceiverLog) << "Stop called on empty URI (no-op)"; - return; - } + qCDebug(GstVideoReceiverLog) << "Stopping" << _uriForLogging(); - qCDebug(GstVideoReceiverLog) << "Stopping" << _uri; + _pipelineGeneration.fetch_add(1, std::memory_order_acq_rel); + _activePipelineIsJpegNetworkSource = false; // Bump the epoch synchronously (atomic — no GUI thread needed) so any in-flight reconnect lambda // is superseded before this stop() returns; cross-callsite QueuedConnection FIFO is not guaranteed. @@ -299,22 +402,16 @@ void GstVideoReceiver::stop() // Only _watchdogTimer.stop() must run on the GUI thread (the timer lives on `this`). QMetaObject::invokeMethod(this, [this]() { _watchdogTimer.stop(); }, Qt::QueuedConnection); - if (_teeProbeId != 0) { - if (_tee) { - GstPad *sinkpad = gst_element_get_static_pad(_tee, "sink"); - if (sinkpad) { - gst_pad_remove_probe(sinkpad, _teeProbeId); - gst_clear_object(&sinkpad); - } - } - _teeProbeId = 0; - } + _removeSourceProbe(); if (_pipeline) { GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(_pipeline)); if (bus) { gst_bus_disable_sync_message_emission(bus); - (void) g_signal_handlers_disconnect_by_data(bus, this); + if (_busHandlerId != 0) { + g_signal_handler_disconnect(bus, _busHandlerId); + _busHandlerId = 0; + } gboolean recordingValveClosed = TRUE; g_object_get(_recorderValve, "drop", &recordingValveClosed, nullptr); @@ -324,13 +421,15 @@ void GstVideoReceiver::stop() // Wait for splitmuxsink to actually finalize its current fragment. async-finalize // pushes muxer teardown off the streaming thread; the splitmuxsink-fragment-closed - // element message is posted (via message-forward=TRUE) exactly when the muxer's - // state has gone NULL. EOS is the fallback for older builds / unexpected paths; + // element message is posted exactly when the muxer's state has gone NULL. A source + // disconnect can post aggregate pipeline EOS before that asynchronous close has + // completed, so EOS is not proof that the recording file is ready to consume. // ERROR breaks out so we don't burn the full budget on a known failure. Track - // elapsed time so unrelated ELEMENT messages don't abort the wait early. + // elapsed time so unrelated ELEMENT/EOS messages don't abort the wait early. const GstClockTime deadline = kEosTimeoutNs; const qint64 startMs = QDateTime::currentMSecsSinceEpoch(); bool finalized = false; + bool finalizationError = false; for (;;) { const qint64 elapsedNs = (QDateTime::currentMSecsSinceEpoch() - startMs) * qint64(GST_MSECOND); @@ -349,24 +448,30 @@ void GstVideoReceiver::stop() break; } case GST_MESSAGE_EOS: - qCDebug(GstVideoReceiverLog) << "End of stream received (fallback path)"; - finalized = true; + qCDebug(GstVideoReceiverLog) + << "Pipeline EOS received while waiting for recording fragment finalization"; break; case GST_MESSAGE_ERROR: qCCritical(GstVideoReceiverLog) << "Error stopping pipeline!"; - finalized = true; + finalizationError = true; break; default: break; } gst_clear_message(&msg); - if (finalized) break; + if (finalized || finalizationError) break; } if (!finalized) { - qCWarning(GstVideoReceiverLog) << "splitmuxsink finalize signal not received within" - << (kEosTimeoutNs / GST_MSECOND) - << "ms — forcing pipeline NULL (recording may be truncated; " - << "faststart + reserved-moov-update-period keep the file playable)"; + if (finalizationError) { + qCWarning(GstVideoReceiverLog) + << "Recording fragment finalization aborted after a pipeline error; " + "the file may be incomplete"; + } else { + qCWarning(GstVideoReceiverLog) << "splitmuxsink finalize signal not received within" + << (kEosTimeoutNs / GST_MSECOND) + << "ms — forcing pipeline NULL (recording may be truncated; " + << "faststart + reserved-moov-update-period keep the file playable)"; + } } } @@ -387,7 +492,7 @@ void GstVideoReceiver::stop() _shutdownDecodingBranch(); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-stopped"); // Lock before nulling so an in-flight _onBusMessage on the streaming thread cannot read // a half-destroyed _pipeline. _acquirePipelineRef takes its own ref under the same lock. @@ -406,18 +511,18 @@ void GstVideoReceiver::stop() if (_streaming) { _streaming = false; - qCDebug(GstVideoReceiverLog) << "Streaming stopped" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming stopped" << _uriForLogging(); emit streamingChanged(_streaming); } else { - qCDebug(GstVideoReceiverLog) << "Streaming did not start" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming did not start" << _uriForLogging(); } } - qCDebug(GstVideoReceiverLog) << "Stopped" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopped" << _uriForLogging(); if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(true); hwStats.totalDelivered > 0) { qCInfo(GstVideoReceiverLog).noquote() - << "HW path stats" << _uri << hwStats.line + HwBuffers::takeExtraPathStats(); + << "HW path stats" << _uriForLogging() << hwStats.line + HwBuffers::takeExtraPathStats(); } emit onStopComplete(STATUS_OK); @@ -426,7 +531,7 @@ void GstVideoReceiver::stop() void GstVideoReceiver::startDecoding(void *sink) { if (!sink) { - qCCritical(GstVideoReceiverLog) << "VideoSink is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "VideoSink is NULL" << _uriForLogging(); return; } @@ -435,10 +540,10 @@ void GstVideoReceiver::startDecoding(void *sink) return; } - qCDebug(GstVideoReceiverLog) << "Starting decoding" << _uri; + qCDebug(GstVideoReceiverLog) << "Starting decoding" << _uriForLogging(); if (!_widget) { - qCDebug(GstVideoReceiverLog) << "Video Widget is NULL" << _uri; + qCDebug(GstVideoReceiverLog) << "Video Widget is NULL" << _uriForLogging(); emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -448,7 +553,7 @@ void GstVideoReceiver::startDecoding(void *sink) } if (_videoSink || _decoding) { - qCDebug(GstVideoReceiverLog) << "Already decoding!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already decoding!" << _uriForLogging(); emit onStartDecodingComplete(STATUS_INVALID_STATE); return; } @@ -456,7 +561,7 @@ void GstVideoReceiver::startDecoding(void *sink) GstElement *videoSink = GST_ELEMENT(sink); GstPad *pad = gst_element_get_static_pad(videoSink, "sink"); if (!pad) { - qCCritical(GstVideoReceiverLog) << "Unable to find sink pad of video sink" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to find sink pad of video sink" << _uriForLogging(); emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -480,7 +585,7 @@ void GstVideoReceiver::startDecoding(void *sink) _ensureVideoSinkInPipeline(); if (!_addDecoder(_decoderValve)) { - qCCritical(GstVideoReceiverLog) << "_addDecoder() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "_addDecoder() failed" << _uriForLogging(); _shutdownDecodingBranch(); emit onStartDecodingComplete(STATUS_FAIL); return; @@ -490,7 +595,7 @@ void GstVideoReceiver::startDecoding(void *sink) "drop", FALSE, nullptr); - qCDebug(GstVideoReceiverLog) << "Decoding started" << _uri; + qCDebug(GstVideoReceiverLog) << "Decoding started" << _uriForLogging(); emit onStartDecodingComplete(STATUS_OK); } @@ -502,14 +607,14 @@ void GstVideoReceiver::stopDecoding() return; } - qCDebug(GstVideoReceiverLog) << "Stopping decoding" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopping decoding" << _uriForLogging(); // Gate on _videoSink (set by startDecoding) instead of _decoding (which only flips on // first sink-buffer probe). Without this, stopDecoding() called between // onStartDecodingComplete(OK) and the first frame returns STATUS_INVALID_STATE and // leaves the decoder/sink branch live. if (!_pipeline || !_videoSink) { - qCDebug(GstVideoReceiverLog) << "Not decoding!" << _uri; + qCDebug(GstVideoReceiverLog) << "Not decoding!" << _uriForLogging(); emit onStopDecodingComplete(STATUS_INVALID_STATE); return; } @@ -535,25 +640,32 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form return; } - qCDebug(GstVideoReceiverLog) << "Starting recording" << _uri; + qCDebug(GstVideoReceiverLog) << "Starting recording" << _uriForLogging(); if (!_pipeline) { - qCDebug(GstVideoReceiverLog) << "Streaming is not active!" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming is not active!" << _uriForLogging(); emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } if (_recording) { - qCDebug(GstVideoReceiverLog) << "Already recording!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already recording!" << _uriForLogging(); emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } - qCDebug(GstVideoReceiverLog) << "New video file:" << videoFile << _uri; + if (_activePipelineIsJpegNetworkSource && format == FILE_FORMAT_MP4) { + qCWarning(GstVideoReceiverLog) + << "MP4 recording is unavailable for HTTP MJPEG and WebSocket JPEG sources; use MKV or MOV"; + emit onStartRecordingComplete(STATUS_NOT_IMPLEMENTED); + return; + } + + qCDebug(GstVideoReceiverLog) << "New video file:" << videoFile << _uriForLogging(); _fileSink = _makeFileSink(videoFile, format); if (!_fileSink) { - qCCritical(GstVideoReceiverLog) << "_makeFileSink() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "_makeFileSink() failed" << _uriForLogging(); emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -565,35 +677,36 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form gst_bin_add(GST_BIN(_pipeline), _fileSink); if (!gst_element_link(_recorderValve, _fileSink)) { - qCCritical(GstVideoReceiverLog) << "Failed to link valve and file sink" << _uri; + qCCritical(GstVideoReceiverLog) << "Failed to link valve and file sink" << _uriForLogging(); emit onStartRecordingComplete(STATUS_FAIL); return; } (void) gst_element_sync_state_with_parent(_fileSink); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-filesink"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-with-filesink"); // Install a probe on the recording branch to drop buffers until we hit our first keyframe // When we hit our first keyframe, we can offset the timestamps appropriately according to the first keyframe time // This will ensure the first frame is a keyframe at t=0, and decoding can begin immediately on playback GstPad *probepad = gst_element_get_static_pad(_recorderValve, "src"); if (!probepad) { - qCCritical(GstVideoReceiverLog) << "gst_element_get_static_pad() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "gst_element_get_static_pad() failed" << _uriForLogging(); emit onStartRecordingComplete(STATUS_FAIL); return; } - _keyframeWatchId = gst_pad_add_probe(probepad, GST_PAD_PROBE_TYPE_BUFFER, _keyframeWatch, this, nullptr); + _keyframeWatchId.store(gst_pad_add_probe(probepad, GST_PAD_PROBE_TYPE_BUFFER, _keyframeWatch, this, nullptr), + std::memory_order_release); gst_clear_object(&probepad); + _setRecordingOutput(videoFile); g_object_set(_recorderValve, "drop", FALSE, nullptr); - _recordingOutput = videoFile; _recording = true; - qCDebug(GstVideoReceiverLog) << "Recording started" << _uri; + qCDebug(GstVideoReceiverLog) << "Recording started" << _uriForLogging(); emit onStartRecordingComplete(STATUS_OK); emit recordingChanged(_recording); } @@ -605,10 +718,10 @@ void GstVideoReceiver::stopRecording() return; } - qCDebug(GstVideoReceiverLog) << "Stopping recording" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopping recording" << _uriForLogging(); if (!_pipeline || !_recording) { - qCDebug(GstVideoReceiverLog) << "Not recording!" << _uri; + qCDebug(GstVideoReceiverLog) << "Not recording!" << _uriForLogging(); emit onStopRecordingComplete(STATUS_INVALID_STATE); return; } @@ -618,16 +731,17 @@ void GstVideoReceiver::stopRecording() nullptr); _removingRecorder = true; + _recordingStopRequested = true; + const guint32 recordingEosSeqnum = gst_util_seqnum_next(); + _recordingEosSeqnum.store(recordingEosSeqnum, std::memory_order_release); - if (!_unlinkBranch(_recorderValve)) { + if (!_unlinkBranch(_recorderValve, recordingEosSeqnum)) { + _recordingEosSeqnum.store(GST_SEQNUM_INVALID, std::memory_order_release); + _recordingStopRequested = false; _removingRecorder = false; emit onStopRecordingComplete(STATUS_FAIL); return; } - - // EOS event propagates valve→mux→filesink; _shutdownRecordingBranch emits the - // complete signal once the muxer index is written and the file is closed. - _recordingStopRequested = true; } void GstVideoReceiver::takeScreenshot(const QString &imageFile) @@ -638,7 +752,7 @@ void GstVideoReceiver::takeScreenshot(const QString &imageFile) return; } - qCDebug(GstVideoReceiverLog) << "taking screenshot" << _uri; + qCDebug(GstVideoReceiverLog) << "taking screenshot" << _uriForLogging(); // FIXME: record screenshot here emit onTakeScreenshotComplete(STATUS_NOT_IMPLEMENTED); @@ -661,7 +775,7 @@ void GstVideoReceiver::_watchdog() if (++_statsTickCounter >= 10) { _statsTickCounter = 0; if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(false); hwStats.totalDelivered > 0) { - qCDebug(GstVideoReceiverLog).noquote() << "HW path live" << _uri << hwStats.line; + qCDebug(GstVideoReceiverLog).noquote() << "HW path live" << _uriForLogging() << hwStats.line; } } @@ -672,8 +786,8 @@ void GstVideoReceiver::_watchdog() qint64 elapsed = now - lastSourceFrameTime; if (elapsed > _timeout) { - qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _uri; - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); + qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _uriForLogging(); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("source watchdog"); return; @@ -688,8 +802,8 @@ void GstVideoReceiver::_watchdog() elapsed = now - lastVideoFrameTime; if (elapsed > (_timeout * 2)) { - qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _uri; - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); + qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _uriForLogging(); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("decoder watchdog"); } @@ -708,19 +822,20 @@ void GstVideoReceiver::_scheduleReconnect(const char *reason) return; } - if (_uri.isEmpty()) { + const QString uri = this->uri(); + if (uri.isEmpty()) { return; } - // Snapshot on the worker thread — where start() last wrote _timeout and where _uri reads - // are already sequenced — so the GUI-thread lambdas below don't read racy members. + // Snapshot on the worker thread so the GUI-thread lambdas below use one + // reconnect decision even if settings change before the timer fires. const uint32_t reconnectTimeout = (_timeout != 0) ? _timeout : 8; - const QString uri = _uri; + const QString uriForLogging = QGCNetworkHelper::redactedUrlForLogging(uri); // Schedule on the GUI thread (QTimer::singleShot requires its receiver's thread). Worker // is the only caller today, but route through invokeMethod so a future direct GUI-thread // call (e.g. user-initiated retry) stays correct. - QMetaObject::invokeMethod(this, [this, reason, reconnectTimeout, uri]() { + QMetaObject::invokeMethod(this, [this, reason, reconnectTimeout, uri, uriForLogging]() { const int next = std::min(_reconnectAttempts.load(std::memory_order_relaxed) + 1, 30); _reconnectAttempts.store(next, std::memory_order_relaxed); // 1s → 2s → 4s → 8s → 16s, capped at 30s. Capping bounds worst-case "vehicle in flight, @@ -729,8 +844,9 @@ void GstVideoReceiver::_scheduleReconnect(const char *reason) const quint64 epoch = _reconnectEpoch.load(std::memory_order_relaxed); const int attempts = next; qCInfo(GstVideoReceiverLog) << "Scheduling reconnect #" << attempts - << "in" << delaySec << "s after" << reason << uri; - QTimer::singleShot(delaySec * 1000, this, [this, epoch, attempts, reconnectTimeout, uri]() { + << "in" << delaySec << "s after" << reason << uriForLogging; + QTimer::singleShot(delaySec * 1000, this, + [this, epoch, attempts, reconnectTimeout, uri, uriForLogging]() { if (epoch != _reconnectEpoch.load(std::memory_order_relaxed)) return; // superseded by stop() // _pipeline is mutated by the worker under _pipelineMutex; a bare deref here (GUI // thread) races teardown, so probe liveness through the mutex-guarded accessor. @@ -738,7 +854,7 @@ void GstVideoReceiver::_scheduleReconnect(const char *reason) const bool pipelineUp = (livePipeline != nullptr); if (livePipeline) gst_object_unref(livePipeline); if (uri.isEmpty() || pipelineUp) return; // pipeline already came back - qCInfo(GstVideoReceiverLog) << "Reconnecting (attempt" << attempts << ")" << uri; + qCInfo(GstVideoReceiverLog) << "Reconnecting (attempt" << attempts << ")" << uriForLogging; start(reconnectTimeout); }); }, Qt::QueuedConnection); @@ -753,7 +869,7 @@ void GstVideoReceiver::dumpPipelineGraph(const QString &tag) return; } const QByteArray tagUtf8 = tag.toUtf8(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, tagUtf8.constData()); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GStreamer::kSafePipelineGraphDetails, tagUtf8.constData()); const QString dotPath = GStreamer::writePipelineDot(pipelineRef, tagUtf8.constData()); if (!dotPath.isEmpty()) { qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; @@ -768,16 +884,49 @@ void GstVideoReceiver::_handleEOS() return; } - if (_endOfStream) { + if (_endOfStream.load(std::memory_order_acquire)) { stop(); } else if (_decoding && _removingDecoder) { _shutdownDecodingBranch(); } else if (_recording && _removingRecorder) { _shutdownRecordingBranch(); - } /*else { + } else { qCWarning(GstVideoReceiverLog) << "Unexpected EOS!"; - stop(); - }*/ + _scheduleReconnect("unexpected EOS"); + } +} + +bool GstVideoReceiver::_isRecordingEOSMessage(GstMessage *message) const +{ + const guint32 recordingEosSeqnum = _recordingEosSeqnum.load(std::memory_order_acquire); + return message && (isRecordingBranchMessage(message) || ((recordingEosSeqnum != GST_SEQNUM_INVALID) && + (gst_message_get_seqnum(message) == recordingEosSeqnum))); +} + +void GstVideoReceiver::_handleBusEOS(bool recordingEOS, bool directPipelineEOS) +{ + if (recordingEOS) { + if (_recording && _removingRecorder) { + qCDebug(GstVideoReceiverLog) << "Received recording branch EOS"; + _shutdownRecordingBranch(); + } else { + qCDebug(GstVideoReceiverLog) << "Ignoring duplicate recording branch EOS"; + } + return; + } + + // GstBin creates a fresh aggregate EOS message after a child sink posts EOS, so the + // branch event seqnum is not preserved on this direct message. The mandatory + // source-lifetime probe marks real source EOS first; an uncorrelated aggregate must not + // reconnect a still-flowing source. Forwarded child EOS remains actionable because it + // can report an unexpected decoder termination. + if (directPipelineEOS && !_endOfStream.load(std::memory_order_acquire) && !(_decoding && _removingDecoder) && + !(_recording && _removingRecorder)) { + qCDebug(GstVideoReceiverLog) << "Ignoring aggregate EOS without source-probe EOS"; + return; + } + + _handleEOS(); } GstElement *GstVideoReceiver::_makeDecoder() @@ -807,7 +956,7 @@ GstElement *GstVideoReceiver::_makeFileSink(const QString &videoFile, FILE_FORMA // lifetime, and finalizes asynchronously so EOS no longer wedges the worker // thread (replaces the manual qtmux/matroskamux+filesink combo + "stuck muxer" // bounded-wait in stop()). max-size-time=0 keeps single-file behaviour. - splitmux = gst_element_factory_make("splitmuxsink", nullptr); + splitmux = gst_element_factory_make("splitmuxsink", kRecordingSplitMuxName); if (!splitmux) { qCCritical(GstVideoReceiverLog) << "gst_element_factory_make('splitmuxsink') failed"; break; @@ -838,9 +987,9 @@ GstElement *GstVideoReceiver::_makeFileSink(const QString &videoFile, FILE_FORMA gst_structure_free(muxerProps); } - bin = gst_bin_new("sinkbin"); + bin = gst_bin_new(kRecordingSinkBinName); if (!bin) { - qCCritical(GstVideoReceiverLog) << "gst_bin_new('sinkbin') failed"; + qCCritical(GstVideoReceiverLog) << "gst_bin_new('recording-sink-bin') failed"; break; } @@ -884,7 +1033,7 @@ GstElement *GstVideoReceiver::_makeFileSink(const QString &videoFile, FILE_FORMA return fileSink; } -void GstVideoReceiver::_onNewSourcePad(GstPad *pad) +void GstVideoReceiver::_onNewSourcePad() { // FIXME: check for caps - if this is not video stream (and preferably - one of these which we have to support) then simply skip it if (!gst_element_link(_source, _tee)) { @@ -894,20 +1043,15 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) if (!_streaming) { _streaming = true; - qCDebug(GstVideoReceiverLog) << "Streaming started" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming started" << _uriForLogging(); emit streamingChanged(_streaming); } - _eosProbeId = gst_pad_add_probe(pad, GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM, _eosProbe, this, nullptr); - if (_eosProbeId != 0) { - // Hold a ref so _shutdownDecodingBranch can remove the probe even after _decoder is gone. - _eosProbePad = GST_PAD_CAST(gst_object_ref(pad)); - } if (!_videoSink) { return; } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-new-source-pad"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-with-new-source-pad"); _ensureVideoSinkInPipeline(); @@ -921,7 +1065,7 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) "drop", FALSE, nullptr); - qCDebug(GstVideoReceiverLog) << "Decoding started" << _uri; + qCDebug(GstVideoReceiverLog) << "Decoding started" << _uriForLogging(); } void GstVideoReceiver::_logDecodebin3SelectedCodec(GstElement *decodebin3) @@ -981,9 +1125,9 @@ void GstVideoReceiver::_logDecodebin3SelectedCodec(GstElement *decodebin3) void GstVideoReceiver::_onNewDecoderPad(GstPad *pad) { - qCDebug(GstVideoReceiverLog) << "_onNewDecoderPad" << _uri; + qCDebug(GstVideoReceiverLog) << "_onNewDecoderPad" << _uriForLogging(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-new-decoder-pad"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-with-new-decoder-pad"); // We should now know what codec decodebin3 selected. _logDecodebin3SelectedCodec(_decoder); @@ -1006,7 +1150,7 @@ bool GstVideoReceiver::_addDecoder(GstElement *src) (void) gst_bin_add(GST_BIN(_pipeline), _decoder); (void) gst_element_sync_state_with_parent(_decoder); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-decoder"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-with-decoder"); if (!gst_element_link(src, _decoder)) { qCCritical(GstVideoReceiverLog) << "Unable to link decoder"; @@ -1081,13 +1225,13 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) (void) gst_element_sync_state_with_parent(_videoSink); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-videosink"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-with-videosink"); // Determine video size. Errors here are non-fatal. QSize videoSize; do { if (!_decoderValve) { - qCCritical(GstVideoReceiverLog) << "Unable to determine video size - _decoderValve is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to determine video size - _decoderValve is NULL" << _uriForLogging(); break; } @@ -1106,7 +1250,7 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) const GstStructure *structure = gst_caps_get_structure(valveSrcPadCaps, 0); if (!structure) { - qCCritical(GstVideoReceiverLog) << "Unable to determine video size - structure is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to determine video size - structure is NULL" << _uriForLogging(); gst_clear_object(&valveSrcPad); break; } @@ -1152,10 +1296,10 @@ void GstVideoReceiver::_noteTeeFrame() } const quint64 sourceFrames = _sourceFrameCount.fetch_add(1, std::memory_order_relaxed) + 1; if (sourceFrames == 1) { - qCInfo(GstVideoReceiverLog).noquote() << "Source receiving frames (tee):" << _uri; + qCInfo(GstVideoReceiverLog).noquote() << "Source receiving frames (tee):" << _uriForLogging(); } else if ((sourceFrames % 300) == 0) { qCDebug(GstVideoReceiverLog).noquote() - << "Source flow: teeFrames=" << sourceFrames << "decoding=" << _decoding << _uri; + << "Source flow: teeFrames=" << sourceFrames << "decoding=" << _decoding << _uriForLogging(); } } @@ -1171,10 +1315,36 @@ void GstVideoReceiver::_noteVideoSinkFrame() void GstVideoReceiver::_noteEndOfStream() { - _endOfStream = true; + if (_endOfStream.exchange(true, std::memory_order_acq_rel)) { + return; + } + + const quint64 pipelineGeneration = _pipelineGeneration.load(std::memory_order_acquire); + _worker->dispatch([this, pipelineGeneration]() { + if (pipelineGeneration != _pipelineGeneration.load(std::memory_order_acquire)) { + return; + } + _handleEOS(); + }); +} + +void GstVideoReceiver::_removeSourceProbe() +{ + if (_sourceProbeId == 0) { + return; + } + + if (_tee) { + GstPad* sinkpad = gst_element_get_static_pad(_tee, "sink"); + if (sinkpad) { + gst_pad_remove_probe(sinkpad, _sourceProbeId); + gst_clear_object(&sinkpad); + } + } + _sourceProbeId = 0; } -bool GstVideoReceiver::_unlinkBranch(GstElement *from) +bool GstVideoReceiver::_unlinkBranch(GstElement *from, guint32 eosSeqnum) { GstPad *src = gst_element_get_static_pad(from, "src"); if (!src) { @@ -1198,8 +1368,13 @@ bool GstVideoReceiver::_unlinkBranch(GstElement *from) gst_clear_object(&src); - // Send EOS at the beginning of the branch - const gboolean ret = gst_pad_send_event(sink, gst_event_new_eos()); + // Send EOS at the beginning of the branch. Recording teardown stamps the + // event so its forwarded and aggregate pipeline messages can be correlated. + GstEvent *eos = gst_event_new_eos(); + if (eosSeqnum != GST_SEQNUM_INVALID) { + gst_event_set_seqnum(eos, eosSeqnum); + } + const gboolean ret = gst_pad_send_event(sink, eos); gst_clear_object(&sink); @@ -1236,13 +1411,6 @@ void GstVideoReceiver::_shutdownDecodingBranch() } _videoSinkProbeId = 0; - if (_eosProbeId != 0 && _eosProbePad) { - // Probe was installed on the source pad in _onNewSourcePad; remove from that exact pad — not from _decoder, which may already be cleared above. - gst_pad_remove_probe(_eosProbePad, _eosProbeId); - } - _eosProbeId = 0; - gst_clear_object(&_eosProbePad); - _lastVideoFrameTime = 0; if (_videoSink) { @@ -1264,18 +1432,20 @@ void GstVideoReceiver::_shutdownDecodingBranch() emit decodingChanged(_decoding); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-decoding-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-decoding-stopped"); } void GstVideoReceiver::_shutdownRecordingBranch() { - if (_keyframeWatchId != 0 && _recorderValve) { + _recordingEosSeqnum.store(GST_SEQNUM_INVALID, std::memory_order_release); + + const gulong keyframeWatchId = _keyframeWatchId.exchange(0, std::memory_order_acq_rel); + if (keyframeWatchId != 0 && _recorderValve) { GstPad *probepad = gst_element_get_static_pad(_recorderValve, "src"); if (probepad) { - gst_pad_remove_probe(probepad, _keyframeWatchId); + gst_pad_remove_probe(probepad, keyframeWatchId); gst_clear_object(&probepad); } - _keyframeWatchId = 0; } gst_bin_remove(GST_BIN(_pipeline), _fileSink); @@ -1296,7 +1466,7 @@ void GstVideoReceiver::_shutdownRecordingBranch() emit onStopRecordingComplete(STATUS_OK); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-recording-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kSafePipelineGraphDetails, "pipeline-recording-stopped"); } bool GstVideoReceiver::_needDispatch() @@ -1318,7 +1488,12 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp return TRUE; } - GstVideoReceiver *pThis = static_cast(data); + auto* context = static_cast(data); + GstVideoReceiver* pThis = context->receiver; + if (!pThis || context->generation != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return TRUE; + } + const quint64 pipelineGeneration = context->generation; if (GST_MESSAGE_TYPE(msg) != GST_MESSAGE_ERROR) { HwBuffers::dispatchBusMessage(msg); @@ -1332,7 +1507,11 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp const bool recoverableH265PaciError = isRecoverableH265PaciError(msg, error, debug); if (debug) { - qCDebug(GstVideoReceiverLog) << "GStreamer debug:" << debug; + if (context->redactDiagnostics) { + qCDebug(GstVideoReceiverLog) << "GStreamer debug details redacted for a credential-capable source"; + } else { + qCDebug(GstVideoReceiverLog) << "GStreamer debug:" << debug; + } g_clear_pointer(&debug, g_free); } @@ -1340,6 +1519,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp if (recoverableH265PaciError) { qCWarning(GstVideoReceiverLog) << "Ignoring unsupported H.265 RTP PACI packet from rtph265depay:" << error->message; + } else if (context->redactDiagnostics) { + qCCritical(GstVideoReceiverLog) + << "GStreamer error for credential-capable source; details redacted. Domain/code:" + << error->domain << error->code; } else { qCCritical(GstVideoReceiverLog) << "GStreamer error:" << error->message; } @@ -1352,10 +1535,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp HwBuffers::dispatchBusMessage(msg); - if (GstElement *pipelineRef = pThis->_acquirePipelineRef()) { + if (GstElement* pipelineRef = context->acquirePipeline()) { // Native dump path (no-op without GST_DEBUG_DUMP_DOT_DIR) plus an unconditional // CacheLocation fallback so field-bug-report bundles include pipeline topology. - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-error"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GStreamer::kSafePipelineGraphDetails, "pipeline-error"); const QString dotPath = GStreamer::writePipelineDot(pipelineRef, "pipeline-error"); if (!dotPath.isEmpty()) { qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; @@ -1365,7 +1548,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp // GPU-side ERROR handling (cached-device drop) runs in HwBuffers::dispatchBusMessage above. // _scheduleReconnect calls stop() then queues a backoff retry if autoReconnect is on. - pThis->_worker->dispatch([pThis]() { + pThis->_worker->dispatch([pThis, pipelineGeneration]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } qCDebug(GstVideoReceiverLog) << "Stopping because of error"; pThis->_scheduleReconnect("pipeline error"); }); @@ -1377,19 +1563,30 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp gchar *debug = nullptr; GError *error = nullptr; gst_message_parse_warning(msg, &error, &debug); - qCWarning(GstVideoReceiverLog) << "GStreamer warning:" - << (error ? error->message : "(no message)") - << "debug:" << (debug ? debug : "(none)"); + if (context->redactDiagnostics) { + qCWarning(GstVideoReceiverLog) + << "GStreamer warning for credential-capable source; details redacted. Domain/code:" + << (error ? error->domain : 0) << (error ? error->code : 0); + } else { + qCWarning(GstVideoReceiverLog) << "GStreamer warning:" + << (error ? error->message : "(no message)") + << "debug:" << (debug ? debug : "(none)"); + } g_clear_error(&error); g_clear_pointer(&debug, g_free); break; } - case GST_MESSAGE_EOS: - pThis->_worker->dispatch([pThis]() { + case GST_MESSAGE_EOS: { + const bool recordingEOS = pThis->_isRecordingEOSMessage(msg); + pThis->_worker->dispatch([pThis, pipelineGeneration, recordingEOS]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } qCDebug(GstVideoReceiverLog) << "Received EOS"; - pThis->_handleEOS(); + pThis->_handleBusEOS(recordingEOS, true); }); break; + } case GST_MESSAGE_STREAM_COLLECTION: { GstStreamCollection *collection = nullptr; gst_message_parse_stream_collection(msg, &collection); @@ -1445,7 +1642,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp const QSize resolution(w, h); // src compared by address only on the GUI thread; never dereferenced (may be gone by then). void *src = GST_MESSAGE_SRC(msg); - QMetaObject::invokeMethod(pThis, [pThis, format, resolution, src]() { + QMetaObject::invokeMethod(pThis, [pThis, format, resolution, src, pipelineGeneration]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } for (auto *c : QGCQVideoSinkController::controllersOf(pThis)) { if (static_cast(c->element()) == src) { c->updateNegotiation(format, resolution); @@ -1465,9 +1665,13 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp } if (GST_MESSAGE_TYPE(forward_msg) == GST_MESSAGE_EOS) { - pThis->_worker->dispatch([pThis]() { + const bool recordingEOS = pThis->_isRecordingEOSMessage(forward_msg); + pThis->_worker->dispatch([pThis, pipelineGeneration, recordingEOS]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } qCDebug(GstVideoReceiverLog) << "Received branch EOS"; - pThis->_handleEOS(); + pThis->_handleBusEOS(recordingEOS, false); }); } @@ -1475,7 +1679,7 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp break; } case GST_MESSAGE_STATE_CHANGED: { - GstElement *pipelineRef = pThis->_acquirePipelineRef(); + GstElement* pipelineRef = context->acquirePipeline(); if (!pipelineRef) break; const bool fromPipeline = (GST_MESSAGE_SRC(msg) == GST_OBJECT(pipelineRef)); if (!fromPipeline) { @@ -1494,7 +1698,7 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp gst_query_unref(q); const QString decName = pThis->decoderName(); qCDebug(GstVideoReceiverLog).noquote() - << "Pipeline PLAYING:" << pThis->_uri + << "Pipeline PLAYING:" << context->uriForLogging << "decoder:" << (decName.isEmpty() ? QStringLiteral("(pending)") : decName) << "min-latency:" << (min / 1000000) << "ms" << "max-latency:" << (max / 1000000) << "ms"; @@ -1503,7 +1707,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp break; } case GST_MESSAGE_LATENCY: - pThis->_worker->dispatch([pThis]() { + pThis->_worker->dispatch([pThis, pipelineGeneration]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } GstElement* pipeline = pThis->_acquirePipelineRef(); if (pipeline) { (void) gst_bin_recalculate_latency(GST_BIN(pipeline)); @@ -1512,7 +1719,10 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp }); // Re-prime sink-side latency tracking after the pipeline recalculation (e.g. RTSP // jitter-buffer reconfigure). Controllers live on the GUI thread; hop there to query. - QMetaObject::invokeMethod(pThis, [pThis]() { + QMetaObject::invokeMethod(pThis, [pThis, pipelineGeneration]() { + if (pipelineGeneration != pThis->_pipelineGeneration.load(std::memory_order_acquire)) { + return; + } for (auto* c : QGCQVideoSinkController::controllersOf(pThis)) c->refreshLatency(); }, Qt::QueuedConnection); @@ -1529,7 +1739,7 @@ void GstVideoReceiver::_onNewPad(GstElement *element, GstPad *pad, gpointer data GstVideoReceiver *self = static_cast(data); if (element == self->_source) { - self->_onNewSourcePad(pad); + self->_onNewSourcePad(); } else if (element == self->_decoder) { self->_onNewDecoderPad(pad); } else { @@ -1537,13 +1747,23 @@ void GstVideoReceiver::_onNewPad(GstElement *element, GstPad *pad, gpointer data } } -GstPadProbeReturn GstVideoReceiver::_teeProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) +GstPadProbeReturn GstVideoReceiver::_sourceProbe(GstPad* pad, GstPadProbeInfo* info, gpointer user_data) { - Q_UNUSED(pad); Q_UNUSED(info) + Q_UNUSED(pad) - if (user_data) { - GstVideoReceiver *pThis = static_cast(user_data); + if (!info || !user_data) { + qCCritical(GstVideoReceiverLog) << "Invalid source probe arguments"; + return GST_PAD_PROBE_OK; + } + + GstVideoReceiver* pThis = static_cast(user_data); + if ((GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_BUFFER) != 0) { pThis->_noteTeeFrame(); + } else if ((GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_EVENT_DOWNSTREAM) != 0) { + const GstEvent* event = gst_pad_probe_info_get_event(info); + if (event && GST_EVENT_TYPE(event) == GST_EVENT_EOS) { + pThis->_noteEndOfStream(); + } } return GST_PAD_PROBE_OK; @@ -1590,22 +1810,6 @@ GstPadProbeReturn GstVideoReceiver::_videoSinkProbe(GstPad *pad, GstPadProbeInfo return GST_PAD_PROBE_OK; } -GstPadProbeReturn GstVideoReceiver::_eosProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) -{ - Q_UNUSED(pad); - Q_ASSERT(user_data); - - if (info) { - const GstEvent *event = gst_pad_probe_info_get_event(info); - if (GST_EVENT_TYPE(event) == GST_EVENT_EOS) { - GstVideoReceiver *pThis = static_cast(user_data); - pThis->_noteEndOfStream(); - } - } - - return GST_PAD_PROBE_OK; -} - GstPadProbeReturn GstVideoReceiver::_keyframeWatch(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) { if (!info || !user_data) { @@ -1625,6 +1829,7 @@ GstPadProbeReturn GstVideoReceiver::_keyframeWatch(GstPad *pad, GstPadProbeInfo qCDebug(GstVideoReceiverLog) << "Got keyframe, stop dropping buffers"; GstVideoReceiver *pThis = static_cast(user_data); + pThis->_keyframeWatchId.store(0, std::memory_order_release); emit pThis->recordingStarted(pThis->recordingOutput()); return GST_PAD_PROBE_REMOVE; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h index 834b31a3bd3f..bf7af4bc17da 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h @@ -11,6 +11,7 @@ #include #include #include +#include #include "VideoReceiver.h" @@ -52,6 +53,8 @@ class GstVideoReceiver : public VideoReceiver Q_PROPERTY(double qosProportion READ qosProportion NOTIFY decoderStatsChanged) Q_PROPERTY(int qosQuality READ qosQuality NOTIFY decoderStatsChanged) + friend class GStreamerTest; + public: explicit GstVideoReceiver(QObject *parent = nullptr); ~GstVideoReceiver(); @@ -87,8 +90,11 @@ private slots: private: GstElement *_makeDecoder(); GstElement *_makeFileSink(const QString &videoFile, FILE_FORMAT format); + QString _uriForLogging() const; + static bool _isJpegNetworkSource(const QString &uri); + static bool _mustRedactPipelineDiagnostics(const QString &uri); - void _onNewSourcePad(GstPad *pad); + void _onNewSourcePad(); void _onNewDecoderPad(GstPad *pad); bool _addDecoder(GstElement *src); void _ensureVideoSinkInPipeline(); @@ -96,9 +102,12 @@ private slots: void _noteTeeFrame(); void _noteVideoSinkFrame(); void _noteEndOfStream(); + void _removeSourceProbe(); /// -Unlink the branch from the src pad /// -Send an EOS event at the beginning of that branch - bool _unlinkBranch(GstElement *from); + bool _unlinkBranch(GstElement *from, guint32 eosSeqnum = GST_SEQNUM_INVALID); + bool _isRecordingEOSMessage(GstMessage *message) const; + void _handleBusEOS(bool recordingEOS, bool directPipelineEOS); void _shutdownDecodingBranch(); void _shutdownRecordingBranch(); void _logDecodebin3SelectedCodec(GstElement *decodebin3); @@ -117,9 +126,8 @@ private slots: static gboolean _onBusMessage(GstBus *bus, GstMessage *message, gpointer user_data); static void _onNewPad(GstElement *element, GstPad *pad, gpointer data); - static GstPadProbeReturn _teeProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); + static GstPadProbeReturn _sourceProbe(GstPad* pad, GstPadProbeInfo* info, gpointer user_data); static GstPadProbeReturn _videoSinkProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); - static GstPadProbeReturn _eosProbe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); static GstPadProbeReturn _keyframeWatch(GstPad *pad, GstPadProbeInfo *info, gpointer user_data); GstElement *_decoder = nullptr; @@ -132,16 +140,18 @@ private slots: GstElement *_tee = nullptr; GstElement *_videoSink = nullptr; GstVideoWorker *_worker = nullptr; + gulong _busHandlerId = 0; std::atomic _reconnectAttempts = 0; ///< Written on the streaming thread (_noteTeeFrame) and GUI thread (reconnect lambda); atomic. std::atomic _reconnectEpoch = 0; ///< Bumped on every stop() — pending singleShot lambdas check this before firing, replacing an explicit cancel/pending-flag pair. + std::atomic _pipelineGeneration = 0; ///< Invalidates callbacks and queued work from a retired pipeline. std::atomic _sourceFrameCount = - 0; ///< Tee-probe frame tally (streaming thread); drives the source-side flow heartbeat log. - gulong _teeProbeId = 0; + 0; ///< Source-probe frame tally (streaming thread); drives the source-side flow heartbeat log. + gulong _sourceProbeId = 0; gulong _videoSinkProbeId = 0; - gulong _eosProbeId = 0; - GstPad *_eosProbePad = nullptr; // ref-held: probe install pad, kept so removal targets the right pad regardless of _decoder lifecycle - gulong _keyframeWatchId = 0; + std::atomic _keyframeWatchId = 0; + std::atomic _recordingEosSeqnum{GST_SEQNUM_INVALID}; bool _recordingStopRequested = false; + bool _activePipelineIsJpegNetworkSource = false; mutable QMutex _decoderNameMutex; // QString refcount isn't thread-safe across reader/writer threads QString _decoderName; diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.cc new file mode 100644 index 000000000000..cc6d890e7c9d --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.cc @@ -0,0 +1,323 @@ +#include "QGCJpegStreamGuard.h" + +#include +#include + +namespace QGCJpegStreamGuard { + +namespace { + +constexpr quint8 kMarkerPrefix = 0xFF; +constexpr quint8 kStartOfImage = 0xD8; +constexpr quint8 kEndOfImage = 0xD9; +constexpr quint8 kStartOfScan = 0xDA; +constexpr qsizetype kMaximumBoundaryTokenBytes = 200; + +void setError(QString* error, const QString& reason) +{ + if (error) { + *error = reason; + } +} + +quint8 byteAt(QByteArrayView data, qsizetype offset) +{ + return static_cast(data.at(offset)); +} + +bool isStartOfFrame(quint8 marker) +{ + return ((marker >= 0xC0) && (marker <= 0xC3)) || ((marker >= 0xC5) && (marker <= 0xC7)) || + ((marker >= 0xC9) && (marker <= 0xCB)) || ((marker >= 0xCD) && (marker <= 0xCF)); +} + +bool isStandaloneMarker(quint8 marker) +{ + return marker == 0x01 || ((marker >= 0xD0) && (marker <= 0xD7)); +} + +bool isValidBoundaryToken(QByteArrayView token) +{ + if (token.isEmpty() || token.size() > kMaximumBoundaryTokenBytes) { + return false; + } + + return std::all_of(token.begin(), token.end(), [](char value) { + const uchar character = static_cast(value); + return (character >= 0x21) && (character <= 0x7E) && character != '"'; + }); +} + +} // namespace + +bool validateJpeg(QByteArrayView jpeg, QString* error) +{ + if (jpeg.size() > kMaximumEncodedBytes) { + setError(error, QStringLiteral("JPEG exceeds the 16 MiB encoded-size limit.")); + return false; + } + if (jpeg.size() < 4 || byteAt(jpeg, 0) != kMarkerPrefix || byteAt(jpeg, 1) != kStartOfImage || + byteAt(jpeg, jpeg.size() - 2) != kMarkerPrefix || byteAt(jpeg, jpeg.size() - 1) != kEndOfImage) { + setError(error, QStringLiteral("JPEG is missing a complete SOI/EOI envelope.")); + return false; + } + + bool foundDimensions = false; + bool foundScan = false; + bool inEntropyData = false; + qsizetype offset = 2; + while (offset < jpeg.size()) { + if (byteAt(jpeg, offset) != kMarkerPrefix) { + if (inEntropyData) { + ++offset; + continue; + } + setError(error, QStringLiteral("JPEG contains data outside a marker segment.")); + return false; + } + + while (offset < jpeg.size() && byteAt(jpeg, offset) == kMarkerPrefix) { + ++offset; + } + if (offset >= jpeg.size()) { + setError(error, QStringLiteral("JPEG ends inside a marker.")); + return false; + } + + const quint8 marker = byteAt(jpeg, offset++); + if (marker == 0x00) { + if (!inEntropyData) { + setError(error, QStringLiteral("JPEG contains an unexpected stuffed byte.")); + return false; + } + continue; + } + if (marker == kEndOfImage) { + if (offset != jpeg.size() || !foundDimensions || !foundScan) { + setError(error, QStringLiteral("JPEG has trailing data or is missing frame/scan metadata.")); + return false; + } + return true; + } + if (marker == kStartOfImage) { + setError(error, QStringLiteral("JPEG contains a nested SOI marker.")); + return false; + } + if (isStandaloneMarker(marker)) { + if (!inEntropyData && marker != 0x01) { + setError(error, QStringLiteral("JPEG restart marker appears outside scan data.")); + return false; + } + continue; + } + + inEntropyData = false; + if ((offset + 2) > jpeg.size()) { + setError(error, QStringLiteral("JPEG segment length is truncated.")); + return false; + } + const quint16 segmentLength = static_cast((byteAt(jpeg, offset) << 8) | byteAt(jpeg, offset + 1)); + if (segmentLength < 2 || segmentLength > static_cast(jpeg.size() - offset)) { + setError(error, QStringLiteral("JPEG segment length is invalid.")); + return false; + } + + if (isStartOfFrame(marker)) { + if (segmentLength < 8) { + setError(error, QStringLiteral("JPEG frame header is truncated.")); + return false; + } + const quint32 height = static_cast((byteAt(jpeg, offset + 3) << 8) | byteAt(jpeg, offset + 4)); + const quint32 width = static_cast((byteAt(jpeg, offset + 5) << 8) | byteAt(jpeg, offset + 6)); + const quint64 pixels = static_cast(width) * height; + if (width == 0 || height == 0) { + setError(error, QStringLiteral("JPEG frame dimensions must be non-zero.")); + return false; + } + if (width > kMaximumDimension || height > kMaximumDimension || pixels > kMaximumDecodedPixels) { + setError(error, QStringLiteral("JPEG frame dimensions exceed the 8K decoder-allocation limit.")); + return false; + } + foundDimensions = true; + } + + if (marker == kStartOfScan) { + foundScan = true; + inEntropyData = true; + } + offset += segmentLength; + } + + setError(error, QStringLiteral("JPEG does not terminate with EOI.")); + return false; +} + +MultipartGuard::MultipartGuard(qsizetype maximumPartBytes, qsizetype maximumInitialBoundaryBytes) + : _maximumPartBytes(std::max(1, maximumPartBytes)), + _maximumInitialBoundaryBytes(std::max(1, maximumInitialBoundaryBytes)) +{} + +bool MultipartGuard::setBoundary(QByteArrayView boundary, QString* error) +{ + QByteArray normalized(boundary.data(), boundary.size()); + if (normalized.size() >= 2 && normalized.front() == '"' && normalized.back() == '"') { + normalized = normalized.mid(1, normalized.size() - 2); + } + if (normalized.startsWith("--")) { + normalized.remove(0, 2); + } + if (!isValidBoundaryToken(normalized)) { + return _reject(QStringLiteral("Multipart boundary token is invalid."), error); + } + + _boundaryMarker = QByteArrayLiteral("--") + normalized; + _framedBoundaryMarker = QByteArrayLiteral("\r\n") + _boundaryMarker; + return true; +} + +bool MultipartGuard::consume(QByteArrayView data, QString* error) +{ + if (_rejected) { + setError(error, QStringLiteral("Multipart stream was already rejected.")); + return false; + } + if (data.isEmpty()) { + return true; + } + if (_boundaryMarker.isEmpty()) { + return _discoverBoundary(data, error); + } + return _consumeKnownBoundary(data, error); +} + +bool MultipartGuard::_discoverBoundary(QByteArrayView data, QString* error) +{ + const QByteArray rawData = QByteArray::fromRawData(data.data(), data.size()); + const qsizetype newline = rawData.indexOf('\n'); + const qsizetype bytesToAppend = (newline >= 0) ? newline + 1 : data.size(); + if ((_initialBoundaryLine.size() + bytesToAppend) > _maximumInitialBoundaryBytes) { + return _reject(QStringLiteral("Multipart stream did not provide a bounded initial boundary."), error); + } + + _initialBoundaryLine.append(data.data(), bytesToAppend); + if (newline < 0) { + return true; + } + + QByteArray line = _initialBoundaryLine; + _initialBoundaryLine.clear(); + if (line.endsWith('\n')) { + line.chop(1); + } + if (line.endsWith('\r')) { + line.chop(1); + } + if (!line.startsWith("--") || !setBoundary(QByteArrayView(line.constData() + 2, line.size() - 2), error)) { + return _reject(QStringLiteral("Multipart stream initial boundary is invalid."), error); + } + + _seenBoundary = true; + _lastBoundaryEnd = 0; + const qsizetype remainingOffset = bytesToAppend; + return _consumeKnownBoundary(QByteArrayView(data.data() + remainingOffset, data.size() - remainingOffset), error); +} + +bool MultipartGuard::_consumeKnownBoundary(QByteArrayView data, QString* error) +{ + const quint64 previousStreamBytes = _streamBytes; + const quint64 newStreamBytes = previousStreamBytes + static_cast(data.size()); + QList boundaryEnds; + + const QList chunkBoundaryEnds = _findBoundaryEnds(data, !_seenBoundary && previousStreamBytes == 0); + for (const qsizetype boundaryEnd : chunkBoundaryEnds) { + boundaryEnds.append(previousStreamBytes + static_cast(boundaryEnd)); + } + + if (!_tail.isEmpty()) { + const qsizetype prefixSize = std::min(data.size(), _framedBoundaryMarker.size() + 2); + QByteArray overlap = _tail; + overlap.append(data.data(), prefixSize); + const quint64 overlapStart = previousStreamBytes - static_cast(_tail.size()); + const QList overlapBoundaryEnds = _findBoundaryEnds(overlap, !_seenBoundary && overlapStart == 0); + for (const qsizetype boundaryEnd : overlapBoundaryEnds) { + boundaryEnds.append(overlapStart + static_cast(boundaryEnd)); + } + } + + _streamBytes = newStreamBytes; + std::sort(boundaryEnds.begin(), boundaryEnds.end()); + boundaryEnds.erase(std::unique(boundaryEnds.begin(), boundaryEnds.end()), boundaryEnds.end()); + for (const quint64 boundaryEnd : std::as_const(boundaryEnds)) { + if (_seenBoundary && boundaryEnd <= _lastBoundaryEnd) { + continue; + } + if (_seenBoundary && + (boundaryEnd - _lastBoundaryEnd) > static_cast(_maximumPartBytes + _framedBoundaryMarker.size())) { + return _reject(QStringLiteral("Multipart part exceeds the configured encoded-frame bound."), error); + } + _seenBoundary = true; + _lastBoundaryEnd = boundaryEnd; + } + _bytesSinceBoundary = _seenBoundary ? newStreamBytes - _lastBoundaryEnd : newStreamBytes; + _updateTail(data); + + if (!_seenBoundary && _streamBytes > static_cast(_maximumInitialBoundaryBytes)) { + return _reject(QStringLiteral("Multipart stream did not match its declared boundary."), error); + } + if (_seenBoundary && _bytesSinceBoundary > static_cast(_maximumPartBytes)) { + return _reject(QStringLiteral("Multipart part exceeds the configured encoded-frame bound."), error); + } + return true; +} + +QList MultipartGuard::_findBoundaryEnds(QByteArrayView data, bool allowInitialBoundary) const +{ + QList boundaryEnds; + if (_boundaryMarker.isEmpty()) { + return boundaryEnds; + } + + const QByteArray rawData = QByteArray::fromRawData(data.data(), data.size()); + const auto suffixIsValid = [&rawData](qsizetype markerEnd) { + return (markerEnd + 2) <= rawData.size() && + ((rawData.at(markerEnd) == '\r' && rawData.at(markerEnd + 1) == '\n') || + (rawData.at(markerEnd) == '-' && rawData.at(markerEnd + 1) == '-')); + }; + + if (allowInitialBoundary && rawData.startsWith(_boundaryMarker) && suffixIsValid(_boundaryMarker.size())) { + boundaryEnds.append(_boundaryMarker.size()); + } + + qsizetype offset = 0; + while ((offset = rawData.indexOf(_framedBoundaryMarker, offset)) >= 0) { + const qsizetype markerEnd = offset + _framedBoundaryMarker.size(); + if (suffixIsValid(markerEnd)) { + boundaryEnds.append(markerEnd); + } + ++offset; + } + return boundaryEnds; +} + +bool MultipartGuard::_reject(const QString& reason, QString* error) +{ + _rejected = true; + setError(error, reason); + return false; +} + +void MultipartGuard::_updateTail(QByteArrayView data) +{ + const qsizetype tailBytes = _framedBoundaryMarker.size() + 2; + if (data.size() >= tailBytes) { + _tail = QByteArray(data.data() + data.size() - tailBytes, tailBytes); + return; + } + + _tail.append(data.data(), data.size()); + if (_tail.size() > tailBytes) { + _tail.remove(0, _tail.size() - tailBytes); + } +} + +} // namespace QGCJpegStreamGuard diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.h b/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.h new file mode 100644 index 000000000000..a0c89ea40850 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCJpegStreamGuard.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace QGCJpegStreamGuard { + +inline constexpr qsizetype kMaximumEncodedBytes = 16 * 1024 * 1024; +inline constexpr quint32 kMaximumDimension = 8192; +inline constexpr quint64 kMaximumDecodedPixels = 7680ULL * 4320ULL; +inline constexpr qsizetype kMaximumMultipartHeaderBytes = 64 * 1024; +inline constexpr qsizetype kMaximumMultipartPartBytes = kMaximumEncodedBytes + kMaximumMultipartHeaderBytes; + +/// Validate a complete JPEG and reject dimensions that could cause excessive decoder allocation. +[[nodiscard]] bool validateJpeg(QByteArrayView jpeg, QString* error = nullptr); + +/// Bounds bytes accepted by multipartdemux between exact MIME boundary markers. +class MultipartGuard +{ +public: + explicit MultipartGuard(qsizetype maximumPartBytes = kMaximumMultipartPartBytes, + qsizetype maximumInitialBoundaryBytes = 4096); + + [[nodiscard]] bool setBoundary(QByteArrayView boundary, QString* error = nullptr); + [[nodiscard]] bool consume(QByteArrayView data, QString* error = nullptr); + +private: + [[nodiscard]] bool _consumeKnownBoundary(QByteArrayView data, QString* error); + [[nodiscard]] bool _discoverBoundary(QByteArrayView data, QString* error); + [[nodiscard]] QList _findBoundaryEnds(QByteArrayView data, bool allowInitialBoundary) const; + [[nodiscard]] bool _reject(const QString& reason, QString* error); + void _updateTail(QByteArrayView data); + + const qsizetype _maximumPartBytes; + const qsizetype _maximumInitialBoundaryBytes; + QByteArray _boundaryMarker; + QByteArray _framedBoundaryMarker; + QByteArray _tail; + QByteArray _initialBoundaryLine; + quint64 _streamBytes = 0; + quint64 _lastBoundaryEnd = 0; + quint64 _bytesSinceBoundary = 0; + bool _seenBoundary = false; + bool _rejected = false; +}; + +} // namespace QGCJpegStreamGuard diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc new file mode 100644 index 000000000000..d6efcde26ea4 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.cc @@ -0,0 +1,286 @@ +#include "QGCWebSocketVideoSource.h" + +#include +#include +#include +#include +#include +#include + +#include "QGCJpegStreamGuard.h" +#include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" +#include "SecureMemory.h" + +QGC_LOGGING_CATEGORY(QGCWebSocketVideoSourceLog, "Video.GStreamer.WebSocketVideoSource") + +QGCWebSocketVideoSource::QGCWebSocketVideoSource(const QUrl& url, const VideoReceiver::NetworkSourceConfig& config, + GstElement* appsrc, QObject* parent) + : QObject(parent), _url(url), _config(config), _appsrc(appsrc) +{ + if (_appsrc) { + gst_object_ref(_appsrc); + } +} + +QGCWebSocketVideoSource::~QGCWebSocketVideoSource() +{ + stop(); + _config.clearSecret(); + gst_clear_object(&_appsrc); +} + +bool QGCWebSocketVideoSource::start(QString& error) +{ + error.clear(); + if (_running) { + return true; + } + _protocolViolationReported = false; + _transportErrorReported = false; + _pendingError = PendingError::None; + _pendingErrorDetail.clear(); + _pendingTransportErrorCode = 0; + _pendingErrorPosted = false; + _pendingErrorRetryScheduled = false; + if (!_appsrc || !_url.isValid() || + (_url.scheme() != QStringLiteral("ws") && _url.scheme() != QStringLiteral("wss"))) { + error = tr("Invalid WebSocket JPEG source."); + return false; + } + if (_config.hasAuthentication() && _url.scheme() != QStringLiteral("wss")) { + error = tr("Authenticated WebSocket video requires WSS."); + return false; + } + if (!_config.caCertificateFile.isEmpty() && _url.scheme() != QStringLiteral("wss")) { + error = tr("A custom CA certificate requires WSS."); + return false; + } + + QNetworkRequest request(_url); + request.setRawHeader("User-Agent", QGCNetworkHelper::defaultUserAgent().toUtf8()); + switch (_config.authentication) { + case VideoReceiver::NetworkSourceConfig::Authentication::Basic: { + QByteArray credentials = _config.username.toUtf8() + QByteArrayLiteral(":") + _config.secret; + QByteArray authorization = QByteArrayLiteral("Basic ") + credentials.toBase64(); + request.setRawHeader("Authorization", authorization); + QGC::secureZero(authorization); + QGC::secureZero(credentials); + break; + } + case VideoReceiver::NetworkSourceConfig::Authentication::Bearer: { + QByteArray authorization = QByteArrayLiteral("Bearer ") + _config.secret; + request.setRawHeader("Authorization", authorization); + QGC::secureZero(authorization); + break; + } + case VideoReceiver::NetworkSourceConfig::Authentication::None: + break; + } + + _webSocket = new QWebSocket(_config.origin, QWebSocketProtocol::VersionLatest, this); + _webSocket->setMaxAllowedIncomingFrameSize(static_cast(QGCJpegStreamGuard::kMaximumEncodedBytes)); + _webSocket->setMaxAllowedIncomingMessageSize(static_cast(QGCJpegStreamGuard::kMaximumEncodedBytes)); + _webSocket->setReadBufferSize(static_cast(QGCJpegStreamGuard::kMaximumEncodedBytes)); + + if (_url.scheme() == QStringLiteral("wss")) { + QSslConfiguration sslConfiguration = QGCNetworkHelper::createSslConfig(); + if (!_config.caCertificateFile.isEmpty()) { + const QList certificates = + QGCNetworkHelper::loadCaCertificates(_config.caCertificateFile, &error); + if (certificates.isEmpty()) { + delete _webSocket; + _webSocket = nullptr; + return false; + } + sslConfiguration.addCaCertificates(certificates); + } + _webSocket->setSslConfiguration(sslConfiguration); + } + + (void) connect(_webSocket, &QWebSocket::connected, this, &QGCWebSocketVideoSource::_onConnected); + (void) connect(_webSocket, &QWebSocket::disconnected, this, &QGCWebSocketVideoSource::_onDisconnected); + (void) connect(_webSocket, &QWebSocket::binaryMessageReceived, this, + &QGCWebSocketVideoSource::_onBinaryMessageReceived); + (void) connect(_webSocket, &QWebSocket::textMessageReceived, this, + &QGCWebSocketVideoSource::_onTextMessageReceived); + (void) connect(_webSocket, &QWebSocket::errorOccurred, this, &QGCWebSocketVideoSource::_onError); + (void) connect(_webSocket, &QWebSocket::sslErrors, this, &QGCWebSocketVideoSource::_onSslErrors); + + _running = true; + qCDebug(QGCWebSocketVideoSourceLog) << "Opening" << QGCNetworkHelper::redactedUrlForLogging(_url); + _webSocket->open(request); + return true; +} + +void QGCWebSocketVideoSource::stop() +{ + if (!_running && !_webSocket) { + return; + } + + _running = false; + _pendingError = PendingError::None; + _pendingErrorDetail.clear(); + _pendingErrorRetryScheduled = false; + if (_webSocket) { + _webSocket->disconnect(this); + _webSocket->abort(); + delete _webSocket; + _webSocket = nullptr; + } +} + +bool QGCWebSocketVideoSource::isCompleteJpeg(const QByteArray& message) +{ + return QGCJpegStreamGuard::validateJpeg(message); +} + +void QGCWebSocketVideoSource::_onConnected() +{ + qCDebug(QGCWebSocketVideoSourceLog) << "Connected" << QGCNetworkHelper::redactedUrlForLogging(_url); + emit connected(); +} + +void QGCWebSocketVideoSource::_onDisconnected() +{ + qCDebug(QGCWebSocketVideoSourceLog) << "Disconnected" << QGCNetworkHelper::redactedUrlForLogging(_url); + const bool terminalErrorOwnsDisconnect = + _transportErrorReported || _pendingError != PendingError::None || _pendingErrorPosted; + if (_running && _appsrc && !terminalErrorOwnsDisconnect) { + (void) gst_app_src_end_of_stream(GST_APP_SRC(_appsrc)); + } + emit disconnected(); +} + +void QGCWebSocketVideoSource::_onBinaryMessageReceived(const QByteArray& message) +{ + if (_protocolViolationReported) { + return; + } + + QString validationError; + if (!QGCJpegStreamGuard::validateJpeg(message, &validationError)) { + _protocolViolationReported = true; + _transportErrorReported = true; + qCWarning(QGCWebSocketVideoSourceLog) + << "Rejecting WebSocket JPEG stream after an invalid" << message.size() << "byte frame:" << validationError; + _pendingError = PendingError::ProtocolViolation; + _pendingErrorDetail = validationError; + _postPendingErrorWhenAttached(); + if (_webSocket) { + _webSocket->close(QWebSocketProtocol::CloseCodeProtocolError, tr("Invalid JPEG frame")); + } + return; + } + _pushFrameToAppsrc(message); +} + +void QGCWebSocketVideoSource::_onTextMessageReceived(const QString& message) +{ + qCDebug(QGCWebSocketVideoSourceLog) << "Ignoring WebSocket text message of" << message.size() << "characters"; +} + +void QGCWebSocketVideoSource::_onError() +{ + if (!_webSocket || _transportErrorReported) { + return; + } + _transportErrorReported = true; + const int errorCode = static_cast(_webSocket->error()); + qCWarning(QGCWebSocketVideoSourceLog) << "WebSocket error code" << errorCode; + _pendingError = PendingError::Transport; + _pendingTransportErrorCode = errorCode; + _postPendingErrorWhenAttached(); +} + +void QGCWebSocketVideoSource::_onSslErrors(const QList& errors) +{ + if (!errors.isEmpty()) { + qCWarning(QGCWebSocketVideoSourceLog) + << "TLS verification failed with" << errors.size() << "error(s); first code" + << static_cast(errors.constFirst().error()); + } +} + +bool QGCWebSocketVideoSource::_appsrcHasReadyPipelineAncestor() const +{ + if (!_appsrc) { + return false; + } + + GstObject* current = GST_OBJECT(gst_object_ref(_appsrc)); + bool foundPipeline = false; + while (current) { + if (GST_IS_PIPELINE(current)) { + GstState state = GST_STATE_NULL; + GstState pending = GST_STATE_VOID_PENDING; + (void) gst_element_get_state(GST_ELEMENT(current), &state, &pending, 0); + foundPipeline = state >= GST_STATE_READY || pending >= GST_STATE_READY; + gst_object_unref(current); + break; + } + GstObject* parent = gst_object_get_parent(current); + gst_object_unref(current); + current = parent; + } + return foundPipeline; +} + +void QGCWebSocketVideoSource::_postPendingErrorWhenAttached() +{ + if (!_running || !_appsrc || _pendingError == PendingError::None || _pendingErrorPosted) { + return; + } + if (!_appsrcHasReadyPipelineAncestor()) { + if (!_pendingErrorRetryScheduled) { + _pendingErrorRetryScheduled = true; + QTimer::singleShot(10, this, [this]() { + _pendingErrorRetryScheduled = false; + _postPendingErrorWhenAttached(); + }); + } + return; + } + + _pendingErrorPosted = true; + switch (_pendingError) { + case PendingError::ProtocolViolation: { + const QByteArray detail = _pendingErrorDetail.toUtf8(); + GST_ELEMENT_ERROR(_appsrc, STREAM, DECODE, ("WebSocket JPEG stream was rejected"), + ("%s", detail.constData())); + break; + } + case PendingError::Transport: + GST_ELEMENT_ERROR(_appsrc, RESOURCE, OPEN_READ, ("WebSocket video connection failed"), + ("Socket error code %d", _pendingTransportErrorCode)); + break; + case PendingError::None: + break; + } + _pendingError = PendingError::None; + _pendingErrorDetail.clear(); +} + +void QGCWebSocketVideoSource::_pushFrameToAppsrc(const QByteArray& jpegData) +{ + if (!_running || !_appsrc) { + return; + } + + GstBuffer* buffer = gst_buffer_new_allocate(nullptr, static_cast(jpegData.size()), nullptr); + if (!buffer) { + qCWarning(QGCWebSocketVideoSourceLog) << "Failed to allocate JPEG buffer"; + return; + } + + gst_buffer_fill(buffer, 0, jpegData.constData(), static_cast(jpegData.size())); + GST_BUFFER_PTS(buffer) = GST_CLOCK_TIME_NONE; + GST_BUFFER_DTS(buffer) = GST_CLOCK_TIME_NONE; + GST_BUFFER_DURATION(buffer) = GST_CLOCK_TIME_NONE; + + const GstFlowReturn result = gst_app_src_push_buffer(GST_APP_SRC(_appsrc), buffer); + if (result != GST_FLOW_OK && result != GST_FLOW_FLUSHING) { + qCWarning(QGCWebSocketVideoSourceLog) << "Failed to push JPEG buffer:" << result; + } +} diff --git a/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h new file mode 100644 index 000000000000..0b2e99b34757 --- /dev/null +++ b/src/VideoManager/VideoReceiver/GStreamer/QGCWebSocketVideoSource.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "VideoReceiver.h" + +class QWebSocket; + +class QGCWebSocketVideoSource : public QObject +{ + Q_OBJECT + +public: + explicit QGCWebSocketVideoSource(const QUrl& url, const VideoReceiver::NetworkSourceConfig& config, + GstElement* appsrc, QObject* parent = nullptr); + ~QGCWebSocketVideoSource() override; + + bool start(QString& error); + void stop(); + + static bool isCompleteJpeg(const QByteArray& message); + +signals: + void connected(); + void disconnected(); + +private slots: + void _onConnected(); + void _onDisconnected(); + void _onBinaryMessageReceived(const QByteArray& message); + void _onTextMessageReceived(const QString& message); + void _onError(); + void _onSslErrors(const QList& errors); + +private: + enum class PendingError + { + None, + ProtocolViolation, + Transport, + }; + + bool _appsrcHasReadyPipelineAncestor() const; + void _postPendingErrorWhenAttached(); + void _pushFrameToAppsrc(const QByteArray& jpegData); + + QUrl _url; + VideoReceiver::NetworkSourceConfig _config; + GstElement* _appsrc = nullptr; + QWebSocket* _webSocket = nullptr; + bool _running = false; + bool _protocolViolationReported = false; + bool _transportErrorReported = false; + PendingError _pendingError = PendingError::None; + QString _pendingErrorDetail; + int _pendingTransportErrorCode = 0; + bool _pendingErrorPosted = false; + bool _pendingErrorRetryScheduled = false; +}; diff --git a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc index e53942f70877..11d5888f45fe 100644 --- a/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc +++ b/src/VideoManager/VideoReceiver/QtMultimedia/QtMultimediaReceiver.cc @@ -137,12 +137,13 @@ void QtMultimediaReceiver::start(uint32_t timeout) return; } - if (_uri.isEmpty()) { + const QString sourceUri = uri(); + if (sourceUri.isEmpty()) { qCDebug(QtMultimediaReceiverLog) << "Failed because URI is not specified"; emit onStartComplete(STATUS_INVALID_URL); return; } - _mediaPlayer->setSource(QUrl::fromUserInput(_uri)); + _mediaPlayer->setSource(QUrl::fromUserInput(sourceUri)); _frameTimer.setInterval(timeout); @@ -264,7 +265,7 @@ void QtMultimediaReceiver::startRecording(const QString& videoFile, FILE_FORMAT } _mediaRecorder->setOutputLocation(QUrl::fromLocalFile(videoFile)); - _recordingOutput = _mediaRecorder->outputLocation().toLocalFile(); + _setRecordingOutput(_mediaRecorder->outputLocation().toLocalFile()); _mediaRecorder->record(); qCDebug(QtMultimediaReceiverLog) << "Recording"; diff --git a/src/VideoManager/VideoReceiver/VideoReceiver.h b/src/VideoManager/VideoReceiver/VideoReceiver.h index 627b2a9e435f..fe3a2197dc99 100644 --- a/src/VideoManager/VideoReceiver/VideoReceiver.h +++ b/src/VideoManager/VideoReceiver/VideoReceiver.h @@ -1,12 +1,19 @@ #pragma once #include +#include +#include +#include +#include #include #include +#include #include #include +#include "SecureMemory.h" + class QGCVideoStreamInfo; class QQuickItem; @@ -19,32 +26,98 @@ class VideoReceiver : public QObject /// Backend-specific decoded-frame sink. using VideoSinkHandle = void *; + struct NetworkSourceConfig + { + enum class Authentication : uint8_t + { + None = 0, + Basic, + Bearer, + }; + + Authentication authentication = Authentication::None; + QString username; + QByteArray secret; + QString origin; + QString caCertificateFile; + + bool operator==(const NetworkSourceConfig& other) const = default; + bool hasAuthentication() const { return authentication != Authentication::None; } + void clearSecret() + { + secret.detach(); + QGC::secureZero(secret); + } + }; + explicit VideoReceiver(QObject *parent = nullptr) : QObject(parent) {} + ~VideoReceiver() override + { + const QMutexLocker locker(&_networkSourceConfigMutex); + _networkSourceConfig.clearSecret(); + } + bool isThermal() const { return (_name == QStringLiteral("thermalVideo")); } VideoSinkHandle sink() const { return _sink; } QQuickItem *widget() { return _widget; } QString name() const { return _name; } - QString uri() const { return _uri; } + QString uri() const + { + const QMutexLocker locker(&_uriMutex); + return _uri; + } bool started() const { return _started; } bool lowLatency() const { return _lowLatency; } int rtpJitterLatencyMs() const { return _rtpJitterLatencyMs; } bool autoReconnect() const { return _autoReconnect; } QGCVideoStreamInfo *videoStreamInfo() { return _videoStreamInfo; } - QString recordingOutput() const { return _recordingOutput; } + QString recordingOutput() const + { + const QMutexLocker locker(&_recordingOutputMutex); + return _recordingOutput; + } virtual void setSink(VideoSinkHandle sink) { if (sink != _sink) { _sink = sink; emit sinkChanged(_sink); } } virtual void setWidget(QQuickItem *widget) { if (widget != _widget) { _widget = widget; emit widgetChanged(_widget); } } void setName(const QString &name) { if (name != _name) { _name = name; emit nameChanged(_name); } } - void setUri(const QString &uri) { if (uri != _uri) { _uri = uri; emit uriChanged(_uri); } } + void setUri(const QString &uri) + { + QString changedUri; + { + const QMutexLocker locker(&_uriMutex); + if (uri == _uri) { + return; + } + _uri = uri; + changedUri = _uri; + } + emit uriChanged(changedUri); + } void setStarted(bool started) { if (started != _started) { _started = started; emit startedChanged(_started); } } void setLowLatency(bool lowLatency) { if (lowLatency != _lowLatency) { _lowLatency = lowLatency; emit lowLatencyChanged(_lowLatency); } } void setRtpJitterLatencyMs(int ms) { if (ms != _rtpJitterLatencyMs) { _rtpJitterLatencyMs = ms; emit rtpJitterLatencyMsChanged(_rtpJitterLatencyMs); } } void setAutoReconnect(bool enabled) { if (enabled != _autoReconnect) { _autoReconnect = enabled; emit autoReconnectChanged(_autoReconnect); } } void setVideoStreamInfo(QGCVideoStreamInfo *videoStreamInfo) { if (videoStreamInfo != _videoStreamInfo) { _videoStreamInfo = videoStreamInfo; emit videoStreamInfoChanged(); } } + bool setNetworkSourceConfig(const NetworkSourceConfig& config) + { + const QMutexLocker locker(&_networkSourceConfigMutex); + if (_networkSourceConfig == config) { + return false; + } + + _networkSourceConfig.clearSecret(); + _networkSourceConfig = config; + return true; + } + NetworkSourceConfig networkSourceConfig() const + { + const QMutexLocker locker(&_networkSourceConfigMutex); + return _networkSourceConfig; + } // QMediaFormat::FileFormat enum FILE_FORMAT { @@ -105,10 +178,17 @@ public slots: virtual void takeScreenshot(const QString &imageFile) = 0; protected: + void _setRecordingOutput(const QString& output) + { + const QMutexLocker locker(&_recordingOutputMutex); + _recordingOutput = output; + } + VideoSinkHandle _sink = nullptr; QQuickItem *_widget = nullptr; QGCVideoStreamInfo *_videoStreamInfo = nullptr; QString _name; + mutable QMutex _uriMutex; QString _uri; bool _started = false; // Flipped on streaming threads, read cross-thread (e.g. tee probe logging). @@ -120,7 +200,8 @@ public slots: // Written live on the GUI thread, read on the receiver worker thread. std::atomic _autoReconnect = true; ///< RTSP/UDP auto-reconnect with exponential backoff on watchdog/error. bool _resetVideoSink = false; - bool _endOfStream = false; + // Written by a streaming-thread source-pad probe, read by bus/worker EOS handling. + std::atomic _endOfStream = false; bool _removingDecoder = false; bool _removingRecorder = false; // buffer: @@ -134,7 +215,10 @@ public slots: int _statsTickCounter = 0; QTimer _watchdogTimer; uint32_t _timeout = 0; + mutable QMutex _recordingOutputMutex; QString _recordingOutput; + mutable QMutex _networkSourceConfigMutex; + NetworkSourceConfig _networkSourceConfig; // bool _initialized = false; // bool _fullScreen = false; diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 589b8fb8cc24..e99a3b7e8d60 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -343,6 +343,18 @@ void QGCNetworkHelperTest::_testUrlWithoutQuery() QVERIFY(result.fragment().isEmpty()); } +void QGCNetworkHelperTest::_testRedactedUrlForLogging() +{ + const QUrl original(QStringLiteral("https://user:secret@example.com:8443/live?token=abc#viewer")); + const QString redacted = QGCNetworkHelper::redactedUrlForLogging(original); + + QCOMPARE(redacted, QStringLiteral("https://example.com:8443/live")); + QVERIFY(!redacted.contains(QStringLiteral("user"))); + QVERIFY(!redacted.contains(QStringLiteral("secret"))); + QVERIFY(!redacted.contains(QStringLiteral("token"))); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("not a URL")), QStringLiteral("")); +} + // ============================================================================ // Request Configuration Tests // ============================================================================ diff --git a/test/Utilities/Network/QGCNetworkHelperTest.h b/test/Utilities/Network/QGCNetworkHelperTest.h index 4b14265c6178..c61eb1e1a8f2 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.h +++ b/test/Utilities/Network/QGCNetworkHelperTest.h @@ -38,6 +38,7 @@ private slots: void _testBuildUrlFromMap(); void _testBuildUrlFromList(); void _testUrlWithoutQuery(); + void _testRedactedUrlForLogging(); // Request configuration tests void _testDefaultUserAgent(); diff --git a/test/VideoManager/CMakeLists.txt b/test/VideoManager/CMakeLists.txt index 834dfa35f760..cfafd57d6939 100644 --- a/test/VideoManager/CMakeLists.txt +++ b/test/VideoManager/CMakeLists.txt @@ -8,6 +8,9 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE VideoManagerInitTest.cc VideoManagerInitTest.h + VideoSettingsTest.cc + VideoSettingsTest.h ) add_qgc_test(VideoManagerInitTest LABELS Unit) +add_qgc_test(VideoSettingsTest LABELS Unit) diff --git a/test/VideoManager/GStreamer/CMakeLists.txt b/test/VideoManager/GStreamer/CMakeLists.txt index 985de203303b..515566b6b65f 100644 --- a/test/VideoManager/GStreamer/CMakeLists.txt +++ b/test/VideoManager/GStreamer/CMakeLists.txt @@ -4,6 +4,7 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE GStreamerTest.cc GStreamerTest.h + NetworkVideoTlsFixture.h HwBuffers/common/GStreamerHwBuffersCommonTest.cc HwBuffers/d3d/GStreamerD3DTest.cc HwBuffers/dmabuf/GStreamerDmaBufTest.cc diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index 9f539c615f21..91ca68419a33 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -431,6 +431,31 @@ void GStreamerTest::_testWritePipelineDotReturnsEmptyOnWriteFailure() QVERIFY2(path.isEmpty(), qPrintable(QStringLiteral("Expected empty path for failed dot write, got %1").arg(path))); } +void GStreamerTest::_testPipelineDotOmitsElementProperties() +{ + GstElement* pipeline = gst_pipeline_new("safe-dot-test"); + GstElement* source = gst_element_factory_make("filesrc", "source"); + QVERIFY(pipeline); + QVERIFY(source); + const auto cleanup = qScopeGuard([&] { gst_clear_object(&pipeline); }); + + constexpr const char* secret = "/tmp/qgc-dot-secret-token"; + g_object_set(source, "location", secret, nullptr); + QVERIFY(gst_bin_add(GST_BIN(pipeline), source)); + + gchar* unsafeDot = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL); + QVERIFY(unsafeDot); + const QByteArray unsafeData(unsafeDot); + g_free(unsafeDot); + QVERIFY2(unsafeData.contains(secret), "The test property must be visible with SHOW_ALL"); + + gchar* safeDot = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GStreamer::kSafePipelineGraphDetails); + QVERIFY(safeDot); + const QByteArray safeData(safeDot); + g_free(safeDot); + QVERIFY2(!safeData.contains(secret), "Persisted pipeline graphs must omit element property values"); +} + void GStreamerTest::_testCompleteInit() { GStreamer::redirectGLibLogging(); @@ -517,6 +542,7 @@ QGC_GST_SKIP_TEST(_testConfigureDebugLoggingIsIdempotent) QGC_GST_SKIP_TEST(_testVerifyRequiredPlugins) QGC_GST_SKIP_TEST(_testEnvironmentSetup) QGC_GST_SKIP_TEST(_testWritePipelineDotReturnsEmptyOnWriteFailure) +QGC_GST_SKIP_TEST(_testPipelineDotOmitsElementProperties) QGC_GST_SKIP_TEST(_testCompleteInit) QGC_GST_SKIP_TEST(_testCreateVideoReceiver) QGC_GST_SKIP_TEST(_testBindDebugLevelFactRejectsNullContext) @@ -573,11 +599,35 @@ QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadUri) QGC_GST_SKIP_TEST(_testSourceFactoryTcpMpegTs) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadTcpUri) QGC_GST_SKIP_TEST(_testSourceFactoryUdp265Caps) +QGC_GST_SKIP_TEST(_testSourceFactoryUdp265UsesExplicitDepayAndParser) QGC_GST_SKIP_TEST(_testSourceFactoryUdpH264Caps) QGC_GST_SKIP_TEST(_testSourceFactoryUdpMpegTs) QGC_GST_SKIP_TEST(_testSourceFactorySchemeCaseInsensitive) QGC_GST_SKIP_TEST(_testSourceFactoryNegativeLatencyClamped) QGC_GST_SKIP_TEST(_testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpeg) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpegAuthRequiresHttps) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpegSecurityProperties) +QGC_GST_SKIP_TEST(_testHttpMjpegDelivery) +QGC_GST_SKIP_TEST(_testPipelineDiagnosticRedactionUsesGeneration) +QGC_GST_SKIP_TEST(_testHttpsMjpegTlsAuth) +QGC_GST_SKIP_TEST(_testHttpsMjpegRejectsUntrustedCa) +QGC_GST_SKIP_TEST(_testHttpsMjpegAuthRedirectNotFollowed) +QGC_GST_SKIP_TEST(_testNetworkJpegValidation) +QGC_GST_SKIP_TEST(_testMultipartJpegGuard) +QGC_GST_SKIP_TEST(_testJpegRecordingContainers) +QGC_GST_SKIP_TEST(_testJpegReceiverRecording) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketJpeg) +QGC_GST_SKIP_TEST(_testSourceFactoryWebSocketAuthRequiresWss) +QGC_GST_SKIP_TEST(_testWebSocketJpegValidation) +QGC_GST_SKIP_TEST(_testWebSocketJpegDelivery) +QGC_GST_SKIP_TEST(_testWebSocketRejectsInvalidFrame) +QGC_GST_SKIP_TEST(_testWebSocketEarlyTransportFailureAfterDelayedParenting) +QGC_GST_SKIP_TEST(_testWebSocketEarlyProtocolFailureAfterDelayedParenting) +QGC_GST_SKIP_TEST(_testWebSocketJpegTlsAuth) +QGC_GST_SKIP_TEST(_testWebSocketAuthRedirectNotFollowed) +QGC_GST_SKIP_TEST(_testWebSocketJpegRejectsUntrustedCa) +QGC_GST_SKIP_TEST(_testWebSocketThreadTeardown) QGC_GST_SKIP_TEST(_testColorimetryColorRangeMapping) QGC_GST_SKIP_TEST(_testPixelFormatAcceptedButNotAdvertised) QGC_GST_SKIP_TEST(_testAdvertisedFormatListMatchesTable) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index f1658fe73461..b8862f68b4c3 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -22,6 +22,7 @@ private slots: void _testVerifyRequiredPlugins(); void _testEnvironmentSetup(); void _testWritePipelineDotReturnsEmptyOnWriteFailure(); + void _testPipelineDotOmitsElementProperties(); void _testCompleteInit(); void _testCreateVideoReceiver(); void _testBindDebugLevelFactRejectsNullContext(); @@ -92,6 +93,30 @@ private slots: void _testSourceFactorySchemeCaseInsensitive(); void _testSourceFactoryNegativeLatencyClamped(); void _testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer(); + void _testSourceFactoryHttpMjpeg(); + void _testSourceFactoryHttpMjpegAuthRequiresHttps(); + void _testSourceFactoryHttpMjpegSecurityProperties(); + void _testHttpMjpegDelivery(); + void _testPipelineDiagnosticRedactionUsesGeneration(); + void _testHttpsMjpegTlsAuth(); + void _testHttpsMjpegRejectsUntrustedCa(); + void _testHttpsMjpegAuthRedirectNotFollowed(); + void _testNetworkJpegValidation(); + void _testMultipartJpegGuard(); + void _testJpegRecordingContainers(); + void _testRecordingEosSeqnumClassification(); + void _testJpegReceiverRecording(); + void _testSourceFactoryWebSocketJpeg(); + void _testSourceFactoryWebSocketAuthRequiresWss(); + void _testWebSocketJpegValidation(); + void _testWebSocketJpegDelivery(); + void _testWebSocketRejectsInvalidFrame(); + void _testWebSocketEarlyTransportFailureAfterDelayedParenting(); + void _testWebSocketEarlyProtocolFailureAfterDelayedParenting(); + void _testWebSocketJpegTlsAuth(); + void _testWebSocketAuthRedirectNotFollowed(); + void _testWebSocketJpegRejectsUntrustedCa(); + void _testWebSocketThreadTeardown(); void _testColorimetryColorRangeMapping(); void _testPixelFormatAcceptedButNotAdvertised(); void _testAdvertisedFormatListMatchesTable(); diff --git a/test/VideoManager/GStreamer/NetworkVideoTlsFixture.h b/test/VideoManager/GStreamer/NetworkVideoTlsFixture.h new file mode 100644 index 000000000000..be95be9bae40 --- /dev/null +++ b/test/VideoManager/GStreamer/NetworkVideoTlsFixture.h @@ -0,0 +1,72 @@ +#pragma once + +namespace NetworkVideoTlsFixture { + +// Test-only CA and localhost certificate, valid through 2036-07-09. +inline constexpr char kCaCertificatePem[] = R"CERT(-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIUIkW/p1Ljc16ZQdLZmT+WoPUpn34wDQYJKoZIhvcNAQEL +BQAwJDEiMCAGA1UEAwwZUUdDIE5ldHdvcmsgVmlkZW8gVGVzdCBDQTAeFw0yNjA3 +MTIxODM1NDlaFw0zNjA3MDkxODM1NDlaMCQxIjAgBgNVBAMMGVFHQyBOZXR3b3Jr +IFZpZGVvIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDo +TEP7TvZMQyV3x1Ibto7WdDG9RQdaOmpspGB2ACmxsUE9aYV1y05NuBLbQJdygHAX +vWub2a6iqwmnLKacVw49aVXrbHBZUN9FLGdOFOQVis3+FEctVBO29YTYEg+g9doA +E30TIwKsYdTLh2dktDIHexdX5jfaIA6XzM3jjDvdz15SmBt8PNZVueEX+BvKf+4R +7ZwIXQQCEL9LJzzPRj19A6aCc9DBaLUOBprTMDN5rGFhWbJF7/F8s1opNWp418li +2rDe5hHkMXC1rAi4GIlvxO8iA7QcSJo5rlKIzk+b0NC4KpV9da975ZFFUWVRNXea +P0ClUOc6AXxfGDP0DA7LAgMBAAGjUzBRMB0GA1UdDgQWBBRL1KZQkeSDvSdP42ON +bMwGzyhAQTAfBgNVHSMEGDAWgBRL1KZQkeSDvSdP42ONbMwGzyhAQTAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC0M6MEJ3EM4nO8Q7si7Fz2joV1 +kEOvUPONoXu32hrndeUw8FQ3eTa2VI+uwwKq7cwKvbh55MPM/U8cSg9QCP7KZU7h +TWnyqSq5Bam9AzDWJiF+HMRm4XV8Y71Iof0StH6BRKHMBOnUyHXF/nQMwQt+3OVH +gqU4FgEIe4TbFAttpmiWu5LE9BXAsuwHZQGSfD3EZEZN/VIZwYlVJHLyO0AJ5Ph5 +6i0FKPTZSC+y3+N76e+9q+FeXv7rhLQNiK1aJvyUJ9JVn6/EhqGt3UjGJ44RBf4+ +v8wyQ/PhAwFo1prrmTZ7AGocOLreUv2FPOjt1kIc5EAfEa9MqxxeX4mmjvFz +-----END CERTIFICATE----- +)CERT"; + +inline constexpr char kServerCertificatePem[] = R"CERT(-----BEGIN CERTIFICATE----- +MIIDRzCCAi+gAwIBAgIUD/LfnDiXjqTDaO/5uJBTePRu0f8wDQYJKoZIhvcNAQEL +BQAwJDEiMCAGA1UEAwwZUUdDIE5ldHdvcmsgVmlkZW8gVGVzdCBDQTAeFw0yNjA3 +MTIxODM1NTBaFw0zNjA3MDkxODM1NTBaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALO1iDRkRZsJ+6ve9/na/g47 +6ApVwDypwN6YbX2PNyFlwdt6XNFP5jDhwrM6FO8RZ24/VRGYs1hSt6Qo3vEel/3L +USXdKQcmmNKJWjURYBPM3RzlSG2oEHBz1WD0HnmteWZOm+E0hBhrsbcMVVrRH8h2 +YW9BCRxANdyOYfxTnUkxSosHrYKvwu/c1ZDFJ7/taIePeBcQxO21uSq2FJ1UXfSG +lZWNvkoiSpgXTJqRpMyw16QIYlJho0YAja99n+R4p2gotS6dGS12+Sx6DiZnXx9a +9hVa2Mv7IleQOnQi17xEi8MVTHDVdHRocdcavdXro7iDSMwEVxX93Wpl48PCYa0C +AwEAAaOBgDB+MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATATBgNVHSUEDDAK +BggrBgEFBQcDATALBgNVHQ8EBAMCBaAwHQYDVR0OBBYEFMv1wca4vp53sj8XSxqE +P8UycN6qMB8GA1UdIwQYMBaAFEvUplCR5IO9J0/jY41szAbPKEBBMA0GCSqGSIb3 +DQEBCwUAA4IBAQCKRKLy2nrtul4FoGvkISmVquqn2X24G5QCm4VIICekpf+wTYLZ +FvW6MDl5HlTlEKEScE7CSKyO59K61LQyMHSqLd1wXJU/a2zvxA4FiOhbbhrJcyPR +7Rbxcko1vzFwmXxtpBcItuGYaclAQI5fcK5nPoCBfsf4QgO8/gfKbVhI5k/eXH0v +JlxTonRcUTM4fuig8LqLI3HUcYQ3pFt1ACN7MQ0yO1c6Y2U2zYcKeMxiiKaKCLBn +s9hfLnqH1LHVN4R0jSGaHV+ASQ11gMxuh7nctBdEqhHKYlLGxcemTPiR802tgChW +Fs/YoJ6X9vCu86oTib2AmPikwVwAgJB2xaCb +-----END CERTIFICATE----- +)CERT"; + +inline constexpr char kServerPrivateKeyDerBase64[] = + "MIIEowIBAAKCAQEAs7WINGRFmwn7q973+dr+DjvoClXAPKnA3phtfY83IWXB23pc0U/mMOHCszoU" + "7xFnbj9VEZizWFK3pCje8R6X/ctRJd0pByaY0olaNRFgE8zdHOVIbagQcHPVYPQeea15Zk6b4TSE" + "GGuxtwxVWtEfyHZhb0EJHEA13I5h/FOdSTFKiwetgq/C79zVkMUnv+1oh494FxDE7bW5KrYUnVRd" + "9IaVlY2+SiJKmBdMmpGkzLDXpAhiUmGjRgCNr32f5HinaCi1Lp0ZLXb5LHoOJmdfH1r2FVrYy/si" + "V5A6dCLXvESLwxVMcNV0dGhx1xq91eujuINIzARXFf3damXjw8JhrQIDAQABAoIBAAx8+7laU3z1" + "6WOU88oM5hVCo/od4eVFTaYaVReB3YyoV9ui6fLQuNgiRCe0zFIdtUCTzZtSr3nei6zweyep3tmP" + "6LR3JTv+OmX9DgP8kF7n4GC67TjTkkriPcyGQlzlyXcq1MnGXryctUWZaZANUsZZNaWn0RSBwMxy" + "J4yDmf1FPgtVgY/dDdK+9YynvIeg2j7W1ufQS/poQ9N3r0eClg8+v8j96+R8SlqAkCXoyJadbABq" + "T1FeBXCygk3Mti3icCQGywgWbtH0cj9arLZErSkqtiIG7odZXpic0seUhA/ef7AN++fv+6JnSuj9" + "aKY1ljzViRZugYzS/zcmgIINO6ECgYEA3cdXTnNmmUiPTkNbucpb7MUsYiNO1CApxGBOZUsV23qR" + "7lExaNVx1Pn/nCiCAtMaVwDJndr2iOOJ/R6g115V86TyRHiCdlcgZkpwL2fKgI28TxJGMvOvH78k" + "wr2NbBnoBgy2EsW+ZjOoDJedXaM6OVceigvA9pa5KREZBwo9wE0CgYEAz3Bd9Rve3u6TrXagb5U6" + "loTmt/yLE3ziTC+gdVetFll58Uw3+L2ZWgPztKKE3IK28aGjxBO8nsOK9IxdWob43ZXwL+DeZZn3" + "ZZLpXLswrkrM/um5yxVHG9lrJoWCOvnPAeFHk5xXQkhmfwFGKyRcJWZPiziiX/yaTEP0szWs1uEC" + "gYEAtjIdt4V2tFa35EPSB0AHZOxXGgiHqh0CMyIrRWv5+OUpHe/193nimmUHaPKeXFOxP+iVuek4" + "wByuMBQJbuVBF3haz4VxKGdLZr2gjFFoO1Q1b4BDy3gGVr5hJNs0Y6qkwtOOgL2TPcMSO8YSsep2" + "2sSHgkFFtU79ro8tUGtJb8ECgYAGNC4bLoIz2J7CCVIzBBuEdOURi5P9OTbrYGFEISMkD/j9pnHm" + "FoWHk6auOE4Q6jfech8bthtmBCMbvTbthivbNKjCRc9g6oHjn8kq5M5H6CTJWuQblr9Rrebud/+1" + "E9OFEt+5ImvZp6CpG7ilgajf1Xd7im4QwNU8gqvG0EwjAQKBgA4yRrt7SRM6HTZzy31+tj4qT9Tc" + "SAmppx6+I6NWTfLi24mGQLcHdNBQckPuK2xaNoqgUhXHlSpRvZ15OYCcskyrLaL4rC2Rih7ohQoG" + "TK7UQXIoq36tpQCPvE2SUvZ/AyuyoBOJXsZYMeu4+mIQXxSTW4covokQi0M7OXxxFJ14"; + +} // namespace NetworkVideoTlsFixture diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 35895f2a9dba..10c7375ded22 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -2,14 +2,76 @@ #ifdef QGC_GST_STREAMING +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include + +#ifdef QGC_HAS_WEBSOCKET_VIDEO +#include +#include +#endif #include "GstSourceFactory.h" +#include "GstVideoReceiver.h" +#include "NetworkVideoTlsFixture.h" +#include "QGCJpegStreamGuard.h" +#ifdef QGC_HAS_WEBSOCKET_VIDEO +#include "QGCWebSocketVideoSource.h" +#endif namespace { +class RawConnectionObservingSslServer final : public QSslServer +{ +public: + int rawConnectionCount() const { return _rawConnectionCount; } + + void setRawConnectionObserver(std::function observer) { _rawConnectionObserver = std::move(observer); } + +protected: + void incomingConnection(qintptr socketDescriptor) override + { + ++_rawConnectionCount; + if (_rawConnectionObserver) { + _rawConnectionObserver(); + } + QSslServer::incomingConnection(socketDescriptor); + } + +private: + int _rawConnectionCount = 0; + std::function _rawConnectionObserver; +}; + // Borrowed (bin-owned) first child whose element-factory name matches, or nullptr. GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) { @@ -42,6 +104,100 @@ GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) return match; } +struct BufferProbeContext +{ + std::mutex mutex; + QByteArray data; + std::atomic_bool observed{false}; +}; + +GstPadProbeReturn captureBufferProbe(GstPad*, GstPadProbeInfo* info, gpointer userData) +{ + GstBuffer* buffer = GST_PAD_PROBE_INFO_BUFFER(info); + if (!buffer) { + return GST_PAD_PROBE_OK; + } + + GstMapInfo map = GST_MAP_INFO_INIT; + if (gst_buffer_map(buffer, &map, GST_MAP_READ)) { + auto* context = static_cast(userData); + { + const std::lock_guard lock(context->mutex); + context->data = QByteArray(reinterpret_cast(map.data), static_cast(map.size)); + } + context->observed.store(true, std::memory_order_release); + gst_buffer_unmap(buffer, &map); + } + return GST_PAD_PROBE_OK; +} + +QByteArray createTestJpeg(int size = 4) +{ + QByteArray jpeg; + QBuffer buffer(&jpeg); + if (!buffer.open(QIODevice::WriteOnly)) { + return {}; + } + + QImage image(size, size, QImage::Format_RGB32); + image.fill(Qt::red); + if (!image.save(&buffer, "JPEG")) { + return {}; + } + return jpeg; +} + +GstMessage* waitForBusMessage(GstBus* bus, GstMessageType types, int timeoutMs) +{ + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + if (GstMessage* message = gst_bus_pop_filtered(bus, types)) { + return message; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(5); + } + return gst_bus_pop_filtered(bus, types); +} + +QSslConfiguration testServerSslConfiguration() +{ + QSslConfiguration configuration = QSslConfiguration::defaultConfiguration(); + configuration.setLocalCertificateChain( + QSslCertificate::fromData(QByteArray(NetworkVideoTlsFixture::kServerCertificatePem), QSsl::Pem)); + configuration.setPrivateKey( + QSslKey(QByteArray::fromBase64(NetworkVideoTlsFixture::kServerPrivateKeyDerBase64), QSsl::Rsa, QSsl::Der)); + configuration.setPeerVerifyMode(QSslSocket::VerifyNone); + return configuration; +} + +bool writeTestCaFile(QFile& file) +{ + return file.open(QIODevice::WriteOnly) && file.write(NetworkVideoTlsFixture::kCaCertificatePem) > 0 && file.flush(); +} + +bool replaceJpegDimensions(QByteArray& jpeg, quint16 width, quint16 height) +{ + for (qsizetype offset = 0; (offset + 8) < jpeg.size(); ++offset) { + if (static_cast(jpeg.at(offset)) != 0xFF) { + continue; + } + const quint8 marker = static_cast(jpeg.at(offset + 1)); + const bool isStartOfFrame = ((marker >= 0xC0) && (marker <= 0xC3)) || ((marker >= 0xC5) && (marker <= 0xC7)) || + ((marker >= 0xC9) && (marker <= 0xCB)) || ((marker >= 0xCD) && (marker <= 0xCF)); + if (!isStartOfFrame) { + continue; + } + jpeg[offset + 5] = static_cast((height >> 8) & 0xFF); + jpeg[offset + 6] = static_cast(height & 0xFF); + jpeg[offset + 7] = static_cast((width >> 8) & 0xFF); + jpeg[offset + 8] = static_cast(width & 0xFF); + return true; + } + return false; +} + } // namespace void GStreamerTest::_testSourceFactoryUdpRtpJitterBuffer() @@ -354,4 +510,1587 @@ void GStreamerTest::_testSourceFactoryDynamicRtpLinkFailureCleansJitterBuffer() "a failed dynamic RTP pad link must remove its temporary jitterbuffer"); } +void GStreamerTest::_testSourceFactoryHttpMjpeg() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("http://127.0.0.1:5077/video_feed"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + QVERIFY2(findChildByFactoryName(bin, "souphttpsrc"), "http:// must build souphttpsrc"); + QVERIFY2(findChildByFactoryName(bin, "multipartdemux"), "HTTP MJPEG must demux multipart/x-mixed-replace"); + QVERIFY2(findChildByFactoryName(bin, "jpegparse"), "HTTP MJPEG must expose parsed JPEG frames"); + QVERIFY2(!findChildByFactoryName(bin, "rtpjitterbuffer"), "HTTP MJPEG is not RTP; no jitterbuffer"); +} + +void GStreamerTest::_testSourceFactoryHttpMjpegAuthRequiresHttps() +{ + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + config.networkSourceConfig.secret = QByteArrayLiteral("secret-token"); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Authenticated HTTP MJPEG video requires HTTPS"))); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("http://127.0.0.1:5077/video_feed"), config)); +} + +void GStreamerTest::_testSourceFactoryHttpMjpegSecurityProperties() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + { + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Basic; + config.networkSourceConfig.username = QStringLiteral("viewer"); + config.networkSourceConfig.secret = QByteArrayLiteral("test-password"); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("https://camera.example/mjpeg"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* source = findChildByFactoryName(bin, "souphttpsrc"); + QVERIFY(source); + gboolean automaticRedirect = TRUE; + gboolean strictTls = FALSE; + GObject* tlsDatabase = nullptr; + gchar* userId = nullptr; + gchar* userPassword = nullptr; + g_object_get(source, "automatic-redirect", &automaticRedirect, "ssl-strict", &strictTls, "tls-database", + &tlsDatabase, "user-id", &userId, "user-pw", &userPassword, nullptr); + const auto credentialsCleanup = qScopeGuard([&] { + g_clear_object(&tlsDatabase); + g_free(userId); + g_free(userPassword); + }); + QCOMPARE(automaticRedirect, FALSE); + QCOMPARE(strictTls, TRUE); + QVERIFY(!tlsDatabase); + QCOMPARE(QByteArray(userId), QByteArrayLiteral("viewer")); + QCOMPARE(QByteArray(userPassword), QByteArrayLiteral("test-password")); + } + + { + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + config.networkSourceConfig.secret = QByteArrayLiteral("test-token"); + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("https://camera.example/mjpeg"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* source = findChildByFactoryName(bin, "souphttpsrc"); + QVERIFY(source); + gboolean automaticRedirect = TRUE; + GstStructure* headers = nullptr; + g_object_get(source, "automatic-redirect", &automaticRedirect, "extra-headers", &headers, nullptr); + QVERIFY(headers); + const auto headersCleanup = qScopeGuard([&] { gst_structure_free(headers); }); + QCOMPARE(automaticRedirect, FALSE); + QCOMPARE(QByteArray(gst_structure_get_string(headers, "Authorization")), + QByteArrayLiteral("Bearer test-token")); + QCOMPARE(QByteArray(gst_structure_get_string(headers, "Origin")), + QByteArrayLiteral("https://operator.example.test")); + } + + { + QTemporaryDir caDirectory; + QVERIFY(caDirectory.isValid()); + const QString caPath = QDir(caDirectory.path()).filePath(QStringLiteral("qgc-\u6d4b\u8bd5-ca.pem")); + QFile caFile(caPath); + QVERIFY(writeTestCaFile(caFile)); + caFile.close(); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.caCertificateFile = caPath; + GstElement* bin = GStreamer::SourceFactory::create(QStringLiteral("https://camera.example/mjpeg"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* source = findChildByFactoryName(bin, "souphttpsrc"); + QVERIFY(source); + gboolean automaticRedirect = TRUE; + GObject* tlsDatabase = nullptr; + g_object_get(source, "automatic-redirect", &automaticRedirect, "tls-database", &tlsDatabase, nullptr); + const auto databaseCleanup = qScopeGuard([&] { g_clear_object(&tlsDatabase); }); + QCOMPARE(automaticRedirect, FALSE); + QVERIFY(tlsDatabase); + } +} + +void GStreamerTest::_testHttpMjpegDelivery() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("fakesink")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + const QByteArray jpeg = createTestJpeg(); + QVERIFY(!jpeg.isEmpty()); + + QTcpServer server; + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QTcpSocket* serverSocket = nullptr; + QByteArray requestBytes; + bool responseSent = false; + bool responseWriteAccepted = false; + connect(&server, &QTcpServer::pendingConnectionAvailable, this, [&]() { + serverSocket = server.nextPendingConnection(); + if (!serverSocket) { + return; + } + connect(serverSocket, &QTcpSocket::readyRead, this, [&]() { + requestBytes += serverSocket->readAll(); + if (responseSent || !requestBytes.contains("\r\n\r\n")) { + return; + } + + QByteArray response = QByteArrayLiteral( + "HTTP/1.1 200 OK\r\n" + "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n" + "--frame\r\n" + "Content-Type: image/jpeg\r\n"); + response += + QByteArrayLiteral("Content-Length: ") + QByteArray::number(jpeg.size()) + QByteArrayLiteral("\r\n\r\n"); + response += jpeg; + response += QByteArrayLiteral("\r\n--frame\r\n"); + responseWriteAccepted = serverSocket->write(response) == static_cast(response.size()); + serverSocket->flush(); + responseSent = true; + }); + }); + + GStreamer::SourceFactory::Config config; + config.timeoutS = 2; + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + const QString url = QStringLiteral("http://127.0.0.1:%1/mjpeg").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + + GstElement* httpSource = findChildByFactoryName(sourceBin, "souphttpsrc"); + QVERIFY(httpSource); + gboolean automaticRedirect = TRUE; + gboolean strictTls = FALSE; + g_object_get(httpSource, "automatic-redirect", &automaticRedirect, "ssl-strict", &strictTls, nullptr); + QCOMPARE(automaticRedirect, FALSE); + QCOMPARE(strictTls, TRUE); + + GstElement* parser = findChildByFactoryName(sourceBin, "jpegparse"); + QVERIFY(parser); + GstPad* parserPad = gst_element_get_static_pad(parser, "src"); + QVERIFY(parserPad); + BufferProbeContext probeContext; + const gulong bufferProbeId = + gst_pad_add_probe(parserPad, GST_PAD_PROBE_TYPE_BUFFER, captureBufferProbe, &probeContext, nullptr); + QVERIFY(bufferProbeId != 0); + const auto probeCleanup = qScopeGuard([&] { + gst_pad_remove_probe(parserPad, bufferProbeId); + gst_object_unref(parserPad); + }); + + GstElement* pipeline = gst_pipeline_new("http-mjpeg-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + g_object_set(sink, "sync", FALSE, nullptr); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(probeContext.observed.load(std::memory_order_acquire), TestTimeout::mediumMs()); + QVERIFY(responseSent); + QVERIFY(responseWriteAccepted); + QVERIFY(requestBytes.startsWith("GET /mjpeg HTTP/1.1\r\n")); + const QByteArray lowerRequest = requestBytes.toLower(); + QVERIFY(lowerRequest.contains("origin: https://operator.example.test\r\n")); + QVERIFY(!lowerRequest.contains("authorization:")); + + QByteArray observedJpeg; + { + const std::lock_guard lock(probeContext.mutex); + observedJpeg = probeContext.data; + } + QCOMPARE(observedJpeg, jpeg); + if (serverSocket) { + serverSocket->disconnectFromHost(); + } +} + +void GStreamerTest::_testPipelineDiagnosticRedactionUsesGeneration() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + QTcpServer server; + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QTcpSocket* serverSocket = nullptr; + QByteArray requestBytes; + connect(&server, &QTcpServer::pendingConnectionAvailable, this, [&]() { + serverSocket = server.nextPendingConnection(); + if (!serverSocket) { + return; + } + connect(serverSocket, &QIODevice::readyRead, this, [&]() { + requestBytes += serverSocket->readAll(); + if (!requestBytes.endsWith("\r\n\r\n")) { + return; + } + serverSocket->write( + QByteArrayLiteral("HTTP/1.1 200 OK\r\n" + "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n" + "Cache-Control: no-store\r\n" + "Connection: keep-alive\r\n\r\n")); + serverSocket->flush(); + }); + }); + + GstVideoReceiver receiver; + receiver.setAutoReconnect(false); + const QString sourceUrl = + QStringLiteral("http://127.0.0.1:%1/mjpeg?token=pipeline-secret").arg(server.serverPort()); + receiver.setUri(sourceUrl); + QSignalSpy startSpy(&receiver, &VideoReceiver::onStartComplete); + receiver.start(3); + QVERIFY_SIGNAL_WAIT(startSpy, TestTimeout::mediumMs()); + + receiver.setUri(QString()); + constexpr const char* sentinel = "pipeline-secret-must-not-be-logged"; + expectLogMessage("Video.GStreamer.GstVideoReceiver", QtWarningMsg, + QRegularExpression(QStringLiteral("details redacted"))); + GstElement* pipeline = receiver._acquirePipelineRef(); + QVERIFY(pipeline); + GError* warning = g_error_new_literal(GST_STREAM_ERROR, GST_STREAM_ERROR_FAILED, sentinel); + GstMessage* message = gst_message_new_warning(GST_OBJECT(pipeline), warning, g_strdup(sentinel)); + QVERIFY(gst_element_post_message(pipeline, message)); + gst_object_unref(pipeline); + verifyExpectedLogMessage(); + + QSignalSpy stopSpy(&receiver, &VideoReceiver::onStopComplete); + receiver.stop(); + QVERIFY_SIGNAL_WAIT(stopSpy, TestTimeout::mediumMs()); +} + +void GStreamerTest::_testHttpsMjpegTlsAuth() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("fakesink")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + QTemporaryFile caFile; + QVERIFY(writeTestCaFile(caFile)); + QSslServer server; + const QSslConfiguration sslConfiguration = testServerSslConfiguration(); + QVERIFY(!sslConfiguration.localCertificate().isNull()); + QVERIFY(!sslConfiguration.privateKey().isNull()); + server.setSslConfiguration(sslConfiguration); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + + const QByteArray jpeg = createTestJpeg(); + QByteArray requestBytes; + QSslSocket* serverSocket = nullptr; + bool challengeSent = false; + bool responseWriteAccepted = false; + connect(&server, &QTcpServer::pendingConnectionAvailable, this, [&]() { + if (serverSocket) { + return; + } + serverSocket = qobject_cast(server.nextPendingConnection()); + if (!serverSocket) { + return; + } + connect(serverSocket, &QIODevice::readyRead, this, [&]() { + requestBytes += serverSocket->readAll(); + if (!challengeSent && requestBytes.contains("\r\n\r\n")) { + challengeSent = true; + const QByteArray challenge = QByteArrayLiteral( + "HTTP/1.1 401 Unauthorized\r\n" + "WWW-Authenticate: Basic realm=\"QGC Video Test\"\r\n" + "Content-Length: 0\r\n" + "Connection: keep-alive\r\n\r\n"); + serverSocket->write(challenge); + serverSocket->flush(); + return; + } + if (responseWriteAccepted || !requestBytes.toLower().contains("authorization: basic ") || + !requestBytes.endsWith("\r\n\r\n")) { + return; + } + const QByteArray response = QByteArrayLiteral( + "HTTP/1.1 200 OK\r\n" + "Cache-Control: no-store\r\n" + "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n" + "Connection: close\r\n\r\n" + "--frame\r\n" + "Content-Type: image/jpeg\r\n") + + QByteArrayLiteral("Content-Length: ") + QByteArray::number(jpeg.size()) + + QByteArrayLiteral("\r\n\r\n") + jpeg + QByteArrayLiteral("\r\n--frame--\r\n"); + responseWriteAccepted = serverSocket->write(response) == response.size(); + serverSocket->flush(); + }); + }); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Basic; + config.networkSourceConfig.username = QStringLiteral("viewer"); + config.networkSourceConfig.secret = QByteArrayLiteral("test-password"); + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + config.networkSourceConfig.caCertificateFile = caFile.fileName(); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + const QString url = QStringLiteral("https://127.0.0.1:%1/mjpeg").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + + GstElement* parser = findChildByFactoryName(sourceBin, "jpegparse"); + QVERIFY(parser); + GstPad* parserPad = gst_element_get_static_pad(parser, "src"); + QVERIFY(parserPad); + BufferProbeContext probeContext; + const gulong bufferProbeId = + gst_pad_add_probe(parserPad, GST_PAD_PROBE_TYPE_BUFFER, captureBufferProbe, &probeContext, nullptr); + QVERIFY(bufferProbeId != 0); + const auto probeCleanup = qScopeGuard([&] { + gst_pad_remove_probe(parserPad, bufferProbeId); + gst_object_unref(parserPad); + }); + + GstElement* pipeline = gst_pipeline_new("https-mjpeg-auth-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + g_object_set(sink, "sync", FALSE, nullptr); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(probeContext.observed.load(std::memory_order_acquire), TestTimeout::mediumMs()); + QVERIFY(challengeSent); + QVERIFY(responseWriteAccepted); + + const QByteArray lowerRequest = requestBytes.toLower(); + QCOMPARE(lowerRequest.count("authorization:"), 1); + QVERIFY(lowerRequest.contains("authorization: basic dmlld2vyonrlc3qtcgfzc3dvcmq=\r\n")); + QVERIFY(lowerRequest.contains("origin: https://operator.example.test\r\n")); +} + +void GStreamerTest::_testHttpsMjpegRejectsUntrustedCa() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("fakesink")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + QSslServer server; + server.setSslConfiguration(testServerSslConfiguration()); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QSignalSpy acceptedConnectionSpy(&server, &QTcpServer::pendingConnectionAvailable); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + const QString url = QStringLiteral("https://127.0.0.1:%1/mjpeg").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + GstElement* pipeline = gst_pipeline_new("https-mjpeg-untrusted-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = waitForBusMessage(bus, GST_MESSAGE_ERROR, TestTimeout::mediumMs()); + gst_object_unref(bus); + QVERIFY2(message, "Untrusted HTTPS MJPEG connection did not fail closed"); + gst_message_unref(message); + QCOMPARE(acceptedConnectionSpy.count(), 0); +} + +void GStreamerTest::_testHttpsMjpegAuthRedirectNotFollowed() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("fakesink")) { + QSKIP("HTTP MJPEG GStreamer plugins unavailable"); + } + + QTemporaryFile caFile; + QVERIFY(writeTestCaFile(caFile)); + const QSslConfiguration sslConfiguration = testServerSslConfiguration(); + RawConnectionObservingSslServer redirectServer; + RawConnectionObservingSslServer targetServer; + redirectServer.setSslConfiguration(sslConfiguration); + targetServer.setSslConfiguration(sslConfiguration); + QVERIFY(redirectServer.listen(QHostAddress::LocalHost, 0)); + QVERIFY(targetServer.listen(QHostAddress::LocalHost, 0)); + + QByteArray requestBytes; + QSslSocket* redirectSocket = nullptr; + bool challengeSent = false; + bool redirectSent = false; + connect(&redirectServer, &QTcpServer::pendingConnectionAvailable, this, [&]() { + redirectSocket = qobject_cast(redirectServer.nextPendingConnection()); + if (!redirectSocket) { + return; + } + connect(redirectSocket, &QIODevice::readyRead, this, [&]() { + requestBytes += redirectSocket->readAll(); + if (!requestBytes.endsWith("\r\n\r\n")) { + return; + } + if (!challengeSent) { + challengeSent = true; + redirectSocket->write( + QByteArrayLiteral("HTTP/1.1 401 Unauthorized\r\n" + "WWW-Authenticate: Basic realm=\"QGC Redirect Test\"\r\n" + "Content-Length: 0\r\n" + "Connection: keep-alive\r\n\r\n")); + redirectSocket->flush(); + return; + } + if (redirectSent || !requestBytes.toLower().contains("authorization: basic ")) { + return; + } + redirectSent = true; + const QByteArray location = + QStringLiteral("https://127.0.0.1:%1/video").arg(targetServer.serverPort()).toUtf8(); + const QByteArray response = QByteArrayLiteral("HTTP/1.1 302 Found\r\nLocation: ") + location + + QByteArrayLiteral("\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + redirectSocket->write(response); + redirectSocket->disconnectFromHost(); + }); + }); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Basic; + config.networkSourceConfig.username = QStringLiteral("viewer"); + config.networkSourceConfig.secret = QByteArrayLiteral("redirect-password"); + config.networkSourceConfig.caCertificateFile = caFile.fileName(); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + const QString url = QStringLiteral("https://127.0.0.1:%1/mjpeg").arg(redirectServer.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + GstElement* pipeline = gst_pipeline_new("https-mjpeg-redirect-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(redirectSent, TestTimeout::mediumMs()); + QVERIFY(challengeSent); + QVERIFY(requestBytes.toLower().contains("authorization: basic ")); + QCOMPARE(targetServer.rawConnectionCount(), 0); +} + +void GStreamerTest::_testNetworkJpegValidation() +{ + const QByteArray validJpeg = createTestJpeg(); + QVERIFY(!validJpeg.isEmpty()); + QString error; + QVERIFY(QGCJpegStreamGuard::validateJpeg(validJpeg, &error)); + QVERIFY(error.isEmpty()); + + QByteArray truncated = validJpeg; + truncated.chop(2); + QVERIFY(!QGCJpegStreamGuard::validateJpeg(truncated, &error)); + QVERIFY(!error.isEmpty()); + + QByteArray excessiveDimensions = validJpeg; + QVERIFY(replaceJpegDimensions(excessiveDimensions, 8192, 8192)); + QVERIFY(!QGCJpegStreamGuard::validateJpeg(excessiveDimensions, &error)); + QVERIFY(error.contains(QStringLiteral("dimensions"))); + + const QByteArray excessiveEncodedSize(QGCJpegStreamGuard::kMaximumEncodedBytes + 1, '\0'); + QVERIFY(!QGCJpegStreamGuard::validateJpeg(excessiveEncodedSize, &error)); + QVERIFY(error.contains(QStringLiteral("encoded-size"))); +} + +void GStreamerTest::_testMultipartJpegGuard() +{ + QString error; + QGCJpegStreamGuard::MultipartGuard declaredGuard(128, 32); + QVERIFY(declaredGuard.setBoundary(QByteArrayLiteral("frame"), &error)); + QVERIFY(declaredGuard.consume(QByteArrayLiteral("--fra"), &error)); + QVERIFY(declaredGuard.consume(QByteArrayLiteral("me\r\nContent-Type: image/jpeg\r\n\r\nabc\r\n--fr"), &error)); + QVERIFY(declaredGuard.consume(QByteArrayLiteral("ame\r\nContent-Type: image/jpeg\r\n\r\ndef\r\n--frame--\r\n"), + &error)); + + QGCJpegStreamGuard::MultipartGuard discoveredGuard(128, 64); + QVERIFY(discoveredGuard.consume(QByteArrayLiteral("--camera\r"), &error)); + QVERIFY( + discoveredGuard.consume(QByteArrayLiteral("\nContent-Type: image/jpeg\r\n\r\nabc\r\n--camera--\r\n"), &error)); + + QGCJpegStreamGuard::MultipartGuard missingBoundary(128, 8); + QVERIFY(missingBoundary.setBoundary(QByteArrayLiteral("frame"), &error)); + QVERIFY(!missingBoundary.consume(QByteArrayLiteral("123456789"), &error)); + QVERIFY(error.contains(QStringLiteral("declared boundary"))); + + QGCJpegStreamGuard::MultipartGuard oversizedPart(24, 32); + QVERIFY(oversizedPart.setBoundary(QByteArrayLiteral("frame"), &error)); + QVERIFY(oversizedPart.consume(QByteArrayLiteral("--frame\r\n"), &error)); + QVERIFY(!oversizedPart.consume(QByteArray(23, 'x'), &error)); + QVERIFY(error.contains(QStringLiteral("exceeds"))); + + QGCJpegStreamGuard::MultipartGuard oversizedSingleBuffer(24, 32); + QVERIFY(oversizedSingleBuffer.setBoundary(QByteArrayLiteral("frame"), &error)); + const QByteArray oversizedBody = + QByteArrayLiteral("--frame\r\n") + QByteArray(30, 'x') + QByteArrayLiteral("\r\n--frame\r\n"); + QVERIFY(!oversizedSingleBuffer.consume(oversizedBody, &error)); + QVERIFY(error.contains(QStringLiteral("exceeds"))); +} + +void GStreamerTest::_testJpegRecordingContainers() +{ + const char* requiredFactories[] = { + "appsrc", "jpegparse", "splitmuxsink", "qtmux", "matroskamux", "playbin", "fakesink", + }; + for (const char* factory : requiredFactories) { + GstElementFactory* elementFactory = gst_element_factory_find(factory); + if (!elementFactory) { + QSKIP(qPrintable(QStringLiteral("Required recording/playback plugin is unavailable: %1").arg(factory))); + } + gst_object_unref(elementFactory); + } + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray jpeg = createTestJpeg(32); + QVERIFY(QGCJpegStreamGuard::validateJpeg(jpeg)); + + const QList> formats = { + {QStringLiteral("qtmux"), QStringLiteral("mov")}, + {QStringLiteral("matroskamux"), QStringLiteral("mkv")}, + }; + for (const auto& [muxer, extension] : formats) { + const QString outputPath = directory.filePath(QStringLiteral("jpeg-recording.%1").arg(extension)); + const QString pipelineDescription = + QStringLiteral( + "appsrc name=source caps=image/jpeg format=time is-live=false ! jpegparse ! " + "splitmuxsink muxer-factory=%1 async-finalize=true message-forward=true location=\"%2\"") + .arg(muxer, outputPath); + GError* parseError = nullptr; + GstElement* pipeline = gst_parse_launch(pipelineDescription.toUtf8().constData(), &parseError); + const QString parseErrorText = parseError ? QString::fromUtf8(parseError->message) : QString(); + g_clear_error(&parseError); + QVERIFY2(pipeline, qPrintable(parseErrorText)); + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + + GstElement* appsrc = gst_bin_get_by_name(GST_BIN(pipeline), "source"); + QVERIFY(appsrc); + const auto appsrcCleanup = qScopeGuard([&] { gst_object_unref(appsrc); }); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + for (int frame = 0; frame < 3; ++frame) { + GstBuffer* frameBuffer = gst_buffer_new_allocate(nullptr, static_cast(jpeg.size()), nullptr); + QVERIFY(frameBuffer); + gst_buffer_fill(frameBuffer, 0, jpeg.constData(), static_cast(jpeg.size())); + GST_BUFFER_PTS(frameBuffer) = static_cast(frame) * GST_SECOND / 10; + GST_BUFFER_DURATION(frameBuffer) = GST_SECOND / 10; + QCOMPARE(gst_app_src_push_buffer(GST_APP_SRC(appsrc), frameBuffer), GST_FLOW_OK); + } + QCOMPARE(gst_app_src_end_of_stream(GST_APP_SRC(appsrc)), GST_FLOW_OK); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = + gst_bus_timed_pop_filtered(bus, static_cast(TestTimeout::longMs()) * GST_MSECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(bus); + QVERIFY2(message, "Timed out finalizing the JPEG recording"); + if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError* recordingError = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &recordingError, &debug); + const QString detail = + recordingError ? QString::fromUtf8(recordingError->message) : QStringLiteral("unknown"); + g_clear_error(&recordingError); + g_clear_pointer(&debug, g_free); + gst_message_unref(message); + QFAIL(qPrintable(QStringLiteral("JPEG recording failed: %1").arg(detail))); + } + gst_message_unref(message); + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + + const QFileInfo recording(outputPath); + QVERIFY(recording.exists()); + QVERIFY(recording.size() > 0); + + GstElement* playback = gst_element_factory_make("playbin", nullptr); + GstElement* videoSink = gst_element_factory_make("fakesink", nullptr); + GstElement* audioSink = gst_element_factory_make("fakesink", nullptr); + QVERIFY(playback); + QVERIFY(videoSink); + QVERIFY(audioSink); + gst_object_ref_sink(videoSink); + gst_object_ref_sink(audioSink); + const QByteArray uri = QUrl::fromLocalFile(outputPath).toEncoded(); + g_object_set(playback, "uri", uri.constData(), "video-sink", videoSink, "audio-sink", audioSink, nullptr); + gst_object_unref(videoSink); + gst_object_unref(audioSink); + const auto playbackCleanup = qScopeGuard([&] { + (void) gst_element_set_state(playback, GST_STATE_NULL); + gst_clear_object(&playback); + }); + QVERIFY(gst_element_set_state(playback, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + bus = gst_element_get_bus(playback); + QVERIFY(bus); + message = gst_bus_timed_pop_filtered(bus, static_cast(TestTimeout::longMs()) * GST_MSECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(bus); + QVERIFY2(message, "Timed out playing the finalized JPEG recording"); + if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError* playbackError = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &playbackError, &debug); + const QString detail = + playbackError ? QString::fromUtf8(playbackError->message) : QStringLiteral("unknown"); + g_clear_error(&playbackError); + g_clear_pointer(&debug, g_free); + gst_message_unref(message); + QFAIL(qPrintable(QStringLiteral("JPEG recording playback failed: %1").arg(detail))); + } + gst_message_unref(message); + } +} + +void GStreamerTest::_testRecordingEosSeqnumClassification() +{ + GstVideoReceiver receiver; + GstElement* messageSource = gst_pipeline_new("recording-eos-seqnum-test"); + GstElement* recordingBin = gst_bin_new("recording-sink-bin"); + GstElement* recordingSink = gst_element_factory_make("fakesink", nullptr); + QVERIFY(messageSource); + QVERIFY(recordingBin); + QVERIFY(recordingSink); + QVERIFY(gst_bin_add(GST_BIN(recordingBin), recordingSink)); + GstMessage* recordingMessage = gst_message_new_eos(GST_OBJECT(messageSource)); + GstMessage* sourceMessage = gst_message_new_eos(GST_OBJECT(messageSource)); + GstMessage* ancestryMessage = gst_message_new_eos(GST_OBJECT(recordingSink)); + QVERIFY(recordingMessage); + QVERIFY(sourceMessage); + QVERIFY(ancestryMessage); + const auto cleanup = qScopeGuard([&] { + gst_clear_message(&recordingMessage); + gst_clear_message(&sourceMessage); + gst_clear_message(&ancestryMessage); + gst_clear_object(&recordingBin); + gst_clear_object(&messageSource); + }); + + const guint32 recordingSeqnum = gst_util_seqnum_next(); + const guint32 sourceSeqnum = gst_util_seqnum_next(); + QVERIFY(recordingSeqnum != GST_SEQNUM_INVALID); + QVERIFY(sourceSeqnum != GST_SEQNUM_INVALID); + QVERIFY(recordingSeqnum != sourceSeqnum); + gst_message_set_seqnum(recordingMessage, recordingSeqnum); + gst_message_set_seqnum(sourceMessage, sourceSeqnum); + + receiver._recordingEosSeqnum.store(recordingSeqnum, std::memory_order_release); + QVERIFY(receiver._isRecordingEOSMessage(recordingMessage)); + QVERIFY(receiver._isRecordingEOSMessage(ancestryMessage)); + QVERIFY(!receiver._isRecordingEOSMessage(sourceMessage)); + + receiver._recordingEosSeqnum.store(GST_SEQNUM_INVALID, std::memory_order_release); + QVERIFY(!receiver._isRecordingEOSMessage(recordingMessage)); +} + +void GStreamerTest::_testJpegReceiverRecording() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + const char* requiredFactories[] = { + "appsrc", "jpegparse", "splitmuxsink", "qtmux", "matroskamux", "playbin", "fakesink", + }; + for (const char* factory : requiredFactories) { + GstElementFactory* elementFactory = gst_element_factory_find(factory); + if (!elementFactory) { + QSKIP(qPrintable(QStringLiteral("Required receiver-recording plugin is unavailable: %1").arg(factory))); + } + gst_object_unref(elementFactory); + } + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto verifyPlayableRecording = [](const QString& outputPath) -> QString { + const QFileInfo recording(outputPath); + if (!recording.exists() || recording.size() <= 0) { + return QStringLiteral("Recording was not finalized: %1").arg(outputPath); + } + + GstElement* playback = gst_element_factory_make("playbin", nullptr); + GstElement* videoSink = gst_element_factory_make("fakesink", nullptr); + GstElement* audioSink = gst_element_factory_make("fakesink", nullptr); + if (!playback || !videoSink || !audioSink) { + gst_clear_object(&audioSink); + gst_clear_object(&videoSink); + gst_clear_object(&playback); + return QStringLiteral("Required recording playback elements are unavailable"); + } + + gst_object_ref_sink(videoSink); + gst_object_ref_sink(audioSink); + const auto playbackCleanup = qScopeGuard([&] { + (void) gst_element_set_state(playback, GST_STATE_NULL); + gst_clear_object(&playback); + }); + const QByteArray uri = QUrl::fromLocalFile(outputPath).toEncoded(); + g_object_set(playback, "uri", uri.constData(), "video-sink", videoSink, "audio-sink", audioSink, nullptr); + gst_object_unref(videoSink); + gst_object_unref(audioSink); + if (gst_element_set_state(playback, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + return QStringLiteral("Failed to start recording playback: %1").arg(outputPath); + } + + GstBus* playbackBus = gst_element_get_bus(playback); + if (!playbackBus) { + return QStringLiteral("Recording playback bus is unavailable: %1").arg(outputPath); + } + GstMessage* playbackMessage = + gst_bus_timed_pop_filtered(playbackBus, static_cast(TestTimeout::longMs()) * GST_MSECOND, + static_cast(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + gst_object_unref(playbackBus); + if (!playbackMessage) { + return QStringLiteral("Timed out playing finalized recording: %1").arg(outputPath); + } + + QString result; + if (GST_MESSAGE_TYPE(playbackMessage) == GST_MESSAGE_ERROR) { + GError* error = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(playbackMessage, &error, &debug); + result = QStringLiteral("Finalized recording playback failed: %1") + .arg(error ? QString::fromUtf8(error->message) : QStringLiteral("unknown error")); + g_clear_error(&error); + g_clear_pointer(&debug, g_free); + } + gst_message_unref(playbackMessage); + return result; + }; + + QWebSocketServer server(QStringLiteral("QGC receiver recording test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QWebSocket* serverSocket = nullptr; + connect(&server, &QWebSocketServer::newConnection, this, + [&server, &serverSocket]() { serverSocket = server.nextPendingConnection(); }); + + GstVideoReceiver receiver; + receiver.setAutoReconnect(false); + receiver.setUri(QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort())); + QSignalSpy receiverStartSpy(&receiver, &VideoReceiver::onStartComplete); + receiver.start(30); + QVERIFY_SIGNAL_WAIT(receiverStartSpy, TestTimeout::mediumMs()); + QVERIFY_TRUE_WAIT(serverSocket != nullptr, TestTimeout::mediumMs()); + const gulong sourceProbeId = receiver._sourceProbeId; + QVERIFY(sourceProbeId != 0); + QSignalSpy receiverStopSpy(&receiver, &VideoReceiver::onStopComplete); + + const QString rejectedMp4 = directory.filePath(QStringLiteral("jpeg-rejected.mp4")); + // The configured URI can change before the old worker pipeline is retired. Recording + // compatibility must remain tied to the source that is actually producing frames. + receiver.setUri(QStringLiteral("rtsp://127.0.0.1/not-the-active-pipeline")); + expectLogMessage("Video.GStreamer.GstVideoReceiver", QtWarningMsg, + QRegularExpression(QStringLiteral("MP4 recording is unavailable"))); + QSignalSpy rejectedRecordingSpy(&receiver, &VideoReceiver::onStartRecordingComplete); + receiver.startRecording(rejectedMp4, VideoReceiver::FILE_FORMAT_MP4); + QVERIFY_SIGNAL_WAIT(rejectedRecordingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(rejectedRecordingSpy.takeFirst().at(0)), + VideoReceiver::STATUS_NOT_IMPLEMENTED); + verifyExpectedLogMessage(); + QVERIFY(!QFileInfo::exists(rejectedMp4)); + receiver.setUri(QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort())); + + const QByteArray jpeg = createTestJpeg(32); + QVERIFY(!jpeg.isEmpty()); + const QList> formats = { + {VideoReceiver::FILE_FORMAT_MOV, QStringLiteral("mov")}, + {VideoReceiver::FILE_FORMAT_MKV, QStringLiteral("mkv")}, + }; + for (const auto& [format, extension] : formats) { + const QString outputPath = directory.filePath(QStringLiteral("receiver-recording.%1").arg(extension)); + QTimer frameTimer; + connect(&frameTimer, &QTimer::timeout, this, [&]() { + if (serverSocket && serverSocket->state() == QAbstractSocket::ConnectedState) { + serverSocket->sendBinaryMessage(jpeg); + serverSocket->flush(); + } + }); + const quint64 preRecordingFrameCount = receiver._sourceFrameCount.load(std::memory_order_relaxed); + frameTimer.start(1); + QVERIFY_TRUE_WAIT(receiver._sourceFrameCount.load(std::memory_order_relaxed) > preRecordingFrameCount, + TestTimeout::mediumMs()); + + QSignalSpy startRecordingSpy(&receiver, &VideoReceiver::onStartRecordingComplete); + QSignalSpy recordingStartedSpy(&receiver, &VideoReceiver::recordingStarted); + receiver.startRecording(outputPath, format); + QVERIFY_SIGNAL_WAIT(startRecordingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(startRecordingSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QVERIFY_SIGNAL_WAIT(recordingStartedSpy, TestTimeout::mediumMs()); + QCOMPARE(recordingStartedSpy.takeFirst().at(0).toString(), outputPath); + QCOMPARE(receiver._keyframeWatchId.load(std::memory_order_acquire), static_cast(0)); + + const quint64 initialFrameCount = receiver._sourceFrameCount.load(std::memory_order_relaxed); + for (int frame = 0; frame < 12; ++frame) { + QCOMPARE(serverSocket->sendBinaryMessage(jpeg), static_cast(jpeg.size())); + serverSocket->flush(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 5); + } + QVERIFY_TRUE_WAIT(receiver._sourceFrameCount.load(std::memory_order_relaxed) >= initialFrameCount + 3, + TestTimeout::mediumMs()); + frameTimer.stop(); + + QSignalSpy stopRecordingSpy(&receiver, &VideoReceiver::onStopRecordingComplete); + receiver.stopRecording(); + QVERIFY_SIGNAL_WAIT(stopRecordingSpy, TestTimeout::longMs()); + QCOMPARE(qvariant_cast(stopRecordingSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QCOMPARE(receiver._keyframeWatchId.load(std::memory_order_acquire), static_cast(0)); + QCOMPARE(receiver._recordingEosSeqnum.load(std::memory_order_acquire), + static_cast(GST_SEQNUM_INVALID)); + + const quint64 postRecordingFrameCount = receiver._sourceFrameCount.load(std::memory_order_relaxed); + frameTimer.start(1); + QVERIFY_TRUE_WAIT(receiver._sourceFrameCount.load(std::memory_order_relaxed) >= postRecordingFrameCount + 12, + TestTimeout::mediumMs()); + frameTimer.stop(); + QCOMPARE(receiverStopSpy.count(), 0); + + const QString playbackFailure = verifyPlayableRecording(outputPath); + QVERIFY2(playbackFailure.isEmpty(), qPrintable(playbackFailure)); + } + + QQuickItem widget; + receiver.setWidget(&widget); + GstElement* videoSink = gst_element_factory_make("fakesink", nullptr); + QVERIFY(videoSink); + gst_object_ref_sink(videoSink); + const auto videoSinkCleanup = qScopeGuard([&] { gst_clear_object(&videoSink); }); + + QTimer decodeFrameTimer; + connect(&decodeFrameTimer, &QTimer::timeout, this, [&]() { + if (serverSocket && serverSocket->state() == QAbstractSocket::ConnectedState) { + serverSocket->sendBinaryMessage(jpeg); + serverSocket->flush(); + } + }); + decodeFrameTimer.start(1); + + QSignalSpy startDecodingSpy(&receiver, &VideoReceiver::onStartDecodingComplete); + QSignalSpy decodingChangedSpy(&receiver, &VideoReceiver::decodingChanged); + receiver.startDecoding(videoSink); + QVERIFY_SIGNAL_WAIT(startDecodingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(startDecodingSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QVERIFY_SIGNAL_WAIT(decodingChangedSpy, TestTimeout::mediumMs()); + QCOMPARE(decodingChangedSpy.takeFirst().at(0).toBool(), true); + + QSignalSpy stopDecodingSpy(&receiver, &VideoReceiver::onStopDecodingComplete); + receiver.stopDecoding(); + QVERIFY_SIGNAL_WAIT(stopDecodingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(stopDecodingSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QVERIFY_SIGNAL_WAIT(decodingChangedSpy, TestTimeout::mediumMs()); + QCOMPARE(decodingChangedSpy.takeFirst().at(0).toBool(), false); + QCOMPARE(receiver._sourceProbeId, sourceProbeId); + + receiver.startDecoding(videoSink); + QVERIFY_SIGNAL_WAIT(startDecodingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(startDecodingSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QVERIFY_SIGNAL_WAIT(decodingChangedSpy, TestTimeout::mediumMs()); + QCOMPARE(decodingChangedSpy.takeFirst().at(0).toBool(), true); + QCOMPARE(receiverStopSpy.count(), 0); + QCOMPARE(receiver._sourceProbeId, sourceProbeId); + + const QString sourceEosRecordingPath = directory.filePath(QStringLiteral("source-eos-recording.mkv")); + QSignalSpy sourceEosStartRecordingSpy(&receiver, &VideoReceiver::onStartRecordingComplete); + QSignalSpy sourceEosRecordingStartedSpy(&receiver, &VideoReceiver::recordingStarted); + QSignalSpy sourceEosRecordingChangedSpy(&receiver, &VideoReceiver::recordingChanged); + QSignalSpy sourceEosStopRecordingSpy(&receiver, &VideoReceiver::onStopRecordingComplete); + QSignalSpy sourceEosStreamingChangedSpy(&receiver, &VideoReceiver::streamingChanged); + const quint64 sourceEosInitialFrameCount = receiver._sourceFrameCount.load(std::memory_order_relaxed); + receiver.startRecording(sourceEosRecordingPath, VideoReceiver::FILE_FORMAT_MKV); + QVERIFY_SIGNAL_WAIT(sourceEosStartRecordingSpy, TestTimeout::mediumMs()); + QCOMPARE(qvariant_cast(sourceEosStartRecordingSpy.takeFirst().at(0)), + VideoReceiver::STATUS_OK); + QVERIFY_SIGNAL_WAIT(sourceEosRecordingStartedSpy, TestTimeout::mediumMs()); + QCOMPARE(sourceEosRecordingStartedSpy.takeFirst().at(0).toString(), sourceEosRecordingPath); + QVERIFY_TRUE_WAIT(receiver._sourceFrameCount.load(std::memory_order_relaxed) >= sourceEosInitialFrameCount + 3, + TestTimeout::mediumMs()); + QVERIFY(receiver._recording); + QVERIFY(!receiver._endOfStream.load(std::memory_order_acquire)); + QCOMPARE(receiverStopSpy.count(), 0); + + decodeFrameTimer.stop(); + serverSocket->close(QWebSocketProtocol::CloseCodeNormal, QStringLiteral("test complete")); + QVERIFY_SIGNAL_WAIT(receiverStopSpy, TestTimeout::longMs()); + QCOMPARE(qvariant_cast(receiverStopSpy.takeFirst().at(0)), VideoReceiver::STATUS_OK); + QVERIFY_TRUE_WAIT(sourceEosRecordingChangedSpy.count() >= 2, TestTimeout::longMs()); + QCOMPARE(sourceEosRecordingChangedSpy.takeFirst().at(0).toBool(), true); + QCOMPARE(sourceEosRecordingChangedSpy.takeFirst().at(0).toBool(), false); + QCOMPARE(sourceEosStopRecordingSpy.count(), 0); + QVERIFY_SIGNAL_COUNT_WAIT(sourceEosStreamingChangedSpy, 1, TestTimeout::mediumMs()); + QCOMPARE(sourceEosStreamingChangedSpy.takeFirst().at(0).toBool(), false); + QVERIFY_SIGNAL_COUNT_WAIT(decodingChangedSpy, 1, TestTimeout::mediumMs()); + QCOMPARE(decodingChangedSpy.takeFirst().at(0).toBool(), false); + QVERIFY(!receiver._recording); + QVERIFY(!receiver._streaming); + QVERIFY(receiver._endOfStream.load(std::memory_order_acquire)); + QVERIFY(receiver._fileSink == nullptr); + QCOMPARE(receiver._recordingEosSeqnum.load(std::memory_order_acquire), static_cast(GST_SEQNUM_INVALID)); + QCOMPARE(receiver._sourceProbeId, static_cast(0)); + const QString sourceEosPlaybackFailure = verifyPlayableRecording(sourceEosRecordingPath); + QVERIFY2(sourceEosPlaybackFailure.isEmpty(), qPrintable(sourceEosPlaybackFailure)); + serverSocket->deleteLater(); + receiver.setWidget(nullptr); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testSourceFactoryWebSocketJpeg() +{ + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; +#ifdef QGC_HAS_WEBSOCKET_VIDEO + QWebSocketServer server(QStringLiteral("QGC source factory test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + const QString url = QStringLiteral("ws://127.0.0.1:%1/ws/video_feed").arg(server.serverPort()); + GstElement* bin = GStreamer::SourceFactory::create(url, config); + QVERIFY(bin); + QWebSocket* serverSocket = nullptr; + const auto cleanup = qScopeGuard([&] { + gst_clear_object(&bin); + if (serverSocket) { + serverSocket->close(); + serverSocket->deleteLater(); + } + }); + + QVERIFY2(findChildByFactoryName(bin, "appsrc"), "ws:// must build an appsrc bridge"); + QVERIFY2(findChildByFactoryName(bin, "jpegparse"), "WebSocket JPEG must expose parsed JPEG frames"); + QVERIFY_TRUE_WAIT(server.hasPendingConnections(), TestTimeout::mediumMs()); + serverSocket = server.nextPendingConnection(); + QVERIFY(serverSocket); +#else + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket JPEG support is unavailable"))); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:5077/ws/video_feed"), config)); +#endif +} + +void GStreamerTest::_testSourceFactoryWebSocketAuthRequiresWss() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + config.networkSourceConfig.secret = QByteArrayLiteral("test-token"); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Authenticated WebSocket JPEG video requires WSS"))); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:5078/video"), config)); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketJpegValidation() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + const QByteArray validJpeg = createTestJpeg(); + QVERIFY(!validJpeg.isEmpty()); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(validJpeg)); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("ffd800ffd9"))); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("ffd800"))); + QVERIFY(!QGCWebSocketVideoSource::isCompleteJpeg(QByteArray::fromHex("000000ffd9"))); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketJpegDelivery() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC video delivery test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QWebSocket* serverSocket = nullptr; + connect(&server, &QWebSocketServer::newConnection, this, + [&server, &serverSocket]() { serverSocket = server.nextPendingConnection(); }); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + const QString url = QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + + GstElement* appsrc = findChildByFactoryName(sourceBin, "appsrc"); + QVERIFY(appsrc); + guint64 maximumBuffers = 0; + guint64 maximumBytes = 0; + GstAppLeakyType leakyType = GST_APP_LEAKY_TYPE_NONE; + g_object_get(appsrc, "max-buffers", &maximumBuffers, "max-bytes", &maximumBytes, "leaky-type", &leakyType, nullptr); + QCOMPARE(maximumBuffers, static_cast(4)); + QCOMPARE(maximumBytes, static_cast(64U * 1024U * 1024U)); + QCOMPARE(leakyType, GST_APP_LEAKY_TYPE_DOWNSTREAM); + GstPad* appsrcPad = gst_element_get_static_pad(appsrc, "src"); + QVERIFY(appsrcPad); + + BufferProbeContext probeContext; + const gulong bufferProbeId = + gst_pad_add_probe(appsrcPad, GST_PAD_PROBE_TYPE_BUFFER, captureBufferProbe, &probeContext, nullptr); + QVERIFY(bufferProbeId != 0); + const auto probeCleanup = qScopeGuard([&] { + gst_pad_remove_probe(appsrcPad, bufferProbeId); + gst_object_unref(appsrcPad); + }); + + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + g_object_set(sink, "sync", FALSE, nullptr); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(serverSocket != nullptr, TestTimeout::mediumMs()); + QCOMPARE(serverSocket->origin(), config.networkSourceConfig.origin); + QVERIFY(!serverSocket->request().rawHeader("User-Agent").isEmpty()); + + const QByteArray jpeg = createTestJpeg(); + QVERIFY(!jpeg.isEmpty()); + QVERIFY(QGCWebSocketVideoSource::isCompleteJpeg(jpeg)); + QCOMPARE(serverSocket->sendBinaryMessage(jpeg), static_cast(jpeg.size())); + serverSocket->flush(); + + QVERIFY_TRUE_WAIT(probeContext.observed.load(std::memory_order_acquire), TestTimeout::mediumMs()); + QByteArray observedJpeg; + { + const std::lock_guard lock(probeContext.mutex); + observedJpeg = probeContext.data; + } + QCOMPARE(observedJpeg, jpeg); + + serverSocket->close(); + serverSocket->deleteLater(); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketRejectsInvalidFrame() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC invalid video test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QWebSocket* serverSocket = nullptr; + connect(&server, &QWebSocketServer::newConnection, this, + [&server, &serverSocket]() { serverSocket = server.nextPendingConnection(); }); + + GStreamer::SourceFactory::Config config; + const QString url = QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + GstElement* pipeline = gst_pipeline_new("websocket-invalid-jpeg-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(serverSocket != nullptr, TestTimeout::mediumMs()); + + QSignalSpy disconnectedSpy(serverSocket, &QWebSocket::disconnected); + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("Rejecting WebSocket JPEG stream"))); + const QByteArray invalidFrame = QByteArray::fromHex("ffd800ffd9"); + for (int attempt = 0; attempt < 3; ++attempt) { + serverSocket->sendBinaryMessage(invalidFrame); + } + serverSocket->flush(); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = gst_bus_timed_pop_filtered( + bus, static_cast(TestTimeout::mediumMs()) * GST_MSECOND, GST_MESSAGE_ERROR); + gst_object_unref(bus); + QVERIFY2(message, "Invalid WebSocket JPEG did not fail the source pipeline"); + gst_message_unref(message); + verifyExpectedLogMessage(); + if (serverSocket->state() != QAbstractSocket::UnconnectedState) { + QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); + } + QCOMPARE(serverSocket->state(), QAbstractSocket::UnconnectedState); + serverSocket->deleteLater(); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketEarlyTransportFailureAfterDelayedParenting() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QTcpServer portReservation; + QVERIFY(portReservation.listen(QHostAddress::LocalHost, 0)); + const quint16 refusedPort = portReservation.serverPort(); + portReservation.close(); + + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket error code"))); + GStreamer::SourceFactory::Config config; + GstElement* sourceBin = + GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:%1/video").arg(refusedPort), config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + + // Keep the source unparented long enough for localhost refusal to complete. + // The error must be retained even though there is no pipeline bus yet. + QElapsedTimer preParentDelay; + preParentDelay.start(); + while (preParentDelay.elapsed() < TestTimeout::shortMs()) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(5); + } + verifyExpectedLogMessage(); + + GstElement* pipeline = gst_pipeline_new("websocket-early-transport-error-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = waitForBusMessage(bus, GST_MESSAGE_ERROR, TestTimeout::mediumMs()); + gst_object_unref(bus); + QVERIFY2(message, "Pre-parent WebSocket refusal was not replayed to the pipeline bus"); + QCOMPARE(GST_MESSAGE_TYPE(message), GST_MESSAGE_ERROR); + GError* error = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &error, &debug); + QCOMPARE(error ? error->domain : 0, static_cast(GST_RESOURCE_ERROR)); + QCOMPARE(error ? error->code : -1, static_cast(GST_RESOURCE_ERROR_OPEN_READ)); + g_clear_error(&error); + g_clear_pointer(&debug, g_free); + gst_message_unref(message); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketEarlyProtocolFailureAfterDelayedParenting() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC early invalid video test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QWebSocket* serverSocket = nullptr; + connect(&server, &QWebSocketServer::newConnection, this, + [&server, &serverSocket]() { serverSocket = server.nextPendingConnection(); }); + + GStreamer::SourceFactory::Config config; + GstElement* sourceBin = + GStreamer::SourceFactory::create(QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()), config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + QVERIFY_TRUE_WAIT(serverSocket != nullptr, TestTimeout::mediumMs()); + + QSignalSpy disconnectedSpy(serverSocket, &QWebSocket::disconnected); + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("Rejecting WebSocket JPEG stream"))); + const QByteArray invalidFrame = QByteArray::fromHex("ffd800ffd9"); + QCOMPARE(serverSocket->sendBinaryMessage(invalidFrame), static_cast(invalidFrame.size())); + serverSocket->flush(); + QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); + verifyExpectedLogMessage(); + + GstElement* pipeline = gst_pipeline_new("websocket-early-protocol-error-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = waitForBusMessage(bus, GST_MESSAGE_ERROR, TestTimeout::mediumMs()); + gst_object_unref(bus); + QVERIFY2(message, "Pre-parent invalid WebSocket JPEG was not replayed to the pipeline bus"); + QCOMPARE(GST_MESSAGE_TYPE(message), GST_MESSAGE_ERROR); + GError* error = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &error, &debug); + QCOMPARE(error ? error->domain : 0, static_cast(GST_STREAM_ERROR)); + QCOMPARE(error ? error->code : -1, static_cast(GST_STREAM_ERROR_DECODE)); + g_clear_error(&error); + g_clear_pointer(&debug, g_free); + gst_message_unref(message); + serverSocket->deleteLater(); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketJpegTlsAuth() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QTemporaryFile caFile; + QVERIFY(writeTestCaFile(caFile)); + QWebSocketServer server(QStringLiteral("QGC secure video delivery test"), QWebSocketServer::SecureMode); + const QSslConfiguration sslConfiguration = testServerSslConfiguration(); + QVERIFY(!sslConfiguration.localCertificate().isNull()); + QVERIFY(!sslConfiguration.privateKey().isNull()); + server.setSslConfiguration(sslConfiguration); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QWebSocket* serverSocket = nullptr; + connect(&server, &QWebSocketServer::newConnection, this, + [&server, &serverSocket]() { serverSocket = server.nextPendingConnection(); }); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + config.networkSourceConfig.secret = QByteArrayLiteral("test-token"); + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + config.networkSourceConfig.caCertificateFile = caFile.fileName(); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + const QString url = QStringLiteral("wss://127.0.0.1:%1/video").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + + GstElement* appsrc = findChildByFactoryName(sourceBin, "appsrc"); + QVERIFY(appsrc); + GstPad* appsrcPad = gst_element_get_static_pad(appsrc, "src"); + QVERIFY(appsrcPad); + BufferProbeContext probeContext; + const gulong bufferProbeId = + gst_pad_add_probe(appsrcPad, GST_PAD_PROBE_TYPE_BUFFER, captureBufferProbe, &probeContext, nullptr); + QVERIFY(bufferProbeId != 0); + const auto probeCleanup = qScopeGuard([&] { + gst_pad_remove_probe(appsrcPad, bufferProbeId); + gst_object_unref(appsrcPad); + }); + + GstElement* pipeline = gst_pipeline_new("websocket-jpeg-tls-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + g_object_set(sink, "sync", FALSE, nullptr); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(serverSocket != nullptr, TestTimeout::mediumMs()); + QCOMPARE(serverSocket->origin(), config.networkSourceConfig.origin); + QCOMPARE(serverSocket->request().rawHeader("Authorization"), QByteArrayLiteral("Bearer test-token")); + + const QByteArray jpeg = createTestJpeg(); + QCOMPARE(serverSocket->sendBinaryMessage(jpeg), static_cast(jpeg.size())); + serverSocket->flush(); + QVERIFY_TRUE_WAIT(probeContext.observed.load(std::memory_order_acquire), TestTimeout::mediumMs()); + serverSocket->close(); + serverSocket->deleteLater(); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketAuthRedirectNotFollowed() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QTemporaryFile caFile; + QVERIFY(writeTestCaFile(caFile)); + const QSslConfiguration sslConfiguration = testServerSslConfiguration(); + RawConnectionObservingSslServer redirectServer; + RawConnectionObservingSslServer targetServer; + redirectServer.setSslConfiguration(sslConfiguration); + targetServer.setSslConfiguration(sslConfiguration); + QVERIFY(redirectServer.listen(QHostAddress::LocalHost, 0)); + QVERIFY(targetServer.listen(QHostAddress::LocalHost, 0)); + + QByteArray requestBytes; + QSslSocket* redirectSocket = nullptr; + bool redirectSent = false; + connect(&redirectServer, &QTcpServer::pendingConnectionAvailable, this, [&]() { + redirectSocket = qobject_cast(redirectServer.nextPendingConnection()); + if (!redirectSocket) { + return; + } + connect(redirectSocket, &QIODevice::readyRead, this, [&]() { + requestBytes += redirectSocket->readAll(); + if (redirectSent || !requestBytes.endsWith("\r\n\r\n")) { + return; + } + redirectSent = true; + const QByteArray location = + QStringLiteral("wss://127.0.0.1:%1/video").arg(targetServer.serverPort()).toUtf8(); + redirectSocket->write(QByteArrayLiteral("HTTP/1.1 302 Found\r\nLocation: ") + location + + QByteArrayLiteral("\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")); + redirectSocket->disconnectFromHost(); + }); + }); + + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.authentication = VideoReceiver::NetworkSourceConfig::Authentication::Bearer; + config.networkSourceConfig.secret = QByteArrayLiteral("redirect-token"); + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + config.networkSourceConfig.caCertificateFile = caFile.fileName(); + const auto secretCleanup = qScopeGuard([&] { config.networkSourceConfig.clearSecret(); }); + const QString url = QStringLiteral("wss://127.0.0.1:%1/video").arg(redirectServer.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + GstElement* pipeline = gst_pipeline_new("websocket-auth-redirect-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket error code"))); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + QVERIFY_TRUE_WAIT(redirectSent, TestTimeout::mediumMs()); + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = waitForBusMessage(bus, GST_MESSAGE_ERROR, TestTimeout::mediumMs()); + gst_object_unref(bus); + QVERIFY2(message, "Redirected WSS handshake did not fail the source pipeline"); + gst_message_unref(message); + verifyExpectedLogMessage(); + + const QByteArray lowerRequest = requestBytes.toLower(); + QVERIFY(lowerRequest.contains("authorization: bearer redirect-token\r\n")); + QVERIFY(lowerRequest.contains("origin: https://operator.example.test\r\n")); + + // Keep the source alive long enough to detect a queued or delayed attempt to + // follow the redirect. The raw observer fires before any TLS handshake result. + QEventLoop noTargetConnectionObservation; + QTimer observationTimeout; + observationTimeout.setSingleShot(true); + QObject::connect(&observationTimeout, &QTimer::timeout, &noTargetConnectionObservation, &QEventLoop::quit); + targetServer.setRawConnectionObserver([&noTargetConnectionObservation] { noTargetConnectionObservation.exit(1); }); + observationTimeout.start(TestTimeout::shortMs()); + QCOMPARE(noTargetConnectionObservation.exec(), 0); + targetServer.setRawConnectionObserver({}); + QCOMPARE(targetServer.rawConnectionCount(), 0); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketJpegRejectsUntrustedCa() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse") || + !gst_element_factory_find("fakesink")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + RawConnectionObservingSslServer server; + server.setSslConfiguration(testServerSslConfiguration()); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("TLS verification failed"))); + expectLogMessage("Video.GStreamer.WebSocketVideoSource", QtWarningMsg, + QRegularExpression(QStringLiteral("WebSocket error code"))); + GStreamer::SourceFactory::Config config; + config.networkSourceConfig.origin = QStringLiteral("https://operator.example.test"); + const QString url = QStringLiteral("wss://127.0.0.1:%1/video").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + auto sourceCleanup = qScopeGuard([&] { gst_clear_object(&sourceBin); }); + GstElement* pipeline = gst_pipeline_new("websocket-untrusted-ca-test"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(pipeline); + QVERIFY(sink); + GstElement* sourceElement = sourceBin; + gst_bin_add_many(GST_BIN(pipeline), sourceBin, sink, nullptr); + sourceCleanup.dismiss(); + sourceBin = nullptr; + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_clear_object(&pipeline); + }); + QVERIFY(gst_element_link(sourceElement, sink)); + QVERIFY(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); + GstBus* bus = gst_element_get_bus(pipeline); + QVERIFY(bus); + GstMessage* message = waitForBusMessage(bus, GST_MESSAGE_ERROR, TestTimeout::mediumMs()); + gst_object_unref(bus); + QVERIFY2(message, "Untrusted WSS connection did not fail the source pipeline"); + gst_message_unref(message); + verifyExpectedLogMessage(); + verifyExpectedLogMessage(); + QVERIFY(server.rawConnectionCount() > 0); +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + +void GStreamerTest::_testWebSocketThreadTeardown() +{ +#ifdef QGC_HAS_WEBSOCKET_VIDEO + if (!gst_element_factory_find("appsrc") || !gst_element_factory_find("jpegparse")) { + QSKIP("WebSocket JPEG GStreamer plugins unavailable"); + } + + QWebSocketServer server(QStringLiteral("QGC video teardown test"), QWebSocketServer::NonSecureMode); + QVERIFY(server.listen(QHostAddress::LocalHost, 0)); + QList serverSockets; + connect(&server, &QWebSocketServer::newConnection, this, [&server, &serverSockets]() { + while (server.hasPendingConnections()) { + serverSockets.append(server.nextPendingConnection()); + } + }); + + for (int iteration = 0; iteration < 3; ++iteration) { + GStreamer::SourceFactory::Config config; + const QString url = QStringLiteral("ws://127.0.0.1:%1/video").arg(server.serverPort()); + GstElement* sourceBin = GStreamer::SourceFactory::create(url, config); + QVERIFY(sourceBin); + QCOMPARE_TRUE_WAIT(serverSockets.size(), iteration + 1, TestTimeout::mediumMs()); + + QWebSocket* socket = serverSockets.at(iteration); + QSignalSpy disconnectedSpy(socket, &QWebSocket::disconnected); + QElapsedTimer elapsed; + elapsed.start(); + gst_object_unref(sourceBin); + QVERIFY2(elapsed.elapsed() < TestTimeout::mediumMs(), "WebSocket source teardown exceeded its normal bound"); + if (socket->state() != QAbstractSocket::UnconnectedState) { + QVERIFY_SIGNAL_WAIT(disconnectedSpy, TestTimeout::mediumMs()); + } + QCOMPARE(socket->state(), QAbstractSocket::UnconnectedState); + } + + for (QWebSocket* socket : std::as_const(serverSockets)) { + delete socket; + } +#else + QSKIP("Qt WebSockets unavailable"); +#endif +} + #endif diff --git a/test/VideoManager/VideoManagerInitTest.cc b/test/VideoManager/VideoManagerInitTest.cc index edff6b6e147f..d04104ccfa49 100644 --- a/test/VideoManager/VideoManagerInitTest.cc +++ b/test/VideoManager/VideoManagerInitTest.cc @@ -2,11 +2,38 @@ #ifdef QGC_GST_STREAMING -#include "VideoManager.h" - #include +#include #include +#include "Fixtures/RAIIFixtures.h" +#include "SettingsManager.h" +#include "VideoManager.h" +#include "VideoReceiver.h" +#include "VideoSettings.h" + +namespace { + +class TestVideoReceiver final : public VideoReceiver +{ +public: + void start(uint32_t) override {} + + void stop() override {} + + void startDecoding(VideoSinkHandle) override {} + + void stopDecoding() override {} + + void startRecording(const QString&, FILE_FORMAT) override {} + + void stopRecording() override {} + + void takeScreenshot(const QString&) override {} +}; + +} // namespace + void VideoManagerInitTest::init() { UnitTest::init(); @@ -101,12 +128,99 @@ void VideoManagerInitTest::_testBackendInitFailure() QCOMPARE(createReceiversCount, 0); } +void VideoManagerInitTest::_testNetworkVideoSettingsPropagation() +{ + TestFixtures::SettingsFixture fixture; + VideoSettings* settings = SettingsManager::instance()->videoSettings(); + QVERIFY(settings); + + fixture.setFactValue(settings->videoSource(), QString::fromLatin1(VideoSettings::videoSourceHTTPMJPEG)); + fixture.setFactValue(settings->httpMjpegUrl(), QStringLiteral("http://192.0.2.1:8080/video_feed")); + fixture.setFactValue(settings->networkVideoAuthType(), VideoSettings::NetworkVideoAuthNone); + fixture.setFactValue(settings->networkVideoUsername(), QString()); + fixture.setFactValue(settings->networkVideoOrigin(), QString()); + fixture.setFactValue(settings->networkVideoCaCertificateFile(), QString()); + fixture.setFactValue(settings->streamEnabled(), true); + + const auto clearSecret = qScopeGuard([settings] { settings->clearNetworkVideoSecret(); }); + VideoManager videoManager; + TestVideoReceiver receiver; + receiver.setName(QStringLiteral("video")); + + QVERIFY(videoManager._updateSettings(&receiver)); + QCOMPARE(receiver.uri(), QStringLiteral("http://192.0.2.1:8080/video_feed")); + VideoReceiver::NetworkSourceConfig networkConfig = receiver.networkSourceConfig(); + QVERIFY(networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::None); + QVERIFY(networkConfig.secret.isEmpty()); + + settings->httpMjpegUrl()->setRawValue(QStringLiteral("https://camera.example/video_feed")); + settings->networkVideoAuthType()->setRawValue(VideoSettings::NetworkVideoAuthBearer); + QCOMPARE(settings->setNetworkVideoSecret(QStringLiteral("session-token")), QString()); + QVERIFY(videoManager._updateSettings(&receiver)); + QCOMPARE(receiver.uri(), QStringLiteral("https://camera.example/video_feed")); + networkConfig.clearSecret(); + networkConfig = receiver.networkSourceConfig(); + QVERIFY(networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::Bearer); + QCOMPARE(networkConfig.secret, QByteArrayLiteral("session-token")); + + settings->httpMjpegUrl()->setRawValue(QStringLiteral("http://192.0.2.1:8080/video_feed")); + expectLogMessage("Video.VideoManager", QtWarningMsg, + QRegularExpression(QStringLiteral("Network video configuration rejected"))); + QVERIFY(videoManager._updateSettings(&receiver)); + verifyExpectedLogMessage(); + QVERIFY(receiver.uri().isEmpty()); + networkConfig.clearSecret(); + networkConfig = receiver.networkSourceConfig(); + QVERIFY(networkConfig.authentication == VideoReceiver::NetworkSourceConfig::Authentication::None); + QVERIFY(networkConfig.secret.isEmpty()); + networkConfig.clearSecret(); +} + +void VideoManagerInitTest::_testJpegNetworkRecordingPolicy() +{ + const QString httpSource = QString::fromLatin1(VideoSettings::videoSourceHTTPMJPEG); + const QString webSocketSource = QString::fromLatin1(VideoSettings::videoSourceWebSocketJPEG); + const QString udpSource = QString::fromLatin1(VideoSettings::videoSourceUDPH264); + + QVERIFY(!VideoManager::_isRecordingFormatSupported(httpSource, VideoReceiver::FILE_FORMAT_MP4)); + QVERIFY(!VideoManager::_isRecordingFormatSupported(webSocketSource, VideoReceiver::FILE_FORMAT_MP4)); + QVERIFY(VideoManager::_isRecordingFormatSupported(httpSource, VideoReceiver::FILE_FORMAT_MOV)); + QVERIFY(VideoManager::_isRecordingFormatSupported(webSocketSource, VideoReceiver::FILE_FORMAT_MKV)); + QVERIFY(VideoManager::_isRecordingFormatSupported(udpSource, VideoReceiver::FILE_FORMAT_MP4)); +} + #else -void VideoManagerInitTest::init() { UnitTest::init(); QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testQmlReadyBeforeBackendReady() { QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testBackendReadyBeforeQmlReady() { QSKIP("GStreamer not enabled"); } -void VideoManagerInitTest::_testBackendInitFailure() { QSKIP("GStreamer not enabled"); } +void VideoManagerInitTest::init() +{ + UnitTest::init(); + QSKIP("GStreamer not enabled"); +} + +void VideoManagerInitTest::_testQmlReadyBeforeBackendReady() +{ + QSKIP("GStreamer not enabled"); +} + +void VideoManagerInitTest::_testBackendReadyBeforeQmlReady() +{ + QSKIP("GStreamer not enabled"); +} + +void VideoManagerInitTest::_testBackendInitFailure() +{ + QSKIP("GStreamer not enabled"); +} + +void VideoManagerInitTest::_testNetworkVideoSettingsPropagation() +{ + QSKIP("GStreamer not enabled"); +} + +void VideoManagerInitTest::_testJpegNetworkRecordingPolicy() +{ + QSKIP("GStreamer not enabled"); +} #endif diff --git a/test/VideoManager/VideoManagerInitTest.h b/test/VideoManager/VideoManagerInitTest.h index 0147e21a4ad7..c8862c5fc168 100644 --- a/test/VideoManager/VideoManagerInitTest.h +++ b/test/VideoManager/VideoManagerInitTest.h @@ -12,4 +12,6 @@ private slots: void _testQmlReadyBeforeBackendReady(); void _testBackendReadyBeforeQmlReady(); void _testBackendInitFailure(); + void _testNetworkVideoSettingsPropagation(); + void _testJpegNetworkRecordingPolicy(); }; diff --git a/test/VideoManager/VideoSettingsTest.cc b/test/VideoManager/VideoSettingsTest.cc new file mode 100644 index 000000000000..ce5fa98b1cea --- /dev/null +++ b/test/VideoManager/VideoSettingsTest.cc @@ -0,0 +1,181 @@ +#include "VideoSettingsTest.h" + +#include +#include +#include + +#if defined(Q_OS_UNIX) && !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#include +#endif + +#include "Fixtures/RAIIFixtures.h" +#include "VideoSettings.h" + +void VideoSettingsTest::_testNetworkVideoUrlValidation_data() +{ + QTest::addColumn("url"); + QTest::addColumn("schemes"); + QTest::addColumn("valid"); + + QTest::newRow("http") << QStringLiteral("http://192.0.2.1:8080/video") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << true; + QTest::newRow("https-query") << QStringLiteral("https://camera.example/video?quality=high") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << true; + QTest::newRow("websocket") << QStringLiteral("wss://camera.example/jpeg") + << QStringList{QStringLiteral("ws"), QStringLiteral("wss")} << true; + QTest::newRow("missing-host") << QStringLiteral("https:///video") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << false; + QTest::newRow("wrong-scheme") << QStringLiteral("ftp://camera.example/video") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << false; + QTest::newRow("userinfo") << QStringLiteral("https://user:pass@camera.example/video") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << false; + QTest::newRow("fragment") << QStringLiteral("wss://camera.example/jpeg#token") + << QStringList{QStringLiteral("ws"), QStringLiteral("wss")} << false; + QTest::newRow("token-query") << QStringLiteral("https://camera.example/video?Access_Token=secret") + << QStringList{QStringLiteral("http"), QStringLiteral("https")} << false; +} + +void VideoSettingsTest::_testNetworkVideoUrlValidation() +{ + QFETCH(QString, url); + QFETCH(QStringList, schemes); + QFETCH(bool, valid); + + QString error; + QCOMPARE(VideoSettings::validateNetworkVideoUrl(url, schemes, error), valid); + QCOMPARE(error.isEmpty(), valid); +} + +void VideoSettingsTest::_testAuthenticatedTransportPolicy() +{ +#ifndef QGC_GST_STREAMING + QSKIP("GStreamer not enabled"); +#else + VideoSettings settings; + TestFixtures::SettingsFixture fixture; + fixture.setFactValue(settings.videoSource(), QString::fromLatin1(VideoSettings::videoSourceHTTPMJPEG)); + fixture.setFactValue(settings.httpMjpegUrl(), QStringLiteral("http://192.0.2.1:8080/video")); + fixture.setFactValue(settings.networkVideoAuthType(), VideoSettings::NetworkVideoAuthBasic); + fixture.setFactValue(settings.networkVideoUsername(), QStringLiteral("viewer")); + QCOMPARE(settings.setNetworkVideoSecret(QStringLiteral("test-password")), QString()); + + QVERIFY(settings.networkVideoConfigurationError().contains(QStringLiteral("HTTPS"))); + + settings.httpMjpegUrl()->setRawValue(QStringLiteral("https://camera.example/video")); + QCOMPARE(settings.networkVideoConfigurationError(), QString()); + + const QStringList invalidUsernames = { + QStringLiteral("viewer:admin"), + QString::fromUtf8("viewer\0admin", 12), + QStringLiteral("viewer\radmin"), + QStringLiteral("viewer\nadmin"), + }; + for (const QString& username : invalidUsernames) { + settings.networkVideoUsername()->setRawValue(username); + QVERIFY(settings.networkVideoConfigurationError().contains(QStringLiteral("username"))); + } +#endif +} + +void VideoSettingsTest::_testOriginValidation() +{ +#ifndef QGC_HAS_WEBSOCKET_VIDEO + QSKIP("Qt WebSockets unavailable"); +#else + VideoSettings settings; + TestFixtures::SettingsFixture fixture; + fixture.setFactValue(settings.videoSource(), QString::fromLatin1(VideoSettings::videoSourceWebSocketJPEG)); + fixture.setFactValue(settings.websocketJpegUrl(), QStringLiteral("ws://192.0.2.1:8080/video")); + + fixture.setFactValue(settings.networkVideoOrigin(), QStringLiteral("https://operator.example/path")); + QVERIFY(settings.networkVideoConfigurationError().contains(QStringLiteral("Origin"))); + + settings.networkVideoOrigin()->setRawValue(QStringLiteral("https://operator.example:8443")); + QCOMPARE(settings.networkVideoConfigurationError(), QString()); +#endif +} + +void VideoSettingsTest::_testCredentialFilePlatformSupport() +{ + VideoSettings settings; +#if defined(Q_OS_UNIX) && !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + QVERIFY(settings.networkVideoCredentialFileSupported()); +#else + QVERIFY(!settings.networkVideoCredentialFileSupported()); +#endif +} + +void VideoSettingsTest::_testSecretFileValidation() +{ +#if !defined(Q_OS_UNIX) || defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + QSKIP("Owner-only credential file validation is Unix-specific"); +#else + VideoSettings settings; + TestFixtures::SettingsFixture fixture; + QTemporaryFile file; + QVERIFY(file.open()); + QCOMPARE(file.write("file-secret\n"), 12); + QVERIFY(file.flush()); + QVERIFY(file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + fixture.setFactValue(settings.networkVideoSecretFile(), file.fileName()); + + QByteArray secret; + QString error; + QVERIFY2(settings.resolveNetworkVideoSecret(secret, error), qPrintable(error)); + QCOMPARE(secret, QByteArrayLiteral("file-secret")); + + QVERIFY(file.resize(0)); + QVERIFY(file.seek(0)); + QCOMPARE(file.write("file-secret\n\n"), 13); + QVERIFY(file.flush()); + QVERIFY(!settings.resolveNetworkVideoSecret(secret, error)); + QVERIFY(error.contains(QStringLiteral("exactly one"))); + + QVERIFY(file.resize(0)); + QVERIFY(file.seek(0)); + QCOMPARE(file.write("file-secret\n"), 12); + QVERIFY(file.flush()); + QVERIFY(file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadGroup)); + QVERIFY(!settings.resolveNetworkVideoSecret(secret, error)); + QVERIFY(error.contains(QStringLiteral("permissions"))); +#endif +} + +void VideoSettingsTest::_testSecretFileRejectsAliases() +{ +#if !defined(Q_OS_UNIX) || defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + QSKIP("Owner-only credential file validation is Unix-specific"); +#else + VideoSettings settings; + TestFixtures::SettingsFixture fixture; + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + const QString targetPath = directory.filePath(QStringLiteral("credential")); + QFile target(targetPath); + QVERIFY(target.open(QIODevice::WriteOnly)); + QCOMPARE(target.write("file-secret\n"), 12); + target.close(); + QVERIFY(target.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + const QString symlinkPath = directory.filePath(QStringLiteral("credential-symlink")); + const QByteArray encodedTarget = QFile::encodeName(targetPath); + const QByteArray encodedSymlink = QFile::encodeName(symlinkPath); + QCOMPARE(::symlink(encodedTarget.constData(), encodedSymlink.constData()), 0); + fixture.setFactValue(settings.networkVideoSecretFile(), symlinkPath); + + QByteArray secret; + QString error; + QVERIFY(!settings.resolveNetworkVideoSecret(secret, error)); + QVERIFY(error.contains(QStringLiteral("symbolic"))); + + const QString hardlinkPath = directory.filePath(QStringLiteral("credential-hardlink")); + const QByteArray encodedHardlink = QFile::encodeName(hardlinkPath); + QCOMPARE(::link(encodedTarget.constData(), encodedHardlink.constData()), 0); + settings.networkVideoSecretFile()->setRawValue(targetPath); + QVERIFY(!settings.resolveNetworkVideoSecret(secret, error)); + QVERIFY(error.contains(QStringLiteral("hard links"))); +#endif +} + +UT_REGISTER_TEST(VideoSettingsTest, TestLabel::Unit) diff --git a/test/VideoManager/VideoSettingsTest.h b/test/VideoManager/VideoSettingsTest.h new file mode 100644 index 000000000000..7fb68b07a213 --- /dev/null +++ b/test/VideoManager/VideoSettingsTest.h @@ -0,0 +1,17 @@ +#pragma once + +#include "UnitTest.h" + +class VideoSettingsTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _testNetworkVideoUrlValidation(); + void _testNetworkVideoUrlValidation_data(); + void _testAuthenticatedTransportPolicy(); + void _testOriginValidation(); + void _testCredentialFilePlatformSupport(); + void _testSecretFileValidation(); + void _testSecretFileRejectsAliases(); +}; diff --git a/test/VideoStreaming/README.md b/test/VideoStreaming/README.md new file mode 100644 index 000000000000..f30ba9db4727 --- /dev/null +++ b/test/VideoStreaming/README.md @@ -0,0 +1,42 @@ +# HTTP MJPEG and WebSocket JPEG Test Sources + +These synthetic sources let reviewers test QGC network video without a camera. +They generate a moving JPEG test pattern and expose it through the same protocols +configured in **Application Settings > Video**. + +Python 3.10 or newer is required. The bounded `websockets` version range keeps +the fixture on its current asyncio API instead of the removed legacy server API. + +```bash +cd test/VideoStreaming +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +## HTTP MJPEG + +```bash +python http_mjpeg_server.py --host 127.0.0.1 --port 5077 +``` + +In QGC: + +- Source: `HTTP MJPEG Video Stream` +- HTTP MJPEG URL: `http://127.0.0.1:5077/video_feed` +- Authentication: `None` + +## WebSocket JPEG + +```bash +python websocket_jpeg_server.py --host 127.0.0.1 --port 5078 +``` + +In QGC: + +- Source: `WebSocket JPEG Video Stream` +- WebSocket JPEG URL: `ws://127.0.0.1:5078/ws/video_feed` +- Authentication: `None` + +Plain `http://` and `ws://` are intended for local lab testing. Use `https://` +or `wss://` before enabling Basic or Bearer authentication. diff --git a/test/VideoStreaming/http_mjpeg_server.py b/test/VideoStreaming/http_mjpeg_server.py new file mode 100755 index 000000000000..20ada40cb018 --- /dev/null +++ b/test/VideoStreaming/http_mjpeg_server.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import http.server +import socketserver +import time +from typing import cast + +from pattern import jpeg_frame + + +class MjpegHandler(http.server.BaseHTTPRequestHandler): + server_version = "QGCTestMjpeg/1.0" + + def do_GET(self) -> None: + if self.path not in ("/", "/video_feed"): + self.send_error(404) + return + + self.send_response(200) + self.send_header("Cache-Control", "no-store") + self.send_header("Pragma", "no-cache") + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + + server = cast("ThreadedServer", self.server) + frame_index = 0 + frame_interval = 1.0 / max(1, server.fps) + while True: + payload = jpeg_frame(frame_index, server.width, server.height, server.quality) + try: + self.wfile.write(b"--frame\r\n") + self.wfile.write(b"Content-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")) + self.wfile.write(payload) + self.wfile.write(b"\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + return + frame_index += 1 + time.sleep(frame_interval) + + def log_message(self, fmt: str, *args: object) -> None: + print(f"{self.address_string()} - {fmt % args}") + + +class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + fps: int + width: int + height: int + quality: int + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Synthetic multipart MJPEG source for QGC video testing." + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=5077) + parser.add_argument("--fps", type=int, default=12) + parser.add_argument("--width", type=int, default=640) + parser.add_argument("--height", type=int, default=360) + parser.add_argument("--quality", type=int, default=80) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + server = ThreadedServer((args.host, args.port), MjpegHandler) + server.fps = args.fps + server.width = args.width + server.height = args.height + server.quality = args.quality + print(f"HTTP MJPEG: http://{args.host}:{args.port}/video_feed") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/VideoStreaming/pattern.py b/test/VideoStreaming/pattern.py new file mode 100644 index 000000000000..110604756b52 --- /dev/null +++ b/test/VideoStreaming/pattern.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import io +import math +import time + +from PIL import Image, ImageDraw, ImageFont + + +def jpeg_frame(index: int, width: int = 640, height: int = 360, quality: int = 80) -> bytes: + image = Image.new("RGB", (width, height), (18, 24, 34)) + draw = ImageDraw.Draw(image) + + draw.rectangle((0, 0, width, 52), fill=(32, 46, 67)) + draw.text( + (18, 16), "QGC Network Video Test", fill=(240, 244, 248), font=ImageFont.load_default() + ) + + grid_color = (56, 68, 82) + for x in range(0, width, 40): + draw.line((x, 52, x, height), fill=grid_color) + for y in range(80, height, 40): + draw.line((0, y, width, y), fill=grid_color) + + phase = index / 18.0 + cx = int((width - 120) * (0.5 + 0.45 * math.sin(phase))) + cy = int(120 + (height - 180) * (0.5 + 0.45 * math.cos(phase * 0.7))) + draw.ellipse((cx, cy, cx + 80, cy + 80), fill=(29, 185, 84), outline=(255, 255, 255), width=3) + draw.rectangle( + (width - 170, height - 58, width - 18, height - 18), outline=(255, 184, 77), width=2 + ) + draw.text( + (width - 156, height - 46), + f"frame {index:06d}", + fill=(255, 224, 160), + font=ImageFont.load_default(), + ) + draw.text( + (18, height - 42), + time.strftime("%Y-%m-%d %H:%M:%S"), + fill=(180, 194, 210), + font=ImageFont.load_default(), + ) + + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=quality, optimize=True) + return buffer.getvalue() diff --git a/test/VideoStreaming/requirements.txt b/test/VideoStreaming/requirements.txt new file mode 100644 index 000000000000..e7961d3d5907 --- /dev/null +++ b/test/VideoStreaming/requirements.txt @@ -0,0 +1,2 @@ +pillow>=10.0 +websockets>=15.0,<17.0 diff --git a/test/VideoStreaming/websocket_jpeg_server.py b/test/VideoStreaming/websocket_jpeg_server.py new file mode 100755 index 000000000000..9ba2f3529513 --- /dev/null +++ b/test/VideoStreaming/websocket_jpeg_server.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import asyncio + +from pattern import jpeg_frame +from websockets.asyncio.server import ServerConnection, serve + + +async def stream(websocket: ServerConnection, args: argparse.Namespace) -> None: + frame_index = 0 + frame_interval = 1.0 / max(1, args.fps) + while True: + await websocket.send(jpeg_frame(frame_index, args.width, args.height, args.quality)) + frame_index += 1 + await asyncio.sleep(frame_interval) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Synthetic WebSocket JPEG source for QGC video testing." + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=5078) + parser.add_argument("--fps", type=int, default=12) + parser.add_argument("--width", type=int, default=640) + parser.add_argument("--height", type=int, default=360) + parser.add_argument("--quality", type=int, default=80) + return parser.parse_args() + + +async def main_async() -> None: + args = parse_args() + + async def handler(websocket: ServerConnection) -> None: + if websocket.request.path != "/ws/video_feed": + await websocket.close(code=1008, reason="Use /ws/video_feed") + return + await stream(websocket, args) + + async with serve(handler, args.host, args.port, max_size=16 * 1024 * 1024): + print(f"WebSocket JPEG: ws://{args.host}:{args.port}/ws/video_feed") + await asyncio.Future() + + +if __name__ == "__main__": + asyncio.run(main_async())