LiveKit integration: real camera feed pipeline #1

Merged
jknapp merged 17 commits from feat/livekit-integration into main 2026-09-07 06:06:50 +00:00
7 changed files with 168 additions and 4 deletions
Showing only changes of commit f2a4932eea - Show all commits
+46
View File
@@ -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)
+11
View File
@@ -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_;
}; };
+39
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
+33
View File
@@ -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();
+19 -2
View File
@@ -410,7 +410,17 @@ void sourceDestroy(void *data)
if (!self) if (!self)
return; return;
self->stopping.store(true); {
// 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->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