org.jivesoftware.openfire.streammanagement.StreamManager Maven / Gradle / Ivy
The newest version!
package org.jivesoftware.openfire.streammanagement;
import java.math.BigInteger;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import org.dom4j.Element;
import org.dom4j.QName;
import org.dom4j.dom.DOMElement;
import org.jivesoftware.openfire.Connection;
import org.jivesoftware.openfire.PacketRouter;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.auth.AuthToken;
import org.jivesoftware.openfire.auth.UnauthorizedException;
import org.jivesoftware.openfire.session.ClientSession;
import org.jivesoftware.openfire.session.LocalClientSession;
import org.jivesoftware.openfire.session.LocalSession;
import org.jivesoftware.openfire.session.Session;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.StringUtils;
import org.jivesoftware.util.XMPPDateTimeFormat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.packet.*;
/**
* XEP-0198 Stream Manager.
* Handles client/server messages acknowledgement.
*
* @author jonnyheavey
*/
public class StreamManager {
private final Logger Log;
private boolean resume = false;
public static class UnackedPacket {
public final long x;
public final Date timestamp = new Date();
public final Packet packet;
public UnackedPacket(long x, Packet p) {
this.x = x;
packet = p;
}
}
public static final String SM_ACTIVE = "stream.management.active";
/**
* Stanza namespaces
*/
public static final String NAMESPACE_V2 = "urn:xmpp:sm:2";
public static final String NAMESPACE_V3 = "urn:xmpp:sm:3";
/**
* Session (stream) to client.
*/
private final LocalSession session;
/**
* Namespace to be used in stanzas sent to client (depending on XEP-0198 version used by client)
*/
private String namespace;
/**
* Count of how many stanzas/packets
* sent from the client that the server has processed
*/
private long serverProcessedStanzas = 0;
/**
* Count of how many stanzas/packets
* sent from the server that the client has processed
*/
private long clientProcessedStanzas = 0;
static private long mask = new BigInteger("2").pow(32).longValue() - 1; // This is used to emulate rollover.
/**
* Collection of stanzas/packets sent to client that haven't been acknowledged.
*/
private Deque unacknowledgedServerStanzas = new LinkedList<>();
public StreamManager(LocalSession session) {
String address;
try {
address = session.getConnection().getHostAddress();
}
catch ( UnknownHostException e )
{
address = null;
}
this.Log = LoggerFactory.getLogger(StreamManager.class + "["+ (address == null ? "(unknown address)" : address) +"]" );
this.session = session;
}
/**
* Returns true if a stream is resumable.
*
* @return True if a stream is resumable.
*/
public boolean getResume() {
return resume;
}
/**
* Processes a stream management element.
*
* @param element The stream management element to be processed.
*/
public void process( Element element )
{
switch(element.getName()) {
case "enable":
String resumeString = element.attributeValue("resume");
boolean resume = false;
if (resumeString != null) {
if (resumeString.equalsIgnoreCase("true") || resumeString.equalsIgnoreCase("yes") || resumeString.equals("1")) {
resume = true;
}
}
enable( element.getNamespace().getStringValue(), resume );
break;
case "resume":
long h = new Long(element.attributeValue("h"));
String previd = element.attributeValue("previd");
startResume( element.getNamespaceURI(), previd, h);
break;
case "r":
sendServerAcknowledgement();
break;
case "a":
processClientAcknowledgement( element);
break;
default:
sendUnexpectedError();
}
}
/**
* Should this session be allowed to resume?
* This is used while processed and
*
* @return True if the session is allowed to resume.
*/
private boolean allowResume() {
boolean allow = false;
// Ensure that resource binding has occurred.
if (session instanceof ClientSession) {
AuthToken authToken = ((LocalClientSession)session).getAuthToken();
if (authToken != null) {
if (!authToken.isAnonymous()) {
allow = true;
}
}
}
return allow;
}
/**
* Attempts to enable Stream Management for the entity identified by the provided JID.
*
* @param namespace The namespace that defines what version of SM is to be enabled.
* @param resume Whether the client is requesting a resumable session.
*/
private void enable( String namespace, boolean resume )
{
boolean offerResume = allowResume();
// Ensure that resource binding has occurred.
if (session.getStatus() != Session.STATUS_AUTHENTICATED) {
this.namespace = namespace;
sendUnexpectedError();
return;
}
String smId = null;
synchronized ( this )
{
// Do nothing if already enabled
if ( isEnabled() )
{
sendUnexpectedError();
return;
}
this.namespace = namespace;
this.resume = resume && offerResume;
if ( this.resume ) {
// Create SM-ID.
smId = StringUtils.encodeBase64( session.getAddress().getResource() + "\0" + session.getStreamID().getID());
}
}
// Send confirmation to the requestee.
Element enabled = new DOMElement(QName.get("enabled", namespace));
if (this.resume) {
enabled.addAttribute("resume", "true");
enabled.addAttribute( "id", smId);
}
session.deliverRawText(enabled.asXML());
}
private void startResume(String namespace, String previd, long h) {
Log.debug("Attempting resumption for {}, h={}", previd, h);
this.namespace = namespace;
// Ensure that resource binding has NOT occurred.
if (!allowResume() ) {
sendUnexpectedError();
return;
}
if (session.getStatus() == Session.STATUS_AUTHENTICATED) {
sendUnexpectedError();
return;
}
AuthToken authToken = null;
// Ensure that resource binding has occurred.
if (session instanceof ClientSession) {
authToken = ((LocalClientSession) session).getAuthToken();
}
if (authToken == null) {
sendUnexpectedError();
return;
}
// Decode previd.
String resource;
String streamId;
try {
StringTokenizer toks = new StringTokenizer(new String(StringUtils.decodeBase64(previd), StandardCharsets.UTF_8), "\0");
resource = toks.nextToken();
streamId = toks.nextToken();
} catch (Exception e) {
Log.debug("Exception from previd decode:", e);
sendUnexpectedError();
return;
}
JID fullJid = new JID(authToken.getUsername(), authToken.getDomain(), resource, true);
Log.debug("Resuming session {}", fullJid);
// Locate existing session.
LocalClientSession otherSession = (LocalClientSession)XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid);
if (otherSession == null) {
sendError(new PacketError(PacketError.Condition.item_not_found));
return;
}
if (!otherSession.getStreamID().getID().equals(streamId)) {
sendError(new PacketError(PacketError.Condition.item_not_found));
return;
}
Log.debug("Found existing session, checking status");
// Previd identifies proper session. Now check SM status
if (!otherSession.getStreamManager().namespace.equals(namespace)) {
sendError(new PacketError(PacketError.Condition.unexpected_request));
return;
}
if (!otherSession.getStreamManager().resume) {
sendError(new PacketError(PacketError.Condition.unexpected_request));
return;
}
if (!otherSession.isDetached()) {
Log.debug("Existing session is not detached; detaching.");
Connection oldConnection = otherSession.getConnection();
otherSession.setDetached();
oldConnection.close();
}
Log.debug("Attaching to other session.");
// If we're all happy, disconnect this session.
Connection conn = session.getConnection();
session.setDetached();
// Connect new session.
otherSession.reattach(conn, h);
// Perform resumption on new session.
session.close();
}
/**
* Called when a session receives a closing stream tag, this prevents the
* session from being detached.
*/
public void formalClose() {
this.resume = false;
}
/**
* Sends XEP-0198 acknowledgement <a /> to client from server
*/
public void sendServerAcknowledgement() {
if(isEnabled()) {
if (session.isDetached()) {
Log.debug("Session is detached, won't request an ack.");
return;
}
String ack = String.format("", namespace, serverProcessedStanzas & mask);
session.deliverRawText( ack );
}
}
/**
* Sends XEP-0198 request to client from server
*/
private void sendServerRequest() {
if(isEnabled()) {
if (session.isDetached()) {
Log.debug("Session is detached, won't request an ack.");
return;
}
String request = String.format(" ", namespace);
session.deliverRawText( request );
}
}
/**
* Send an error if a XEP-0198 stanza is received at an unexpected time.
* e.g. before resource-binding has completed.
*/
private void sendUnexpectedError() {
sendError(new PacketError( PacketError.Condition.unexpected_request ));
}
/**
* Send a generic failed error.
*
* @param error PacketError describing the failure.
*/
private void sendError(PacketError error) {
session.deliverRawText(
String.format("", namespace)
+ String.format("<%s xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>", error.getCondition().toXMPP())
+ " "
);
this.namespace = null; // isEnabled() is testing this.
}
/**
* Process client acknowledgements for a given value of h.
*
* @param h Last handled stanza to be acknowledged.
*/
private void processClientAcknowledgement(long h) {
synchronized (this) {
if ( !unacknowledgedServerStanzas.isEmpty() && h > unacknowledgedServerStanzas.getLast().x ) {
Log.warn( "Client acknowledges stanzas that we didn't send! Client Ack h: {}, our last stanza: {}", h, unacknowledgedServerStanzas.getLast().x );
}
clientProcessedStanzas = h;
// Remove stanzas from temporary storage as now acknowledged
Log.trace( "Before processing client Ack (h={}): {} unacknowledged stanzas.", h, unacknowledgedServerStanzas.size() );
// Pop all acknowledged stanzas.
while( !unacknowledgedServerStanzas.isEmpty() && unacknowledgedServerStanzas.getFirst().x <= h )
{
unacknowledgedServerStanzas.removeFirst();
}
// Ensure that unacknowledged stanzas are purged after the client rolled over 'h' which occurs at h= (2^32)-1
final int maxUnacked = getMaximumUnacknowledgedStanzas();
final boolean clientHadRollOver = h < maxUnacked && !unacknowledgedServerStanzas.isEmpty() && unacknowledgedServerStanzas.getLast().x > mask - maxUnacked;
if ( clientHadRollOver )
{
Log.info( "Client rolled over 'h'. Purging high-numbered unacknowledged stanzas." );
while ( !unacknowledgedServerStanzas.isEmpty() && unacknowledgedServerStanzas.getLast().x > mask - maxUnacked)
{
unacknowledgedServerStanzas.removeLast();
}
}
Log.trace( "After processing client Ack (h={}): {} unacknowledged stanzas.", h, unacknowledgedServerStanzas.size());
}
}
/**
* Receive and process acknowledgement packet from client
* @param ack XEP-0198 acknowledgement stanza to process
*/
private void processClientAcknowledgement(Element ack) {
if(isEnabled()) {
if (ack.attribute("h") != null) {
final long h = Long.valueOf(ack.attributeValue("h"));
Log.debug( "Received acknowledgement from client: h={}", h );
processClientAcknowledgement(h);
}
}
}
/**
* Registers that Openfire sends a stanza to the client (which is expected to be acknowledged later).
* @param packet The stanza that is sent.
*/
public void sentStanza(Packet packet) {
if(isEnabled()) {
final long requestFrequency = JiveGlobals.getLongProperty( "stream.management.requestFrequency", 5 );
final int size;
synchronized (this)
{
// The next ID is one higher than the last stanza that was sent (which might be unacknowledged!)
final long x = 1 + ( unacknowledgedServerStanzas.isEmpty() ? clientProcessedStanzas : unacknowledgedServerStanzas.getLast().x );
unacknowledgedServerStanzas.addLast( new StreamManager.UnackedPacket( x, packet.createCopy() ) );
size = unacknowledgedServerStanzas.size();
Log.trace( "Added stanza of type '{}' to collection of unacknowledged stanzas (x={}). Collection size is now {}.", packet.getElement().getName(), x, size );
// Prevent keeping to many stanzas in memory.
if ( size > getMaximumUnacknowledgedStanzas() )
{
Log.warn( "To many stanzas go unacknowledged for this connection. Clearing queue and disabling functionality." );
namespace = null;
unacknowledgedServerStanzas.clear();
return;
}
}
// When we have a sizable amount of unacknowledged stanzas, request acknowledgement.
if ( size % requestFrequency == 0 ) {
Log.debug( "Requesting acknowledgement from peer, as we have {} or more unacknowledged stanzas.", requestFrequency );
sendServerRequest();
}
}
}
public void onClose(PacketRouter router, JID serverAddress) {
// Re-deliver unacknowledged stanzas from broken stream (XEP-0198)
synchronized (this) {
if(isEnabled()) {
namespace = null; // disable stream management.
for (StreamManager.UnackedPacket unacked : unacknowledgedServerStanzas) {
if (unacked.packet instanceof Message) {
Message m = (Message) unacked.packet;
if (m.getExtension("delay", "urn:xmpp:delay") == null) {
Element delayInformation = m.addChildElement("delay", "urn:xmpp:delay");
delayInformation.addAttribute("stamp", XMPPDateTimeFormat.format(unacked.timestamp));
delayInformation.addAttribute("from", serverAddress.toBareJID());
}
router.route(unacked.packet);
}
}
}
}
}
public void onResume(JID serverAddress, long h) {
Log.debug("Agreeing to resume");
Element resumed = new DOMElement(QName.get("resumed", namespace));
resumed.addAttribute("previd", StringUtils.encodeBase64( session.getAddress().getResource() + "\0" + session.getStreamID().getID()));
resumed.addAttribute("h", Long.toString(serverProcessedStanzas));
session.getConnection().deliverRawText(resumed.asXML());
Log.debug("Resuming session: Ack for {}", h);
processClientAcknowledgement(h);
Log.debug("Processing remaining unacked stanzas");
// Re-deliver unacknowledged stanzas from broken stream (XEP-0198)
synchronized (this) {
if(isEnabled()) {
for (StreamManager.UnackedPacket unacked : unacknowledgedServerStanzas) {
try {
if (unacked.packet instanceof Message) {
Message m = (Message) unacked.packet;
if (m.getExtension("delay", "urn:xmpp:delay") == null) {
Element delayInformation = m.addChildElement("delay", "urn:xmpp:delay");
delayInformation.addAttribute("stamp", XMPPDateTimeFormat.format(unacked.timestamp));
delayInformation.addAttribute("from", serverAddress.toBareJID());
}
session.getConnection().deliver(m);
} else if (unacked.packet instanceof Presence) {
Presence p = (Presence) unacked.packet;
if (p.getExtension("delay", "urn:xmpp:delay") == null) {
Element delayInformation = p.addChildElement("delay", "urn:xmpp:delay");
delayInformation.addAttribute("stamp", XMPPDateTimeFormat.format(unacked.timestamp));
delayInformation.addAttribute("from", serverAddress.toBareJID());
}
session.getConnection().deliver(p);
} else {
session.getConnection().deliver(unacked.packet);
}
} catch (UnauthorizedException e) {
Log.warn("Caught unauthorized exception, which seems worrying: ", e);
}
}
sendServerRequest();
}
}
}
/**
* Determines whether Stream Management enabled for session this
* manager belongs to.
* @return true when stream management is enabled, otherwise false.
*/
public boolean isEnabled() {
return namespace != null;
}
/**
* Increments the count of stanzas processed by the server since
* Stream Management was enabled.
*/
public void incrementServerProcessedStanzas() {
if(isEnabled()) {
this.serverProcessedStanzas++;
}
}
/**
* The maximum amount of stanzas we keep, waiting for ack.
* @return The maximum number of stanzas.
*/
private int getMaximumUnacknowledgedStanzas()
{
return JiveGlobals.getIntProperty( "stream.management.max-unacked", 10000 );
}
}