/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*
*/
package org.apache.vysper.xmpp.extension.xep0124;
import java.util.LinkedList;
import java.util.Queue;
import java.util.SortedMap;
import java.util.TreeMap;
import org.apache.vysper.xml.fragment.Renderer;
import org.apache.vysper.xml.fragment.XMLElement;
import org.apache.vysper.xmpp.protocol.SessionStateHolder;
import org.apache.vysper.xmpp.server.AbstractSessionContext;
import org.apache.vysper.xmpp.server.ServerRuntimeContext;
import org.apache.vysper.xmpp.server.SessionState;
import org.apache.vysper.xmpp.stanza.Stanza;
import org.apache.vysper.xmpp.writer.StanzaWriter;
import org.eclipse.jetty.continuation.Continuation;
import org.eclipse.jetty.continuation.ContinuationListener;
import org.eclipse.jetty.continuation.ContinuationSupport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Keeps the session state of a BOSH client
*
* @author The Apache MINA Project (dev@mina.apache.org)
*/
public class BoshBackedSessionContext extends AbstractSessionContext implements StanzaWriter {
private final static Logger LOGGER = LoggerFactory.getLogger(BoshBackedSessionContext.class);
private final BoshHandler boshHandler;
private final int maxpause = 120;
private final int inactivity = 60;
private final int polling = 15;
private final int maximumSentResponses = 10;
/*
* The number of milliseconds that will have to pass for a response to be reported missing to the client by
* responding with a report and time attributes. See Response Acknowledgements in XEP-0124.
*/
private final int brokenConnectionReportTimeout = 1000;
/*
* Keeps the suspended HTTP requests (does not respond to them) until the server has an asynchronous message
* to send to the client. (Comet HTTP Long Polling technique - described in XEP-0124)
*
* The BOSH requests are sorted by their RIDs.
*/
private final SortedMap<Long, BoshRequest> requestsWindow;
/*
* Keeps the asynchronous messages sent from server that cannot be delivered to the client because there are
* no available HTTP requests to respond to (requestsWindow is empty).
*/
private final Queue<Stanza> delayedResponseQueue;
/*
* A cache of sent responses to the BOSH client, kept in the event of delivery failure and retransmission requests.
* See Broken Connections in XEP-0124.
*/
private final SortedMap<Long, BoshResponse> sentResponses;
private int requests = 2;
private String boshVersion = "1.9";
private String contentType = BoshServlet.XML_CONTENT_TYPE;
private int wait = 60;
private int hold = 1;
private int currentInactivity = inactivity;
/*
* The highest RID that can be read and processed, this is the highest (rightmost) contiguous RID.
* The requests from the client can come theoretically with missing updates:
* rid_1, rid_2, rid_4 (missing rid_3, highestReadRid is rid_2)
*/
private Long highestReadRid = null;
private Long currentProcessingRequest = null;
private BoshRequest latestEmptyPollingRequest = null;
/*
* Indicate if the BOSH client will use acknowledgements throughout the session and that the absence of an 'ack'
* attribute in any request is meaningful.
*/
private boolean clientAcknowledgements;
/*
* The timestamp of the latest response wrote to the client is used to measure the inactivity period.
* When reaching the maximum inactivity the session will automatically close.
*/
private long latestWriteTimestamp = System.currentTimeMillis();
private final InactivityChecker inactivityChecker;
private Long lastInactivityExpireTime;
private boolean isWatchedByInactivityChecker;
/**
* Creates a new context for a session
* @param boshHandler
* @param serverRuntimeContext
* @param inactivityChecker
*/
public BoshBackedSessionContext(BoshHandler boshHandler, ServerRuntimeContext serverRuntimeContext, InactivityChecker inactivityChecker) {
super(serverRuntimeContext, new SessionStateHolder());
// in BOSH we jump directly to the encrypted state
sessionStateHolder.setState(SessionState.ENCRYPTED);
this.boshHandler = boshHandler;
requestsWindow = new TreeMap<Long, BoshRequest>();
delayedResponseQueue = new LinkedList<Stanza>();
sentResponses = new TreeMap<Long, BoshResponse>();
this.inactivityChecker = inactivityChecker;
updateInactivityChecker();
}
public boolean isWatchedByInactivityChecker() {
return isWatchedByInactivityChecker;
}
private void updateInactivityChecker() {
Long newInactivityExpireTime = null;
if (requestsWindow.isEmpty()) {
newInactivityExpireTime = latestWriteTimestamp + currentInactivity * 1000;
if (newInactivityExpireTime == lastInactivityExpireTime) {
return;
}
} else if (!isWatchedByInactivityChecker) {
return;
}
isWatchedByInactivityChecker = inactivityChecker.updateExpireTime(this, lastInactivityExpireTime, newInactivityExpireTime);
lastInactivityExpireTime = newInactivityExpireTime;
}
/**
* Returns the highest RID that is received in a continuous (uninterrupted) sequence of RIDs.
* Higher RIDs can exist with gaps separating them from the highestReadRid.
* @return the highest continuous RID received so far
*/
public long getHighestReadRid() {
return highestReadRid;
}
public SessionStateHolder getStateHolder() {
return sessionStateHolder;
}
public StanzaWriter getResponseWriter() {
return this;
}
public void setIsReopeningXMLStream() {
// BOSH does not use XML streams, the BOSH equivalent for reopening an XML stream is to restart the BOSH connection,
// and this is done in BoshHandler when the client requests it
}
/*
* This method is synchronized on the session object to prevent concurrent writes to the same BOSH client
*/
synchronized public void write(Stanza stanza) {
write0(boshHandler.wrapStanza(stanza));
}
/**
* Writes a BOSH response (that is wrapped in a <body/> element) if there are available HTTP requests
* to respond to, otherwise the response is queued to be sent later (when a HTTP request will be available).
* <p>
* (package access)
*
* @param response The BOSH response to write
*/
void write0(Stanza response) {
BoshRequest req;
if (requestsWindow.isEmpty() || requestsWindow.firstKey() > highestReadRid) {
delayedResponseQueue.offer(response);
return;
} else {
req = requestsWindow.remove(requestsWindow.firstKey());
}
BoshResponse boshResponse = getBoshResponse(response, req.getRid().equals(highestReadRid) ? null : highestReadRid);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("BOSH writing response: {}", new String(boshResponse.getContent()));
}
if (isResponseSavable(req, response)) {
sentResponses.put(req.getRid(), boshResponse);
// The number of responses to non-pause requests kept in the buffer SHOULD be either the same as the maximum
// number of simultaneous requests allowed or, if Acknowledgements are being used, the number of responses
// that have not yet been acknowledged (this part is handled in insertRequest(BoshRequest)), or
// the hard limit maximumSentResponses (not in the specification) that prevents excessive memory consumption.
if (sentResponses.size() > maximumSentResponses || (!isClientAcknowledgements() && sentResponses.size() > requests)) {
sentResponses.remove(sentResponses.firstKey());
}
}
Continuation continuation = ContinuationSupport.getContinuation(req.getHttpServletRequest());
continuation.setAttribute("response", boshResponse);
continuation.resume();
latestWriteTimestamp = System.currentTimeMillis();
updateInactivityChecker();
}
private boolean isResponseSavable(BoshRequest req, Stanza response) {
// responses to pause requests are not saved
if (req.getBody().getAttributeValue("pause") != null) {
return false;
}
// responses with binding error are not saved
for (XMLElement element : response.getInnerElements()) {
if ("iq".equals(element.getName()) && "error".equals(element.getAttributeValue("type"))) {
for (XMLElement subelement : element.getInnerElements()) {
if ("bind".equals(subelement.getName())) {
return false;
}
}
}
}
return true;
}
/**
* Writes an error to the client and closes the connection
* @param br
* @param condition the error condition
*/
private void error(BoshRequest br, String condition) {
requestsWindow.put(br.getRid(), br);
BoshRequest req = requestsWindow.remove(requestsWindow.firstKey());
Stanza body = boshHandler.getTerminateResponse();
body = boshHandler.addAttribute(body, "condition", condition);
BoshResponse boshResponse = getBoshResponse(body, null);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("BOSH writing response: {}", new String(boshResponse.getContent()));
}
Continuation continuation = ContinuationSupport.getContinuation(req.getHttpServletRequest());
continuation.setAttribute("response", boshResponse);
continuation.resume();
close();
}
/*
* Terminates the BOSH session
*/
synchronized public void close() {
// respond to all the queued HTTP requests with termination responses
while (!requestsWindow.isEmpty()) {
BoshRequest req = requestsWindow.remove(requestsWindow.firstKey());
Stanza body = boshHandler.getTerminateResponse();
BoshResponse boshResponse = getBoshResponse(body, null);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("BOSH writing response: {}", new String(boshResponse.getContent()));
}
Continuation continuation = ContinuationSupport.getContinuation(req.getHttpServletRequest());
continuation.setAttribute("response", boshResponse);
continuation.resume();
}
serverRuntimeContext.getResourceRegistry().unbindSession(this);
sessionStateHolder.setState(SessionState.CLOSED);
inactivityChecker.updateExpireTime(this, lastInactivityExpireTime, null);
lastInactivityExpireTime = null;
LOGGER.info("BOSH session {} closed", getSessionId());
}
public void switchToTLS(boolean delayed, boolean clientTls) {
// BOSH cannot switch dynamically (because STARTTLS cannot be used with HTTP),
// SSL can be enabled/disabled in BoshEndpoint#setSSLEnabled()
}
/**
* Setter for the Content-Type header of the HTTP responses sent to the BOSH client associated with this session
* @param contentType
*/
public void setContentType(String contentType) {
this.contentType = contentType;
}
/**
* Getter for the Content-Type header
* @return the configured Content-Type
*/
public String getContentType() {
return contentType;
}
/**
* Getter for the maximum length of a temporary session pause (in seconds) that a client can request
* @return
*/
public int getMaxPause() {
return maxpause;
}
/**
* Setter for the BOSH 'wait' parameter, the longest time (in seconds) that the connection manager is allowed to
* wait before responding to any request during the session. The BOSH client can only configure this parameter to
* a lower value than the default value from this session context.
* @param wait the BOSH 'wait' parameter
*/
public void setWait(int wait) {
this.wait = Math.min(wait, this.wait);
}
/**
* Getter for the BOSH 'wait' parameter
* @return The BOSH 'wait' parameter
*/
public int getWait() {
return wait;
}
/**
* Setter for the BOSH 'hold' parameter, the maximum number of HTTP requests the connection manager is allowed to
* keep waiting at any one time during the session. The value of this parameter can trigger the modification of
* the BOSH 'requests' parameter.
* @param hold
*/
public void setHold(int hold) {
this.hold = hold;
if (hold >= 2) {
requests = hold + 1;
}
}
/**
* Getter for the BOSH 'hold' parameter
* @return the BOSH 'hold' parameter
*/
public int getHold() {
return hold;
}
/**
* Setter for the client acknowledgements throughout the session
* @param value true is enabled, false otherwise
*/
public void setClientAcknowledgements(boolean value) {
clientAcknowledgements = value;
}
/**
* Getter for client acknowledgements
* @return true if enabled, false otherwise
*/
public boolean isClientAcknowledgements() {
return clientAcknowledgements;
}
/**
* Setter for the highest version of the BOSH protocol that the connection manager supports, or the version
* specified by the client in its request, whichever is lower.
* @param version the BOSH version
*/
public void setBoshVersion(String version) {
String[] v = boshVersion.split("\\.");
int major = Integer.parseInt(v[0]);
int minor = Integer.parseInt(v[1]);
v = version.split("\\.");
if (v.length == 2) {
int clientMajor = Integer.parseInt(v[0]);
int clientMinor = Integer.parseInt(v[1]);
if (clientMajor < major || (clientMajor == major && clientMinor < minor)) {
boshVersion = version;
}
}
}
/**
* Getter for the BOSH protocol version
* @return the BOSH version
*/
public String getBoshVersion() {
return boshVersion;
}
/**
* Getter for the BOSH 'inactivity' parameter, the longest allowable inactivity period (in seconds).
* @return the BOSH 'inactivity' parameter
*/
public int getInactivity() {
return inactivity;
}
/**
* Getter for the BOSH 'polling' parameter, the shortest allowable polling interval (in seconds).
* @return the BOSH 'polling' parameter
*/
public int getPolling() {
return polling;
}
/**
* Getter for the BOSH 'requests' parameter, the limit number of simultaneous requests the client makes.
* @return the BOSH 'requests' parameter
*/
public int getRequests() {
return requests;
}
/*
* A request expires when it stays enqueued in the requestsWindow longer than the allowed 'wait' time.
* The synchronization on the session object ensures that there will be no concurrent writes or other concurrent
* expirations for the BOSH client while the current request expires.
*/
synchronized private void requestExpired(Continuation continuation) {
BoshRequest req = (BoshRequest) continuation.getAttribute("request");
if (req == null) {
LOGGER.warn("Continuation expired without having an associated request!");
return;
}
while (!requestsWindow.isEmpty() && requestsWindow.firstKey() <= req.getRid()) {
write0(boshHandler.getEmptyResponse());
}
}
/**
* Suspends and enqueues an HTTP request to be used later when an asynchronous message needs to be sent from
* the connection manager to the BOSH client.
*
* @param req the HTTP request
*/
public void insertRequest(BoshRequest br) {
// reset the inactivity
currentInactivity = inactivity;
Continuation continuation = ContinuationSupport.getContinuation(br.getHttpServletRequest());
addContinuationExpirationListener(continuation);
continuation.setTimeout(wait * 1000);
continuation.setAttribute("request", br);
continuation.suspend();
if (highestReadRid != null && highestReadRid + requests < br.getRid()) {
LOGGER.warn("BOSH received RID greater than the permitted window of concurrent requests");
error(br, "item-not-found");
return;
}
if (highestReadRid != null && br.getRid() <= highestReadRid) {
if (sentResponses.containsKey(br.getRid())) {
// Resending the old response
resendResponse(br);
} else {
LOGGER.warn("BOSH response not in buffer error");
error(br, "item-not-found");
}
return;
}
if (requestsWindow.size() + 1 > requests && !"terminate".equals(br.getBody().getAttributeValue("type"))
&& br.getBody().getAttributeValue("pause") == null) {
LOGGER.warn("BOSH Overactivity: Too many simultaneous requests");
error(br, "policy-violation");
return;
}
if (requestsWindow.size() + 1 == requests && !"terminate".equals(br.getBody().getAttributeValue("type"))
&& br.getBody().getAttributeValue("pause") == null && br.getBody().getInnerElements().isEmpty()) {
if (!requestsWindow.isEmpty()
&& br.getTimestamp() - requestsWindow.get(requestsWindow.lastKey()).getTimestamp() < polling * 1000) {
LOGGER.warn("BOSH Overactivity: Too frequent requests");
error(br, "policy-violation");
return;
}
}
if ((wait == 0 || hold == 0) && br.getBody().getInnerElements().isEmpty()) {
if (latestEmptyPollingRequest != null && br.getTimestamp() - latestEmptyPollingRequest.getTimestamp() < polling * 1000) {
LOGGER.warn("BOSH Overactivity for polling: Too frequent requests");
error(br, "policy-violation");
return;
}
latestEmptyPollingRequest = br;
}
requestsWindow.put(br.getRid(), br);
updateInactivityChecker();
if (highestReadRid == null) {
highestReadRid = br.getRid();
}
for (;;) {
// update the highestReadRid to the latest value
// it is possible to have higher RIDs than the highestReadRid with a gap between them (e.g. lost client request)
if (requestsWindow.containsKey(highestReadRid + 1)) {
highestReadRid++;
} else {
break;
}
}
if (isClientAcknowledgements()) {
if (br.getBody().getAttribute("ack") == null) {
// if there is no ack attribute present then the client confirmed it received all the responses to all the previous requests
// and we clear the cache
sentResponses.clear();
} else if (!sentResponses.isEmpty()) {
// After receiving a request with an 'ack' value less than the 'rid' of the last request that it has already responded to,
// the connection manager MAY inform the client of the situation. In this case it SHOULD include a 'report' attribute set
// to one greater than the 'ack' attribute it received from the client, and a 'time' attribute set to the number of milliseconds
// since it sent the response associated with the 'report' attribute.
long ack = Long.parseLong(br.getBody().getAttributeValue("ack"));
if (ack < sentResponses.lastKey() && sentResponses.containsKey(ack + 1)) {
long delta = System.currentTimeMillis() - sentResponses.get(ack + 1).getTimestamp();
if (delta >= brokenConnectionReportTimeout) {
sendBrokenConnectionReport(ack + 1, delta);
return;
}
}
}
}
// we cannot pause if there are missing requests, this is tested with
// br.getRid().equals(requestsWindow.lastKey()) && highestReadRid.equals(br.getRid())
if (br.getBody().getAttribute("pause") != null && br.getRid().equals(requestsWindow.lastKey()) && highestReadRid.equals(br.getRid())) {
int pause = Integer.parseInt(br.getBody().getAttributeValue("pause"));
if (pause > maxpause) {
// do not allow to pause more than maxpause
pause = maxpause;
}
if (pause < 0) {
pause = 0;
}
respondToPause(pause);
return;
}
// If there are delayed responses waiting to be sent to the BOSH client, then we wrap them all in
// a <body/> element and send them as a HTTP response to the current HTTP request.
Stanza delayedResponse;
Stanza mergedResponse = null;
while ((delayedResponse = delayedResponseQueue.poll()) != null) {
mergedResponse = boshHandler.mergeResponses(mergedResponse, delayedResponse);
}
if (mergedResponse != null) {
write0(mergedResponse);
return;
}
// If there are more suspended enqueued requests than it is allowed by the BOSH 'hold' parameter,
// than we release the oldest one by sending an empty response.
if (requestsWindow.size() > hold) {
write0(boshHandler.getEmptyResponse());
}
}
private void respondToPause(int pause) {
LOGGER.debug("Setting inactivity period to {}", pause);
currentInactivity = pause;
for (;;) {
BoshRequest boshRequest = getNextRequest();
if (boshRequest == null) {
break;
}
write0(boshHandler.getEmptyResponse());
}
}
private void sendBrokenConnectionReport(long report, long delta) {
Stanza body = boshHandler.getTerminateResponse();
body = boshHandler.addAttribute(body, "report", Long.toString(report));
body = boshHandler.addAttribute(body, "time", Long.toString(delta));
write0(body);
}
private void addContinuationExpirationListener(Continuation continuation) {
// listen the continuation to be notified when the request expires
continuation.addContinuationListener(new ContinuationListener() {
public void onTimeout(Continuation continuation) {
requestExpired(continuation);
}
public void onComplete(Continuation continuation) {
// ignore
}
});
}
private void resendResponse(BoshRequest br) {
BoshResponse boshResponse = sentResponses.get(br.getRid());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("BOSH writing response: {}", new String(boshResponse.getContent()));
}
Continuation continuation = ContinuationSupport.getContinuation(br.getHttpServletRequest());
continuation.setAttribute("response", boshResponse);
continuation.resume();
latestWriteTimestamp = System.currentTimeMillis();
updateInactivityChecker();
}
private BoshResponse getBoshResponse(Stanza stanza, Long ack) {
if (ack != null) {
stanza = boshHandler.addAttribute(stanza, "ack", ack.toString());
}
byte[] content = new Renderer(stanza).getComplete().getBytes();
return new BoshResponse(contentType, content);
}
/**
* Returns the next BOSH body to process.
* It is possible to have more than one BOSH body to process in the case where a lost request is resent by the client.
* @return the next (by RID order) body to process
*/
public BoshRequest getNextRequest() {
if (requestsWindow.isEmpty()) {
return null;
}
if (currentProcessingRequest == null || currentProcessingRequest < requestsWindow.firstKey()) {
currentProcessingRequest = requestsWindow.firstKey();
}
if (currentProcessingRequest > highestReadRid) {
return null;
} else {
currentProcessingRequest++;
return requestsWindow.get(currentProcessingRequest - 1);
}
}
}