LiveKit integration: real camera feed pipeline #1
@@ -73,17 +73,63 @@ set(STPLUGIN_LIVEKIT_SDK_TRIPLE "" CACHE STRING
|
|||||||
set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH
|
set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH
|
||||||
"Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)")
|
"Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)")
|
||||||
|
|
||||||
|
# Pinned SHA256 checksums for the client-sdk-cpp v1.10.1 release archives,
|
||||||
|
# so the download in cmake/LiveKitSDK.cmake is verified the same way the
|
||||||
|
# obs-deps bootstrap next to it already is (see
|
||||||
|
# cmake/common/buildspec_common.cmake ~line 324). Each hash below was
|
||||||
|
# computed by downloading the real GitHub release asset and running
|
||||||
|
# `sha256sum` on it (2026-09-06/07) -- none of these were guessed or copied
|
||||||
|
# from an unverified source. To add a hash for a new version or triple:
|
||||||
|
# curl -LO https://github.com/livekit/client-sdk-cpp/releases/download/v<VERSION>/livekit-sdk-<TRIPLE>-<VERSION>.<tar.gz|zip>
|
||||||
|
# sha256sum livekit-sdk-<TRIPLE>-<VERSION>.*
|
||||||
|
# Covers every triple _lk_default_triple() can resolve to for this pinned
|
||||||
|
# version: Linux (ubuntu-22.04-x64/arm64), macOS (macos-x64/arm64) and
|
||||||
|
# Windows (windows-x64). Verified by extracting each archive
|
||||||
|
# (tar tzf / unzip -l) and confirming a real LiveKitConfig.cmake inside --
|
||||||
|
# only Linux was also verified by an actual local CMake configure+build in
|
||||||
|
# this environment; macOS and Windows were downloaded and hashed but not
|
||||||
|
# build-tested here.
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-x64 "6f4fc8143f36952d42bfd5ff8d1782cf6211ba8fd6b055877e9ef85441d66324")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-arm64 "399677167b474b7f107c6937904ea01898c9ec8da648cbed669387e599c6ea45")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_macos-x64 "7102655c1f2947be4b06a95f9fafa1a11379219328e82ad875e5ebccfc9ac7e3")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_macos-arm64 "0822af7014519a473c5b5cd019bde58c26cc2bfe5b78e4a790395ead232dc55b")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_windows-x64 "b9fc6b2865298d7e3d032205d7e74fb9628cfe55a2ed28cb657db0a481cd518c")
|
||||||
|
|
||||||
include(LiveKitSDK)
|
include(LiveKitSDK)
|
||||||
|
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
||||||
|
set(_stplugin_livekit_triple "${STPLUGIN_LIVEKIT_SDK_TRIPLE}")
|
||||||
|
else()
|
||||||
|
# Mirrors LiveKitSDK.cmake's own autodetection so the checksum lookup
|
||||||
|
# below matches whatever triple livekit_sdk_setup() will actually
|
||||||
|
# resolve to and download.
|
||||||
|
_lk_default_triple(_stplugin_livekit_triple)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(_stplugin_livekit_sha256_var
|
||||||
|
"_stplugin_livekit_sha256_${STPLUGIN_LIVEKIT_SDK_VERSION}_${_stplugin_livekit_triple}")
|
||||||
|
if(DEFINED ${_stplugin_livekit_sha256_var})
|
||||||
|
set(_stplugin_livekit_sha256 "${${_stplugin_livekit_sha256_var}}")
|
||||||
|
else()
|
||||||
|
set(_stplugin_livekit_sha256 "")
|
||||||
|
message(WARNING
|
||||||
|
"LiveKitSDK: no pinned SHA256 for triple '${_stplugin_livekit_triple}' "
|
||||||
|
"at version ${STPLUGIN_LIVEKIT_SDK_VERSION} -- the downloaded archive "
|
||||||
|
"will NOT be integrity-checked. Compute one (see the comment above "
|
||||||
|
"this block) and add it to CMakeLists.txt.")
|
||||||
|
endif()
|
||||||
|
|
||||||
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
||||||
livekit_sdk_setup(
|
livekit_sdk_setup(
|
||||||
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
||||||
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
||||||
TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}"
|
TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}"
|
||||||
|
SHA256 "${_stplugin_livekit_sha256}"
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
livekit_sdk_setup(
|
livekit_sdk_setup(
|
||||||
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
||||||
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
||||||
|
SHA256 "${_stplugin_livekit_sha256}"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
find_package(LiveKit CONFIG REQUIRED)
|
find_package(LiveKit CONFIG REQUIRED)
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ public:
|
|||||||
/// with the read key replaced by "***".
|
/// with the read key replaced by "***".
|
||||||
static std::string redactedUrl(const std::string &url);
|
static std::string redactedUrl(const std::string &url);
|
||||||
|
|
||||||
|
/// Scrubs "access_token=<value>" and "key=<value>" out of arbitrary
|
||||||
|
/// text -- not necessarily a bare URL/query string -- replacing each
|
||||||
|
/// value with "<redacted>". Unlike redactedUrl (which only has to
|
||||||
|
/// handle "&"-delimited query parameters), a value here can be followed
|
||||||
|
/// by a quote or whitespace, because the text this scrubs is a free-form
|
||||||
|
/// log line that may merely *contain* a URL. Used by the OBS adapter's
|
||||||
|
/// LiveKit SDK log bridge: LiveKit's signaling URL carries the access
|
||||||
|
/// token as a query parameter, and the SDK's own log lines could
|
||||||
|
/// include it.
|
||||||
|
static std::string redactSensitiveParams(const std::string &text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::shared_ptr<HttpClient> http_;
|
std::shared_ptr<HttpClient> http_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw)
|
|||||||
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
|
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
|
||||||
if (!has_scheme)
|
if (!has_scheme)
|
||||||
url = "https://" + url;
|
url = "https://" + url;
|
||||||
|
// An explicit "http://..." is left as-is on purpose: an operator who
|
||||||
|
// typed the scheme out has made a deliberate (if inadvisable) choice,
|
||||||
|
// and this function's job is only to supply a sane default, not to
|
||||||
|
// second-guess an explicit one.
|
||||||
|
|
||||||
while (!url.empty() && url.back() == '/')
|
while (!url.empty() && url.back() == '/')
|
||||||
url.pop_back();
|
url.pop_back();
|
||||||
@@ -108,6 +112,11 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw)
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No current call site logs a request URL (the OBS adapter only logs
|
||||||
|
// ws_url/status text, never the streamer-tools API request URL itself) --
|
||||||
|
// this exists as a deliberate guard rail for whenever request-URL logging
|
||||||
|
// is added later, so the read key can never be pasted into an OBS log by
|
||||||
|
// accident. Not dead code to be deleted.
|
||||||
std::string ApiClient::redactedUrl(const std::string &url)
|
std::string ApiClient::redactedUrl(const std::string &url)
|
||||||
{
|
{
|
||||||
const std::size_t at = url.find("key=");
|
const std::size_t at = url.find("key=");
|
||||||
@@ -120,6 +129,36 @@ std::string ApiClient::redactedUrl(const std::string &url)
|
|||||||
return url.substr(0, value) + "***" + url.substr(end);
|
return url.substr(0, value) + "***" + url.substr(end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string ApiClient::redactSensitiveParams(const std::string &text)
|
||||||
|
{
|
||||||
|
static const char *const kParams[] = {"access_token=", "key="};
|
||||||
|
|
||||||
|
std::string out = text;
|
||||||
|
for (const char *param : kParams) {
|
||||||
|
const std::size_t param_len = std::string(param).size();
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = out.find(param, pos)) != std::string::npos) {
|
||||||
|
const std::size_t value_start = pos + param_len;
|
||||||
|
std::size_t value_end = value_start;
|
||||||
|
// A value ends at the next query-string delimiter, a quote (the
|
||||||
|
// URL is often embedded in a quoted/bracketed log line), or
|
||||||
|
// whitespace -- whichever comes first -- or at the end of the
|
||||||
|
// string.
|
||||||
|
while (value_end < out.size()) {
|
||||||
|
const char c = out[value_end];
|
||||||
|
if (c == '&' || c == '"' || c == '\'' || c == ' ' || c == '\t' || c == '\n' ||
|
||||||
|
c == '\r' || c == ')' || c == ']')
|
||||||
|
break;
|
||||||
|
++value_end;
|
||||||
|
}
|
||||||
|
const std::string replacement = "<redacted>";
|
||||||
|
out.replace(value_start, value_end - value_start, replacement);
|
||||||
|
pos = value_start + replacement.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
|
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
|
||||||
{
|
{
|
||||||
SlotsResult result;
|
SlotsResult result;
|
||||||
|
|||||||
+11
-2
@@ -78,8 +78,17 @@ public:
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
// Redirects are never legitimate here: this client only ever talks to
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
// two fixed, first-party streamer-tools API endpoints, and the read
|
||||||
|
// key travels as a URL query parameter (see api_client.cpp). Blindly
|
||||||
|
// following a redirect -- including an HTTPS->HTTP downgrade, which
|
||||||
|
// curl does not refuse by default -- would hand that key to whatever
|
||||||
|
// host the redirect points at. A redirect from our own server is a
|
||||||
|
// configuration error, so treat it as a failed request instead of
|
||||||
|
// silently following it. This also brings this backend in line with
|
||||||
|
// http_winhttp.cpp, which already refuses HTTPS->HTTP downgrades by
|
||||||
|
// default.
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
|
||||||
// NOSIGNAL is required whenever curl is used off the main thread:
|
// NOSIGNAL is required whenever curl is used off the main thread:
|
||||||
// without it curl installs a SIGALRM handler for DNS timeouts, which
|
// without it curl installs a SIGALRM handler for DNS timeouts, which
|
||||||
|
|||||||
@@ -617,6 +617,15 @@ bool LiveKitSession::connect(const SessionConfig &config)
|
|||||||
livekit::RoomOptions options;
|
livekit::RoomOptions options;
|
||||||
// auto_subscribe is what makes track_subscribed events (and therefore any
|
// auto_subscribe is what makes track_subscribed events (and therefore any
|
||||||
// media at all) happen; the SDK is emphatic about this.
|
// media at all) happen; the SDK is emphatic about this.
|
||||||
|
//
|
||||||
|
// Known, measured-but-unaddressed cost: auto_subscribe pulls every
|
||||||
|
// participant's published track, not just the one camera this session
|
||||||
|
// actually wants, and this client discards the unwanted ones
|
||||||
|
// client-side. In a multi-camera room that is real, wasted bandwidth
|
||||||
|
// and decode CPU that scales with room size, not with what this source
|
||||||
|
// displays. Selectively unsubscribing from unwanted publications (the
|
||||||
|
// SDK exposes per-publication subscribe/unsubscribe) is a real
|
||||||
|
// follow-up optimization, deliberately out of scope here.
|
||||||
options.auto_subscribe = true;
|
options.auto_subscribe = true;
|
||||||
options.dynacast = false;
|
options.dynacast = false;
|
||||||
// This client never publishes, so a single peer connection is all it
|
// This client never publishes, so a single peer connection is all it
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ void testUrlEncodeAndRedaction()
|
|||||||
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
|
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testRedactSensitiveParams()
|
||||||
|
{
|
||||||
|
// The LiveKit log bridge's actual use case: a signaling URL embedded in
|
||||||
|
// a free-form SDK log line, not a bare query string.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams(
|
||||||
|
"connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"),
|
||||||
|
std::string("connecting to wss://lk.example.com/rtc?access_token=<redacted>&x=1"));
|
||||||
|
|
||||||
|
// A value can be terminated by a quote or whitespace, not just '&', since
|
||||||
|
// this scrubs arbitrary text rather than a URL/query string.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"),
|
||||||
|
std::string("url=\"wss://h/rtc?access_token=<redacted>\" state=connecting"));
|
||||||
|
|
||||||
|
// "key=" is also scrubbed, matching redactedUrl's convention.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"),
|
||||||
|
std::string("GET https://h/api/obs/r/slots?key=<redacted>"));
|
||||||
|
|
||||||
|
// Both params can appear in the same message, and each is independently
|
||||||
|
// redacted.
|
||||||
|
ST_ASSERT_EQ(
|
||||||
|
ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"),
|
||||||
|
std::string("a access_token=<redacted> b key=<redacted> c"));
|
||||||
|
|
||||||
|
// Text with neither parameter passes through unchanged.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"),
|
||||||
|
std::string("livekit: participant joined"));
|
||||||
|
|
||||||
|
// A value at the very end of the string is still bounded correctly.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"),
|
||||||
|
std::string("token was access_token=<redacted>"));
|
||||||
|
}
|
||||||
|
|
||||||
void testRequestShape()
|
void testRequestShape()
|
||||||
{
|
{
|
||||||
auto fake = makeFake(200, R"({"slots":[]})");
|
auto fake = makeFake(200, R"({"slots":[]})");
|
||||||
@@ -421,6 +453,7 @@ int main()
|
|||||||
{
|
{
|
||||||
testNormalizeServerUrl();
|
testNormalizeServerUrl();
|
||||||
testUrlEncodeAndRedaction();
|
testUrlEncodeAndRedaction();
|
||||||
|
testRedactSensitiveParams();
|
||||||
testRequestShape();
|
testRequestShape();
|
||||||
testSlotsHappyPath();
|
testSlotsHappyPath();
|
||||||
testSlotsEdgeCases();
|
testSlotsEdgeCases();
|
||||||
|
|||||||
@@ -410,7 +410,17 @@ void sourceDestroy(void *data)
|
|||||||
if (!self)
|
if (!self)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
{
|
||||||
|
// Must be set while holding `mutex`, matching how `generation` is
|
||||||
|
// mutated in applySettings: the worker's wait predicate reads
|
||||||
|
// `stopping` under this same lock, so setting it outside the lock
|
||||||
|
// can race between the worker's predicate check and it entering
|
||||||
|
// the wait, dropping the notify_all() below and leaving the worker
|
||||||
|
// asleep for its full backoff (up to kBackoffMaxMs) while this
|
||||||
|
// (OBS UI) thread blocks in worker.join().
|
||||||
|
std::lock_guard<std::mutex> guard(self->mutex);
|
||||||
self->stopping.store(true);
|
self->stopping.store(true);
|
||||||
|
}
|
||||||
self->wake.notify_all();
|
self->wake.notify_all();
|
||||||
if (self->worker.joinable())
|
if (self->worker.joinable())
|
||||||
self->worker.join();
|
self->worker.join();
|
||||||
@@ -563,7 +573,14 @@ void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::st
|
|||||||
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
|
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
|
||||||
default: obs_level = LOG_DEBUG; break;
|
default: obs_level = LOG_DEBUG; break;
|
||||||
}
|
}
|
||||||
obs_log(obs_level, "livekit: %s", message.c_str());
|
// LiveKit's signaling connection URL carries the access token as a
|
||||||
|
// query parameter. This is defensive, not a response to a confirmed
|
||||||
|
// leak: if the SDK ever logs that URL (or anything else carrying
|
||||||
|
// "access_token=" or "key="), the token must not land verbatim in an
|
||||||
|
// OBS log file that a director might paste into a support ticket. Scrub
|
||||||
|
// unconditionally before this message ever reaches obs_log.
|
||||||
|
const std::string scrubbed = ApiClient::redactSensitiveParams(message);
|
||||||
|
obs_log(obs_level, "livekit: %s", scrubbed.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
Reference in New Issue
Block a user