Red5 Pro WebSockets
WebSockets with Red5 Pro
WebSockets allows you to connect your JavaScript code on client-side to server-side Java code and create a low-latency remote method invocation or push notification mechanism. For example: you could create your own Red5 Pro Java application with business logic for real-time communication, data gathering and more, and access the methods from client-side using JavaScript. Quite useful! Especially since this is a low-latency technique that can be used in conjunction with the very low-latency video streaming on Red5 Pro Server. Here are few tips for using WebSockets on Red5 Pro.
Traditional systems of using Flash for a chat application involved connecting the Flash client to the application via a particular scope in Red5 Pro. A scope is a logical separation within Red5 Pro, much like logical partitions on a physical hard drive. Scopes give you the advantage of better management of resources when building applications which involve lots of connections. Traditionally scopes were used as “rooms” in chat applications.
The standard way of connecting to a Red5 Pro application via a RTMP client is to use the following RTMP URL format: rtmp://host:5080/{application}
. And to connect to a scope within the application, the URL format would be: rtmp://host:5080/{application}/{scopename}
WebSockets do not have rooms or scopes. So ws://localhost:8081/chat/room1/room2
won’t work out of the box. Think of WebSockets as a single-level application where there is no depth. However there can be paths
.
Migration Steps
Migration from Red5 Tomcat plugin version 1.20
The only new part of the configuration to support WebSocket, is the addition of the property to enable or disable the WebSocket feature within the tomcat.server
bean.
<property name="websocketEnabled" value="true" />
Migration from Red5 WebSocket plugin version 1.16.14 and earlier
The first step is to identify a special configuration in-place within your existing webSocketTransport
or webSocketTransportSecure
beans. If you have specified cipherSuites
or protocols
, they will need to be translated over to the Tomcat configuration bean. Once you’ve taken note of your configuration options, remove the webSocketTransport
or webSocketTransportSecure
beans in your conf/jee-container.xml
file.
The IP addresses and ports identified for ws
and wss
in the conf/jee-container.xml
file are no longer used. The http
and https
configuration in the Tomcat bean are used instead since this version of the WebSocket plugin is integrated with Tomcat itself.
WebSocket
Websocket plug-in is integrated into the Tomcat plugin as of Red5 Pro 5.4 release. The primary reasoning behind this is the maintenance aspect. This change also means a move away from Mina for the I/O layer for WebSockets; the previous plugin will continue to live on here.
This plugin is meant to provide websocket functionality for applications running in red5. The code is constructed to comply with rfc6455 and JSR365.
The previous Red5 WebSocket plugin was developed with assistence from Takahiko Toda and Dhruv Chopra.
Configuration
Update the conf/jee-container.xml
file to suit your needs.
Non-secure – http and ws:
<bean id="tomcat.server" class="org.red5.server.tomcat.TomcatLoader" depends-on="context.loader,warDeployer" lazy-init="true">
<property name="websocketEnabled" value="true" />
<property name="webappFolder" value="${red5.root}/webapps" />
<property name="connectors">
<list>
<bean name="httpConnector" class="org.red5.server.tomcat.TomcatConnector">
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="${http.host}:${http.port}" />
<property name="redirectPort" value="${https.port}" />
<property name="connectionProperties">
<map>
<entry key="maxHttpHeaderSize" value="${http.max_headers_size}"/>
<entry key="maxKeepAliveRequests" value="${http.max_keep_alive_requests}"/>
<entry key="keepAliveTimout" value="-1"/>
</map>
</property>
</bean>
</list>
</property>
<property name="baseHost">
<bean class="org.apache.catalina.core.StandardHost">
<property name="name" value="${http.host}" />
</bean>
</property>
</bean>
Secure – https and wss:
<bean id="tomcat.server" class="org.red5.server.tomcat.TomcatLoader" depends-on="context.loader" lazy-init="true">
<property name="websocketEnabled" value="true" />
<property name="webappFolder" value="${red5.root}/webapps" />
<property name="connectors">
<list>
<bean name="httpConnector" class="org.red5.server.tomcat.TomcatConnector">
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="${http.host}:${http.port}" />
<property name="redirectPort" value="${https.port}" />
</bean>
<bean name="httpsConnector" class="org.red5.server.tomcat.TomcatConnector">
<property name="secure" value="true" />
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="${http.host}:${https.port}" />
<property name="redirectPort" value="${http.port}" />
<property name="connectionProperties">
<map>
<entry key="port" value="${https.port}" />
<entry key="redirectPort" value="${http.port}" />
<entry key="SSLEnabled" value="true" />
<entry key="sslProtocol" value="TLSv1.2" />
<entry key="ciphers" value="TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_128_GCM_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA" />
<entry key="useServerCipherSuitesOrder" value="true" />
<entry key="keystoreFile" value="${rtmps.keystorefile}" />
<entry key="keystorePass" value="${rtmps.keystorepass}" />
<entry key="truststoreFile" value="${rtmps.truststorefile}" />
<entry key="truststorePass" value="${rtmps.truststorepass}" />
<entry key="clientAuth" value="false" />
<entry key="allowUnsafeLegacyRenegotiation" value="false" />
<entry key="maxHttpHeaderSize" value="${http.max_headers_size}"/>
<entry key="maxKeepAliveRequests" value="${http.max_keep_alive_requests}"/>
<entry key="keepAliveTimout" value="-1"/>
<entry key="useExecutor" value="true"/>
<entry key="maxThreads" value="${http.max_threads}"/>
<entry key="acceptorThreadCount" value="${http.acceptor_thread_count}"/>
<entry key="processorCache" value="${http.processor_cache}"/>
</map>
</property>
</bean>
</list>
</property>
<property name="baseHost">
<bean class="org.apache.catalina.core.StandardHost">
<property name="name" value="${http.host}" />
</bean>
</property>
</bean>
To bind to more than one IP address / port, add additional httpConnector
or httpsConnector
entries:
<property name="connectors">
<list>
<bean name="httpConnector" class="org.red5.server.tomcat.TomcatConnector">
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="${http.host}:${http.port}" />
<property name="redirectPort" value="${https.port}" />
</bean>
<bean name="httpConnector1" class="org.red5.server.tomcat.TomcatConnector">
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="192.168.1.1:5080" />
<property name="redirectPort" value="${https.port}" />
</bean>
<bean name="httpConnector2" class="org.red5.server.tomcat.TomcatConnector">
<property name="protocol" value="org.apache.coyote.http11.Http11Nio2Protocol" />
<property name="address" value="10.10.10.1:5080" />
<property name="redirectPort" value="${https.port}" />
</bean>
</list>
</property>
Note
If you are not using unlimited strength JCE (ex. you are outside the USA), your cipher suite selections will fail if any containing AES_256
are specified.
Initializing WebSockets For a Red5pro Application
Websockets can be enabled for a Red5pro application through its Application adapter class. The recommended way of doing this is to register websocket for the application in the appStart
handler and remove it using the appStop
handler. The code snippet given below shows how the Application adapter
of the chat application is used for registering and unregistering with the websocket plugin.
public class Application extends MultiThreadedApplicationAdapter implements ApplicationContextAware {
private static Logger log = Red5LoggerFactory.getLogger(Application.class, "chat");
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public boolean appStart(IScope scope) {
log.info("Chat starting");
configureApplicationScopeWebSocket(scope);
return super.appStart(scope);
}
@Override
public void appStop(IScope scope) {
log.info("Chat stopping");
// remove our app
WebSocketScopeManager manager = ((WebSocketPlugin) PluginRegistry.getPlugin("WebSocketPlugin")).getManager(scope);
manager.removeApplication(scope);
manager.stop();
}
/**
* Configures a websocket scope for a given application scope.
*
* @param scope Server application scope
*/
private void configureApplicationScopeWebSocket(IScope scope) {
// first get the websocket plugin
WebSocketPlugin wsPlugin = ((WebSocketPlugin) PluginRegistry.getPlugin(WebSocketPlugin.NAME));
// get the websocket scope manager for the red5 scope
WebSocketScopeManager manager = wsPlugin.getManager(scope);
if (manager == null) {
// get the application adapter
MultiThreadedApplicationAdapter app = (MultiThreadedApplicationAdapter) scope.getHandler();
log.debug("Creating WebSocketScopeManager for {}", app);
// set the application in the plugin to create a websocket scope manager for it
wsPlugin.setApplication(app);
// get the new manager
manager = wsPlugin.getManager(scope);
}
// the websocket scope
WebSocketScope wsScope = (WebSocketScope) scope.getAttribute(WSConstants.WS_SCOPE);
// check to see if its already configured
if (wsScope == null) {
log.debug("Configuring application scope: {}", scope);
// create a websocket scope for the application
wsScope = new WebSocketScope(scope);
// register the ws scope
wsScope.register();
}
}
}
Once the application is registered with the websocket plugin, the next step is to create your websocket handler class, extending the org.red5.net.websocket.listener.WebSocketDataListener
class. This class wil handle the standard server side websocket events for the clients. An example of the implementation would be the TODO
Finally we tell the application to use this WebSocketDataListener
implementation to handle all websocket requests to our Red5pro application. This is done by adding a Bean definition to the context file (red5-web.xml
) of the Red5pro application.
If you look at the Red5 red5-web.xml
file of the red5 websocket chat sample application, you will see how a reference of the Red5 application is made available to the websocket listener via the virtual router (Router.java) using spring bean configuration.
<bean id="web.handler" class="org.red5.demos.chat.Application" />
<bean id="router" class="org.red5.demos.chat.Router">
<property name="app" ref="web.handler" />
</bean>
<!-- WebSocket scope with our listeners -->
<bean id="webSocketScopeDefault" class="org.red5.net.websocket.WebSocketScope" lazy-init="true">
<!-- Application scope -->
<constructor-arg ref="web.scope" />
<!-- The ws scope listeners -->
<property name="listeners">
<list>
<bean id="chatListener" class="org.red5.demos.chat.WebSocketChatDataListener">
<property name="router" ref="router" />
</bean>
</list>
</property>
</bean>
Websocket filter (required for Red5 Pro version 5.4.0 or higher)
Lastly, the websocket filter must be added to each web application that will act as a websocket end point. In the webapp descriptor webapps/myapp/WEB-INF/web.xml
add this entry alongside any other filters or servlets.
<!-- WebSocket filter -->
<filter>
<filter-name>WebSocketFilter</filter-name>
<filter-class>org.red5.net.websocket.server.WsFilter</filter-class>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>WebSocketFilter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
To support subprotocols, add them as a comma-delimited string in the web.xml
:
<!-- WebSocket subprotocols -->
<context-param>
<param-name>subProtocols</param-name>
<param-value>chat,json</param-value>
</context-param>
The plugin will default to allowing any requested subprotocol if none are specified.
You can use this to form a means of communication between your Red5 application adapter and WebSocket data listener classes.
Extending the WebSocket Endpoint
Implementers may extend the default websocket endpoint class provided by this plugin org.red5.net.websocket.server.DefaultWebSocketEndpoint
. The first step is to become familiar with the class and then extend
it in your application; once that is complete, your class must be placed in the lib
directory of your Red5 server, not the webapps/yourapp/WEB-INF/lib
directory. Lastly, in your webapp descriptor webapps/yourapp/WEB-INF/web.xml
file, an entry named wsEndpointClass
will need to be made for your class:
<context-param>
<param-name>wsEndpointClass</param-name>
<param-value>com.mydomain.websocket.MyWebSocketEndpoint</param-value>
</context-param>
One reason to extend the endpoint for your own use is because the default endpoint implementation only handles text data.
Security Features
Since WebSockets don’t implement Same Origin Policy (SOP) nor Cross-Origin Resource Sharing (CORS), we’ve implemented a means to restrict access via configuration using SOP / CORS logic. To configure the security features, edit your conf/jee-container.xml
file and locate the bean displayed below:
<bean id="tomcat.server" class="org.red5.server.tomcat.TomcatLoader" depends-on="context.loader" lazy-init="true">
<property name="websocketEnabled" value="true" />
<property name="sameOriginPolicy" value="false" />
<property name="crossOriginPolicy" value="true" />
<property name="allowedOrigins">
<array>
<value>localhost</value>
<value>red5.org</value>
</array>
</property>
Properties:
- sameOriginPolicy – Enables or disables SOP. The logic differs from standard web SOP by NOT enforcing protocol and port.
- crossOriginPolicy – Enables or disables CORS. This option pairs with the
allowedOrigins
array. - allowedOrigins – The list or host names or fqdn which are to be permitted access. The default if none are specified is
*
which equates to any or all.
Using Scopes with WebSockets
To connect a WebSocket to a Red5 Pro application the following URL syntax is used: ws://{host}:5080/{application}/
. You then pass the scope path to the application via query string
or URL path
. How you wish to do this depends on your application design.
OPTION 1
rtmp://{host}:1935/{application}?scope=conference1 ws://{host}:5080/{application}/?scope=conference1
- When you use this method you will need to parse out the scope variable value from querystring.
- Your clients will all be connecting to the top-most application level.
- You need to design a mechanism to generate a unique name for your shared objects using the scope requested (since all connections are on single level). You then need to check if the shared object exists, and if not then create it with the evaluated name.
- RTMP clients can push messages to shared objects via client-side API.
- This will require special logic for WebSocket clients to resolve a SharedObject using scope name on server side and then push messages to it.
OPTION 2
rtmp://{host}:1935/{application}/conference1 ws://{host}:5080/{application}/conference1/
When you use this method you will need to capture the path
from the connection url for websocket handler. RTMP publisher will automatically create its sub scope(s).
WebSocketConnection.getPath()
- Your RTMP clients will all be connecting to the scope specified in the RTMP URL and WebSocket connections will connect as they normally do.
- RTMP clients can use same shared object name since the scope automatically manages isolation of shared object by same name. ie: /conference => SO and /conference1/SO are automatically separated and uniquely identified using the scope path.
- RTMP clients can push messages to shared objects via client side API.
- This will require special logic for WebSocket clients to resolve a SharedObject using scope name on server side and then push messages to it.
Or, you can also use a mix of Option 1 and 2 :
OPTION 3
rtmp://{host}:1935/{application}/conference1 ws://{host}:5080/{application}/?scope=conference1
OPTION 4
rtmp://{host}:1935/{application}?scope=conference1 ws://{host}:5080/{application}/conference1/
No matter which option you choose the challenge lies in connecting the WebSocket client to a scope for sending/receiving messages to/from RTMP clients. The answer to this can be found in the virtual router
implementation Router.java
of the sample WebSocket app red5 websocket chat.
Below is the function adapted from the red5-websocket-chat example app:
/**
* Get the Shared object for a given path.
*
* @param path
* @return the shared object for the path or null if its not available
*/
private ISharedObject getSharedObject(String path, String soname)
{
// get the application level scope
IScope appScope = app.getScope();
// resolve the path given to an existing scope
IScope scope = ScopeUtils.resolveScope(appScope, path);
if (scope == null)
{
// attempt to create the missing scope for the given path
if (!appScope.createChildScope(path))
{
log.warn("Scope creation failed for {}", path);
return null;
}
scope = ScopeUtils.resolveScope(appScope, path);
}
// get the shared object
ISharedObject so = app.getSharedObject(scope, soname);
if (so == null)
{
if (!app.createSharedObject(scope, soname, false))
{
log.warn("Chat SO creation failed");
return null;
}
// get the newly created shared object
so = app.getSharedObject(scope, "chat");
}
// ensure the so is acquired and our listener has been added
if (!so.isAcquired())
{
// acquire the so to prevent it being removed unexpectedly
so.acquire(); // TODO in a "real" world implementation, this would need to be paired with a call to release when the so is no longer needed
// add a listener for detecting sync on the so
so.addSharedObjectListener(new SharedObjectListener(this, scope, path));
}
return so;
}
Explanation
The function accepts a path
which is the location of the scope the WebSocket client is interested in for messages. This can be parsed from a query string or the WebSocket path
property (as mentioned above). The second parameter is the shared object name on which it wishes to convey messages. This name needs to be same for RTMP and WebSocket clients.
The function tries first to find the scope at the given location path
. If it fails to find one it will attempt to create one. If at least one RTMP client is connected to the scope it will persist automatically, otherwise it will be lost.
Once we have a scope we attempt to connect to a shared object in it by the name soname
. As with the scope, we have to force-create a new shared object if we can’t find an existing one. Finally we acquire
it and register a ISharedObjectListener
on it. This is to receive a notification when an event occurs on the SharedObject. A SharedObjectListener
is used to monitor events occuring on the acquired SharedObject
. The typical logic here is to update a attribute on the shared object such that it automatically triggers a sync event to all listeners (including flash clients).
NOTE: As a good programming habit make sure to release
the acquired object when you know it wont be used anymore.
The ISharedObjectListener implementation for this SharedObject would look like this:
private final class SharedObjectListener implements ISharedObjectListener
{
private final Router router;
private final IScope scope;
private final String path;
SharedObjectListener(Router router, IScope scope, String path) {
log.debug("path: {} scope: {}", path, scope);
this.router = router;
this.scope = scope;
this.path = path;
}
@Override
public void onSharedObjectClear(ISharedObjectBase so) {
log.debug("onSharedObjectClear path: {}", path);
}
@Override
public void onSharedObjectConnect(ISharedObjectBase so) {
log.debug("onSharedObjectConnect path: {}", path);
}
@Override
public void onSharedObjectDelete(ISharedObjectBase so, String key) {
log.debug("onSharedObjectDelete path: {} key: {}", path, key);
}
@Override
public void onSharedObjectDisconnect(ISharedObjectBase so) {
log.debug("onSharedObjectDisconnect path: {}", path);
}
@Override
public void onSharedObjectSend(ISharedObjectBase so, String method, List<?> attributes) {
log.debug("onSharedObjectSend path: {} - method: {} {}", path, method, attributes);
}
@Override
public void onSharedObjectUpdate(ISharedObjectBase so, IAttributeStore attributes) {
log.debug("onSharedObjectUpdate path: {} - {}", path, attributes);
}
@Override
public void onSharedObjectUpdate(ISharedObjectBase so, Map<String, Object> attributes) {
log.debug("onSharedObjectUpdate path: {} - {}", path, attributes);
}
@Override
public void onSharedObjectUpdate(ISharedObjectBase so, String key, Object value) {
log.debug("onSharedObjectUpdate path: {} - {} = {}", path, key, value);
// route to the websockets if we have an RTMP connection as the originator, otherwise websockets will get duplicate messages
if (Red5.getConnectionLocal() != null) {
router.route(scope, value.toString());
}
}
}
In the above class SharedObjectListener
, take note of the method onSharedObjectUpdate
. On the SharedObject update event we check to make sure that only messages from RTMP clients are relayed to WebSocket Clients. To prevent duplicates, messages from WebSocket clients are not relayed. (If you wanted to send messages from WebSocket to WebSocket you could design a unicast/multicast solution to check certain parameters, such as IP address, and relay messages only to specific WebSocket connections).