Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ if(NOT ENABLE_WEBSOCKET)
return()
endif()

option(ENABLE_WEBSOCKET_CLIENT_TLS "Enable TLS (wss://) for obs-websocket client mode" ON)

# Find Qt
find_package(Qt6 REQUIRED Core Widgets Svg Network)

Expand All @@ -28,6 +30,11 @@ find_package(Websocketpp 0.8 REQUIRED)
# Find Asio
find_package(Asio 1.12.1 REQUIRED)

if(ENABLE_WEBSOCKET_CLIENT_TLS)
# Find OpenSSL (required for TLS client mode)
find_package(OpenSSL REQUIRED)
endif()

add_library(obs-websocket MODULE)
add_library(OBS::websocket ALIAS obs-websocket)

Expand All @@ -49,12 +56,15 @@ target_sources(
target_sources(
obs-websocket
PRIVATE # cmake-format: sortable
src/websocketclient/WebSocketClient.cpp
src/websocketclient/WebSocketClient.h
src/websocketserver/rpc/WebSocketSession.h
src/websocketserver/types/WebSocketCloseCode.h
src/websocketserver/types/WebSocketOpCode.h
src/websocketserver/WebSocketProtocol.cpp
src/websocketserver/WebSocketProtocol.h
src/websocketserver/WebSocketServer.cpp
src/websocketserver/WebSocketServer.h
src/websocketserver/WebSocketServer_Protocol.cpp)
src/websocketserver/WebSocketServer.h)

target_sources(
obs-websocket
Expand Down Expand Up @@ -131,7 +141,8 @@ target_sources(obs-websocket PRIVATE plugin-macros.generated.h)

target_compile_definitions(
obs-websocket PRIVATE ASIO_STANDALONE $<$<BOOL:${PLUGIN_TESTS}>:PLUGIN_TESTS>
$<$<PLATFORM_ID:Windows>:_WEBSOCKETPP_CPP11_STL_> $<$<PLATFORM_ID:Windows>:_WIN32_WINNT=0x0603>)
$<$<PLATFORM_ID:Windows>:_WEBSOCKETPP_CPP11_STL_> $<$<PLATFORM_ID:Windows>:_WIN32_WINNT=0x0603>
OBS_WEBSOCKET_CLIENT_TLS=$<BOOL:${ENABLE_WEBSOCKET_CLIENT_TLS}>)

