Commit d574fed4 authored by Tom Evans's avatar Tom Evans

OF-933: Initial WebSocket implementation

Adds websocket capabilities compliant with the latest specifications
(RFC 7395).

Note that the new component is implemented as a plugin, but I have
included a few small modifications to core classes to improve
extensibility.

I have tested these changes using the stanza.io client library on both
Chrome and Firefox.
parent ee22466c
......@@ -35,7 +35,7 @@ import java.io.UnsupportedEncodingException;
*/
public class SessionPacketRouter implements PacketRouter {
private LocalClientSession session;
protected LocalClientSession session;
private PacketRouter router;
private boolean skipJIDValidation = false;
......
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<title>Openfire WebSocket Plugin Changelog</title>
<style type="text/css">
BODY {
font-size : 100%;
}
BODY, TD, TH {
font-family : tahoma, verdana, arial, helvetica, sans-serif;
font-size : 0.8em;
}
H2 {
font-size : 10pt;
font-weight : bold;
padding-left : 1em;
}
A:hover {
text-decoration : none;
}
H1 {
font-family : tahoma, arial, helvetica, sans-serif;
font-size : 1.4em;
font-weight: bold;
border-bottom : 1px #ccc solid;
padding-bottom : 2px;
}
TT {
font-family : courier new;
font-weight : bold;
color : #060;
}
PRE {
font-family : courier new;
font-size : 100%;
}
</style>
</head>
<body>
<h1>
Openfire WebSocket Plugin Changelog
</h1>
<p><b>1.0</b> -- July 28, 2015</p>
<ul>
<li>Initial release. </li>
</ul>
</body>
</html>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!--
Plugin configuration for the WebSocket plugin.
-->
<plugin>
<class>org.jivesoftware.openfire.websocket.WebSocketPlugin</class>
<name>Openfire WebSocket</name>
<description>Provides WebSocket support for Openfire.</description>
<author>Tom Evans</author>
<version>1.0.0</version>
<date>07/28/2015</date>
<url>https://tools.ietf.org/html/rfc7395</url>
<minServerVersion>3.10.0</minServerVersion>
</plugin>
\ No newline at end of file
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<title>Openfire WebSocket Plugin Readme</title>
<style type="text/css">
BODY {
font-size : 100%;
}
BODY, TD, TH {
font-family : tahoma, verdana, arial, helvetica, sans-serif;
font-size : 0.8em;
}
H2 {
font-size : 10pt;
font-weight : bold;
}
A:hover {
text-decoration : none;
}
H1 {
font-family : tahoma, arial, helvetica, sans-serif;
font-size : 1.4em;
font-weight: bold;
border-bottom : 1px #ccc solid;
padding-bottom : 2px;
}
TT {
font-family : courier new;
font-weight : bold;
color : #060;
}
PRE {
font-family : courier new;
font-size : 100%;
}
</style>
</head>
<body>
<h1>
Openfire WebSocket Plugin Readme
</h1>
<h2>Overview</h2>
<p>
This plugin extends Openfire to support WebSocket. The implementation follows the XMPP WebSocket subprotocol
(<a href="https://tools.ietf.org/html/rfc7395">RFC 7395</a>) specification, which is a standard extension of the
WebSocket protocol specification (<a href="https://tools.ietf.org/html/rfc6455">RFC 6455</a>).
</p>
<p>
Note that the BOSH (http-bind) capabilities of Openfire must be enabled and correctly configured as a
prerequisite before installing this plugin. The WebSocket servlet is installed within the same context
as the BOSH component, and will reuse the same HTTP/S port(s) when establishing the WebSocket connection.
</p>
<h2>Installation</h2>
<p>Copy websocket.jar into the plugins directory of your Openfire installation. The
plugin will then be automatically deployed. To upgrade to a new version, copy the new
websocket.jar file over the existing file.</p>
<p>
Upon installation, the WebSocket URI path will be /ws/ on the same server/port as the BOSH
connector. To establish a secure WebSocket, modify the following URL as appropriate:
</p>
<pre>
wss://your.openfire.host:7443/ws/
</pre>
</body>
</html>
package org.jivesoftware.openfire.websocket;
import java.io.UnsupportedEncodingException;
import org.dom4j.Element;
import org.jivesoftware.openfire.SessionPacketRouter;
import org.jivesoftware.openfire.multiplex.UnknownStanzaException;
import org.jivesoftware.openfire.session.LocalClientSession;
import org.jivesoftware.openfire.streammanagement.StreamManager;
import org.jivesoftware.util.JiveGlobals;
/**
* This class extends Openfire's session packet router with the ACK capabilities
* specified by XEP-0198: Stream Management.
*
* NOTE: This class does NOT support the XEP-0198 stream resumption capabilities.
*
* XEP-0198 allows either party (client or server) to send unsolicited ack/answer
* stanzas on a periodic basis. This implementation approximates BOSH ack behavior
* by sending unsolicited <a /> stanzas from the server to the client after a
* configurable number of stanzas have been received from the client.
*
* Setting the system property to "1" would indicate that each client packet should
* be ack'd by the server when stream management is enabled for a particular stream.
* To disable unsolicited server acks, use the default value for system property
* "stream.management.unsolicitedAckFrequency" ("0"). This setting does not affect
* server responses to explicit ack requests from the client.
*/
public class StreamManagementPacketRouter extends SessionPacketRouter {
public static final String SM_UNSOLICITED_ACK_FREQUENCY = "stream.management.unsolicitedAckFrequency";
static {
JiveGlobals.migrateProperty(SM_UNSOLICITED_ACK_FREQUENCY);
}
private int unsolicitedAckFrequency = JiveGlobals.getIntProperty(SM_UNSOLICITED_ACK_FREQUENCY, 0);
public StreamManagementPacketRouter(LocalClientSession session) {
super(session);
}
@Override
public void route(Element wrappedElement) throws UnsupportedEncodingException, UnknownStanzaException {
String tag = wrappedElement.getName();
if (StreamManager.NAMESPACE_V3.equals(wrappedElement.getNamespace().getStringValue())) {
switch(tag) {
case "enable":
session.enableStreamMangement(wrappedElement);
break;
case "r":
session.getStreamManager().sendServerAcknowledgement();
break;
case "a":
session.getStreamManager().processClientAcknowledgement(wrappedElement);
break;
default:
session.getStreamManager().sendUnexpectedError();
}
} else {
super.route(wrappedElement);
if (isUnsolicitedAckExpected()) {
session.getStreamManager().sendServerAcknowledgement();
}
}
}
private boolean isUnsolicitedAckExpected() {
if (!session.getStreamManager().isEnabled()) {
return false;
}
return unsolicitedAckFrequency > 0 && session.getNumClientPackets() % unsolicitedAckFrequency == 0;
}
}
package org.jivesoftware.openfire.websocket;
import java.net.InetSocketAddress;
import org.dom4j.Namespace;
import org.jivesoftware.openfire.PacketDeliverer;
import org.jivesoftware.openfire.auth.UnauthorizedException;
import org.jivesoftware.openfire.net.VirtualConnection;
import org.jivesoftware.openfire.nio.OfflinePacketDeliverer;
import org.xmpp.packet.Packet;
import org.xmpp.packet.StreamError;
/**
* Following the conventions of the BOSH implementation, this class extends {@link VirtualConnection}
* and delegates the expected XMPP connection behaviors to the corresponding {@link XmppWebSocket}.
*/
public class WebSocketConnection extends VirtualConnection
{
private static final String CLIENT_NAMESPACE = "jabber:client";
private InetSocketAddress remotePeer;
private XmppWebSocket socket;
private PacketDeliverer backupDeliverer;
public WebSocketConnection(XmppWebSocket socket, InetSocketAddress remotePeer) {
this.socket = socket;
this.remotePeer = remotePeer;
}
@Override
public void closeVirtualConnection()
{
socket.closeSession();
}
@Override
public byte[] getAddress() {
return remotePeer.getAddress().getAddress();
}
@Override
public String getHostAddress() {
return remotePeer.getAddress().getHostAddress();
}
@Override
public String getHostName() {
return remotePeer.getHostName();
}
@Override
public void systemShutdown() {
deliverRawText(new StreamError(StreamError.Condition.system_shutdown).toXML());
close();
}
@Override
public void deliver(Packet packet) throws UnauthorizedException
{
if (Namespace.NO_NAMESPACE.equals(packet.getElement().getNamespace())) {
packet.getElement().add(Namespace.get(CLIENT_NAMESPACE));
}
if (validate()) {
deliverRawText(packet.toXML());
} else {
// use fallback delivery mechanism (offline)
getPacketDeliverer().deliver(packet);
}
}
@Override
public void deliverRawText(String text)
{
socket.deliver(text);
}
@Override
public boolean validate() {
return socket.isWebSocketOpen();
}
@Override
public boolean isSecure() {
return socket.isWebSocketSecure();
}
@Override
public PacketDeliverer getPacketDeliverer() {
if (backupDeliverer == null) {
backupDeliverer = new OfflinePacketDeliverer();
}
return backupDeliverer;
}
@Override
public boolean isCompressed() {
return XmppWebSocket.isCompressionEnabled();
}
}
package org.jivesoftware.openfire.websocket;
import java.io.File;
import java.text.MessageFormat;
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.websocket.common.extensions.compress.PerMessageDeflateExtension;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
import org.eclipse.jetty.websocket.servlet.WebSocketCreator;
import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.jivesoftware.openfire.SessionManager;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.container.Plugin;
import org.jivesoftware.openfire.container.PluginClassLoader;
import org.jivesoftware.openfire.container.PluginManager;
import org.jivesoftware.openfire.http.HttpBindManager;
import org.jivesoftware.openfire.session.ClientSession;
import org.jivesoftware.openfire.session.LocalSession;
import org.jivesoftware.util.JiveGlobals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This plugin enables XMPP over WebSocket (RFC 7395) for Openfire.
*
* The Jetty WebSocketServlet serves as a base class and enables easy integration into the
* BOSH (http-bind) web context. Each WebSocket request received at the "/ws/" URI will be
* forwarded to this plugin/servlet, which will in turn create a new {@link XmppWebSocket}
* for each new connection.
*/
public class WebSocketPlugin extends WebSocketServlet implements Plugin {
private static final long serialVersionUID = 7281841492829464603L;
private static final Logger Log = LoggerFactory.getLogger(WebSocketPlugin.class);
private ServletContextHandler contextHandler;
protected PluginClassLoader pluginClassLoader = null;
@Override
public void initializePlugin(final PluginManager manager, final File pluginDirectory) {
if (Boolean.valueOf(JiveGlobals.getBooleanProperty(HttpBindManager.HTTP_BIND_ENABLED, true))) {
Log.info(String.format("Initializing websocket plugin"));
try {
ContextHandlerCollection contexts = HttpBindManager.getInstance().getContexts();
contextHandler = new ServletContextHandler(contexts, "/ws", ServletContextHandler.SESSIONS);
contextHandler.addServlet(new ServletHolder(this), "/*");
} catch (Exception e) {
Log.error("Failed to start websocket plugin", e);
}
} else {
Log.warn("Failed to start websocket plugin; http-bind is disabled");
}
}
@Override
public void destroyPlugin() {
// terminate any active websocket sessions
SessionManager sm = XMPPServer.getInstance().getSessionManager();
for (ClientSession session : sm.getSessions()) {
if (session instanceof LocalSession) {
Object ws = ((LocalSession) session).getSessionData("ws");
if (ws != null && (Boolean) ws) {
session.close();
}
}
}
ContextHandlerCollection contexts = HttpBindManager.getInstance().getContexts();
contexts.removeHandler(contextHandler);
contextHandler = null;
pluginClassLoader = null;
}
@Override
public void configure(WebSocketServletFactory factory)
{
if (XmppWebSocket.isCompressionEnabled()) {
factory.getExtensionFactory().register("permessage-deflate", PerMessageDeflateExtension.class);
}
factory.setCreator(new WebSocketCreator() {
@Override
public Object createWebSocket(ServletUpgradeRequest req, ServletUpgradeResponse resp)
{
ClassLoader ccl = Thread.currentThread().getContextClassLoader();
try {
ClassLoader pcl = getPluginClassLoader();
Thread.currentThread().setContextClassLoader(pcl == null ? ccl : pcl);
for (String subprotocol : req.getSubProtocols())
{
if ("xmpp".equals(subprotocol))
{
resp.setAcceptedSubProtocol(subprotocol);
return new XmppWebSocket();
}
}
} catch (Exception e) {
Log.warn(MessageFormat.format("Unable to load websocket factory: {0} ({1})", e.getClass().getName(), e.getMessage()));
} finally {
Thread.currentThread().setContextClassLoader(ccl);
}
Log.warn("Failed to create websocket: " + req);
return null;
}
});
}
protected synchronized PluginClassLoader getPluginClassLoader() {
PluginManager pm = XMPPServer.getInstance().getPluginManager();
if (pluginClassLoader == null) {
pluginClassLoader = pm.getPluginClassloader(this);
}
// report error if plugin is unavailable
if (pluginClassLoader == null) {
Log.error("Unable to find class loader for websocket plugin");
}
return pluginClassLoader;
}
}
package org.jivesoftware.openfire.websocket;
import org.apache.commons.pool2.BasePooledObjectFactory;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.dom4j.io.XMPPPacketReader;
import org.jivesoftware.openfire.net.MXParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
public class XMPPPPacketReaderFactory extends BasePooledObjectFactory<XMPPPacketReader> {
private static Logger Log = LoggerFactory.getLogger( XMPPPPacketReaderFactory.class );
private static XmlPullParserFactory xppFactory = null;
static {
try {
xppFactory = XmlPullParserFactory.newInstance(MXParser.class.getName(), null);
xppFactory.setNamespaceAware(true);
}
catch (XmlPullParserException e) {
Log.error("Error creating a parser factory", e);
}
}
//-- BasePooledObjectFactory implementation
@Override
public XMPPPacketReader create() throws Exception {
XMPPPacketReader parser = new XMPPPacketReader();
parser.setXPPFactory( xppFactory );
return parser;
}
@Override
public PooledObject<XMPPPacketReader> wrap(XMPPPacketReader reader) {
return new DefaultPooledObject<XMPPPacketReader>(reader);
}
@Override
public boolean validateObject(PooledObject<XMPPPacketReader> po) {
// reset the input for the pooled parser
try {
po.getObject().getXPPParser().resetInput();
return true;
} catch (XmlPullParserException xppe) {
Log.error("Failed to reset pooled parser; evicting from pool", xppe);
return false;
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment