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 WebSocketChatDataListener.java class.

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).

Demo application project

Red5 open source websocket chat