target_compile_options(
obs-websocket
Expand Down Expand Up @@ -165,6 +176,10 @@ target_link_libraries(
Asio::Asio
qrcodegencpp::qrcodegencpp)

if(ENABLE_WEBSOCKET_CLIENT_TLS)
target_link_libraries(obs-websocket PRIVATE OpenSSL::SSL OpenSSL::Crypto)
endif()

target_link_options(obs-websocket PRIVATE $<$<PLATFORM_ID:Windows>:/IGNORE:4099>)

set_target_properties_obs(
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Binaries **for OBS Studio < 28.0.0** on Windows, MacOS, and Linux are available

It is **highly recommended** to keep obs-websocket protected with a password against unauthorized control. obs-websocket generates a password for you automatically when you load it for the first time. To change this, open the "obs-websocket Settings" dialog under OBS' "Tools" menu. In the settings dialog, you can enable or disable authentication and set a password for it.

(Psst. You can use `--websocket_port`(value), `--websocket_password`(value), `--websocket_debug`(flag) and `--websocket_ipv4_only`(flag) on the command line to override the configured values.)
(Psst. You can use `--websocket_port`(value), `--websocket_password`(value), `--websocket_debug`(flag) and `--websocket_ipv4_only`(flag) on the command line to override configured **server** values. For outbound client mode, you can use `--websocket_client_enabled`(flag), `--websocket_client_url`(value), `--websocket_client_password`(value), `--websocket_client_allow_insecure`(flag), and `--websocket_client_allow_invalid_cert`(flag).)

### Possible use cases

Expand Down Expand Up @@ -61,6 +61,14 @@ Here's a list of available language APIs for obs-websocket:
The 5.x server is a typical WebSocket server running by default on port 4455 (the port number can be changed in the Settings dialog under `Tools`).
The protocol we use is documented in [PROTOCOL.md](docs/generated/protocol.md).

### Client mode (outbound)

obs-websocket can optionally initiate an outbound WebSocket connection to a remote controller while preserving the existing protocol semantics (OBS still sends `Hello`, the remote controller `Identifies`). Client mode is disabled by default and lives in the obs-websocket Settings dialog. Enter a full WebSocket connection URL using `ws://` or `wss://` (for example `wss://controller.example.com:4455`).

Client connection URLs must not include credentials, a path, a query string, or a fragment. If a port is provided, it must be in the range `1-65534`.

TLS (`wss://`) is recommended, while unencrypted `ws://` connections are gated behind an explicit unsafe toggle. TLS support requires OpenSSL and can be toggled at build time with `-DENABLE_WEBSOCKET_CLIENT_TLS=ON|OFF` (default ON). Server mode and outbound client mode can be enabled independently and run at the same time.

We'd like to know what you're building with obs-websocket! If you do something in this fashion, feel free to drop a message in `#project-showoff` in the [discord server!](https://discord.gg/WBaSQ3A)

## Contributors
Expand Down
44 changes: 43 additions & 1 deletion data/locale/en-US.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
OBSWebSocket.Plugin.Description="Remote-control of OBS Studio through WebSocket"

OBSWebSocket.Settings.DialogTitle="WebSocket Server Settings"
OBSWebSocket.Settings.DialogTitle="WebSocket Settings"

OBSWebSocket.Settings.PluginSettingsTitle="Plugin Settings"
OBSWebSocket.Settings.ServerEnable="Enable WebSocket server"
Expand All @@ -23,7 +23,49 @@ OBSWebSocket.Settings.Save.UserPasswordWarningInfoText="Are you sure you want to
OBSWebSocket.Settings.Save.PasswordInvalidErrorTitle="Error: Invalid Configuration"
OBSWebSocket.Settings.Save.PasswordInvalidErrorMessage="You must use a password that is 6 or more characters."

OBSWebSocket.Settings.ClientSettingsTitle="Client Settings (Outbound)"
OBSWebSocket.Settings.ClientEnable="Enable WebSocket client mode (outbound)"
OBSWebSocket.Settings.ClientHost="Client Connection"
OBSWebSocket.Settings.ClientHostPlaceholder="wss://controller.example.com:4455"
OBSWebSocket.Settings.ClientPort="Client Port"
OBSWebSocket.Settings.ClientUseTls="Use TLS (wss://)"
OBSWebSocket.Settings.ClientAllowInsecure="Allow unencrypted ws:// connections (unsafe)"
OBSWebSocket.Settings.ClientAllowInvalidCert="Allow invalid TLS certificates (unsafe)"
OBSWebSocket.Settings.ClientAuthRequired="Require Authentication"
OBSWebSocket.Settings.ClientPassword="Client Password"
OBSWebSocket.Settings.Show="Show"
OBSWebSocket.Settings.Hide="Hide"
OBSWebSocket.Settings.ClientGeneratePassword="Generate Password"
OBSWebSocket.Settings.ClientStatus="Client Status"
OBSWebSocket.Settings.ClientStatusUnavailable="Unavailable"
OBSWebSocket.Settings.ClientStatusDisabled="Disabled"
OBSWebSocket.Settings.ClientStatusConnecting="Connecting"
OBSWebSocket.Settings.ClientStatusConnected="Connected"
OBSWebSocket.Settings.ClientStatusDisconnected="Disconnected"
OBSWebSocket.Settings.ClientStatusError="Error"
OBSWebSocket.Settings.ClientWarningText="Client mode initiates an outbound connection to a remote controller. Only enable for trusted endpoints."
OBSWebSocket.Settings.ClientHostInvalidTitle="Error: Invalid Client Connection"
OBSWebSocket.Settings.ClientHostInvalidMessage="Client connection is required when client mode is enabled."
OBSWebSocket.Settings.ClientConnectionInvalidMessage="Client connection must be a valid URL (for example: wss://controller.example.com:4455)."
OBSWebSocket.Settings.ClientConnectionInvalidSchemeMessage="Client connection must use ws:// or wss://."
OBSWebSocket.Settings.ClientConnectionCredentialsMessage="Client connection must not include username or password."
OBSWebSocket.Settings.ClientConnectionPathMessage="Client connection must not include a path, query, or fragment."
OBSWebSocket.Settings.ClientConnectionPortMessage="Client connection port must be between 1 and 65534."
OBSWebSocket.Settings.ClientInsecureBlockedTitle="Error: Insecure Client Mode"
OBSWebSocket.Settings.ClientInsecureBlockedMessage="Unencrypted ws:// is disabled. Enable the unsafe toggle to allow ws://."
OBSWebSocket.Settings.ClientTlsDisabledMessage="TLS is disabled in this build. Rebuild with ENABLE_WEBSOCKET_CLIENT_TLS=ON to allow wss://."
OBSWebSocket.Settings.Save.ClientPasswordWarningTitle="Warning: Potential Security Issue"
OBSWebSocket.Settings.Save.ClientPasswordWarningMessage="obs-websocket stores the client password as plain text. Using a password generated by obs-websocket is highly recommended."
OBSWebSocket.Settings.Save.ClientPasswordWarningInfoText="Are you sure you want to use your own password?"
OBSWebSocket.Settings.ClientEnableWarningTitle="Warning: Client Mode"
OBSWebSocket.Settings.ClientEnableWarningMessage="Client mode allows remote control of OBS by connecting to a remote server."
OBSWebSocket.Settings.ClientEnableWarningInfoText="Only enable this for endpoints you trust."
OBSWebSocket.Settings.ClientInsecureWarningTitle="Warning: Insecure Client Settings"
OBSWebSocket.Settings.ClientInsecureWarningMessage="You are allowing insecure client settings (unencrypted or invalid certificates)."
OBSWebSocket.Settings.ClientInsecureWarningInfoText="Only proceed if you understand the risks."

OBSWebSocket.SessionTable.Title="Connected WebSocket Sessions"
OBSWebSocket.SessionTable.ServerOnlyNote="Shows inbound connections to this OBS instance only. Outbound client mode appears above."
OBSWebSocket.SessionTable.RemoteAddressColumnTitle="Remote Address"
OBSWebSocket.SessionTable.SessionDurationColumnTitle="Session Duration"
OBSWebSocket.SessionTable.MessagesInOutColumnTitle="Messages In/Out"
Expand Down
151 changes: 151 additions & 0 deletions src/Config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ with this program. If not, see <https://www.gnu.org/licenses/>

#include <filesystem>

#include <QUrl>
#include <obs-frontend-api.h>

#include "Config.h"
Expand All @@ -41,11 +42,89 @@ with this program. If not, see <https://www.gnu.org/licenses/>
#define PARAM_ALERTS "alerts_enabled"
#define PARAM_AUTHREQUIRED "auth_required"
#define PARAM_PASSWORD "server_password"
#define PARAM_CLIENT_ENABLED "client_enabled"
#define PARAM_CLIENT_HOST "client_host"
#define PARAM_CLIENT_PORT "client_port"
#define PARAM_CLIENT_USE_TLS "client_use_tls"
#define PARAM_CLIENT_ALLOW_INSECURE "client_allow_insecure"
#define PARAM_CLIENT_ALLOW_INVALID_CERT "client_allow_invalid_cert"
#define PARAM_CLIENT_AUTH_REQUIRED "client_auth_required"
#define PARAM_CLIENT_PASSWORD "client_password"

#define CMDLINE_WEBSOCKET_PORT "websocket_port"
#define CMDLINE_WEBSOCKET_IPV4_ONLY "websocket_ipv4_only"
#define CMDLINE_WEBSOCKET_PASSWORD "websocket_password"
#define CMDLINE_WEBSOCKET_DEBUG "websocket_debug"
#define CMDLINE_WEBSOCKET_CLIENT_ENABLED "websocket_client_enabled"
#define CMDLINE_WEBSOCKET_CLIENT_URL "websocket_client_url"
#define CMDLINE_WEBSOCKET_CLIENT_PASSWORD "websocket_client_password"
#define CMDLINE_WEBSOCKET_CLIENT_ALLOW_INSECURE "websocket_client_allow_insecure"
#define CMDLINE_WEBSOCKET_CLIENT_ALLOW_INVALID_CERT "websocket_client_allow_invalid_cert"

namespace {
struct ParsedClientEndpoint {
bool valid = false;
bool useTls = false;
std::string host;
bool hasPort = false;
uint16_t port = 0;
std::string error;
};

ParsedClientEndpoint ParseClientEndpoint(QString endpointInput)
{
ParsedClientEndpoint parsed;
QString trimmed = endpointInput.trimmed();
if (trimmed.isEmpty()) {
parsed.error = "value is empty";
return parsed;
}

QUrl endpoint(trimmed, QUrl::StrictMode);
if (!endpoint.isValid()) {
parsed.error = "value is not a valid URL";
return parsed;
}

QString scheme = endpoint.scheme().toLower();
if (scheme != "ws" && scheme != "wss") {
parsed.error = "URL scheme must be ws:// or wss://";
return parsed;
}

if (endpoint.host().isEmpty()) {
parsed.error = "URL host is empty";
return parsed;
}

if (!endpoint.userName().isEmpty() || !endpoint.password().isEmpty()) {
parsed.error = "URL must not include credentials";
return parsed;
}

QString path = endpoint.path();
if ((path.size() > 1) || endpoint.hasQuery() || endpoint.hasFragment()) {
parsed.error = "URL must not include a path, query, or fragment";
return parsed;
}

int parsedPort = endpoint.port(-1);
if (parsedPort != -1) {
if (parsedPort < 1 || parsedPort > 65534) {
parsed.error = "URL port must be between 1 and 65534";
return parsed;
}

parsed.hasPort = true;
parsed.port = static_cast<uint16_t>(parsedPort);
}

parsed.valid = true;
parsed.useTls = (scheme == "wss");
parsed.host = endpoint.host().toStdString();
return parsed;
}
}

void Config::Load(json config)
{
Expand All @@ -72,6 +151,22 @@ void Config::Load(json config)
AuthRequired = config[PARAM_AUTHREQUIRED];
if (config.contains(PARAM_PASSWORD) && config[PARAM_PASSWORD].is_string())
ServerPassword = config[PARAM_PASSWORD];
if (config.contains(PARAM_CLIENT_ENABLED) && config[PARAM_CLIENT_ENABLED].is_boolean())
ClientEnabled = config[PARAM_CLIENT_ENABLED];
if (config.contains(PARAM_CLIENT_HOST) && config[PARAM_CLIENT_HOST].is_string())
ClientHost = config[PARAM_CLIENT_HOST];
if (config.contains(PARAM_CLIENT_PORT) && config[PARAM_CLIENT_PORT].is_number_unsigned())
ClientPort = config[PARAM_CLIENT_PORT];
if (config.contains(PARAM_CLIENT_USE_TLS) && config[PARAM_CLIENT_USE_TLS].is_boolean())
ClientUseTls = config[PARAM_CLIENT_USE_TLS];
if (config.contains(PARAM_CLIENT_ALLOW_INSECURE) && config[PARAM_CLIENT_ALLOW_INSECURE].is_boolean())
ClientAllowInsecure = config[PARAM_CLIENT_ALLOW_INSECURE];
if (config.contains(PARAM_CLIENT_ALLOW_INVALID_CERT) && config[PARAM_CLIENT_ALLOW_INVALID_CERT].is_boolean())
ClientAllowInvalidCert = config[PARAM_CLIENT_ALLOW_INVALID_CERT];
if (config.contains(PARAM_CLIENT_AUTH_REQUIRED) && config[PARAM_CLIENT_AUTH_REQUIRED].is_boolean())
ClientAuthRequired = config[PARAM_CLIENT_AUTH_REQUIRED];
if (config.contains(PARAM_CLIENT_PASSWORD) && config[PARAM_CLIENT_PASSWORD].is_string())
ClientPassword = config[PARAM_CLIENT_PASSWORD];

// Set server password and save it to the config before processing overrides,
// so that there is always a true configured password regardless of if
Expand All @@ -87,6 +182,11 @@ void Config::Load(json config)
Save();
}

if (ClientHost.empty())
ClientHost = "127.0.0.1";
if (ClientPassword.empty())
ClientPassword = ServerPassword;

// If there are migrated settings, write them to disk before processing arguments.
if (!config.empty())
Save();
Expand Down Expand Up @@ -126,6 +226,49 @@ void Config::Load(json config)
blog(LOG_INFO, "[Config::Load] --websocket_debug passed. Enabling debug logging.");
DebugEnabled = true;
}

// Process `--websocket_client_enabled` override
if (Utils::Platform::GetCommandLineFlagSet(CMDLINE_WEBSOCKET_CLIENT_ENABLED)) {
blog(LOG_INFO, "[Config::Load] --websocket_client_enabled passed. Enabling WebSocket client mode.");
ClientEnabled = true;
}

// Process `--websocket_client_url` override
QString clientUrlArgument = Utils::Platform::GetCommandLineArgument(CMDLINE_WEBSOCKET_CLIENT_URL);
if (clientUrlArgument != "") {
auto parsedEndpoint = ParseClientEndpoint(clientUrlArgument);
if (parsedEndpoint.valid) {
blog(LOG_INFO, "[Config::Load] --websocket_client_url passed. Overriding client endpoint.");
ClientHost = parsedEndpoint.host;
ClientUseTls = parsedEndpoint.useTls;
if (parsedEndpoint.hasPort)
ClientPort = parsedEndpoint.port;
} else {
blog(LOG_WARNING, "[Config::Load] Ignoring --websocket_client_url override: %s",
parsedEndpoint.error.c_str());
}
}

// Process `--websocket_client_password` override
QString clientPasswordArgument = Utils::Platform::GetCommandLineArgument(CMDLINE_WEBSOCKET_CLIENT_PASSWORD);
if (clientPasswordArgument != "") {
blog(LOG_INFO, "[Config::Load] --websocket_client_password passed. Overriding WebSocket client password.");
ClientAuthRequired = true;
ClientPassword = clientPasswordArgument.toStdString();
}

// Process `--websocket_client_allow_insecure` override
if (Utils::Platform::GetCommandLineFlagSet(CMDLINE_WEBSOCKET_CLIENT_ALLOW_INSECURE)) {
blog(LOG_INFO, "[Config::Load] --websocket_client_allow_insecure passed. Enabling insecure ws:// client mode.");
ClientAllowInsecure = true;
}

// Process `--websocket_client_allow_invalid_cert` override
if (Utils::Platform::GetCommandLineFlagSet(CMDLINE_WEBSOCKET_CLIENT_ALLOW_INVALID_CERT)) {
blog(LOG_INFO,
"[Config::Load] --websocket_client_allow_invalid_cert passed. Allowing invalid TLS certs for client mode.");
ClientAllowInvalidCert = true;
}
}

void Config::Save()
Expand All @@ -144,6 +287,14 @@ void Config::Save()
config[PARAM_AUTHREQUIRED] = AuthRequired.load();
config[PARAM_PASSWORD] = ServerPassword;
}
config[PARAM_CLIENT_ENABLED] = ClientEnabled.load();
config[PARAM_CLIENT_HOST] = ClientHost;
config[PARAM_CLIENT_PORT] = ClientPort.load();
config[PARAM_CLIENT_USE_TLS] = ClientUseTls.load();
config[PARAM_CLIENT_ALLOW_INSECURE] = ClientAllowInsecure.load();
config[PARAM_CLIENT_ALLOW_INVALID_CERT] = ClientAllowInvalidCert.load();
config[PARAM_CLIENT_AUTH_REQUIRED] = ClientAuthRequired.load();
config[PARAM_CLIENT_PASSWORD] = ClientPassword;

if (Utils::Json::SetJsonFileContent(configFilePath, config))
blog(LOG_DEBUG, "[Config::Save] Saved config.");
Expand Down
9 changes: 9 additions & 0 deletions src/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ struct Config {
std::atomic<bool> AlertsEnabled = false;
std::atomic<bool> AuthRequired = true;
std::string ServerPassword;

std::atomic<bool> ClientEnabled = false;
std::string ClientHost = "127.0.0.1";
std::atomic<uint16_t> ClientPort = 4455;
std::atomic<bool> ClientUseTls = true;
std::atomic<bool> ClientAllowInsecure = false;
std::atomic<bool> ClientAllowInvalidCert = false;
std::atomic<bool> ClientAuthRequired = true;
std::string ClientPassword;
};

json MigrateGlobalConfigData();
Expand Down
Loading