001/**
002 *
003 * Copyright 2009 Jive Software.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.jivesoftware.smack.bosh;
019
020import java.io.IOException;
021import java.io.PipedReader;
022import java.io.PipedWriter;
023import java.io.StringReader;
024import java.io.Writer;
025import java.util.Map;
026import java.util.logging.Level;
027import java.util.logging.Logger;
028
029import org.jivesoftware.smack.AbstractXMPPConnection;
030import org.jivesoftware.smack.SmackException;
031import org.jivesoftware.smack.SmackException.ConnectionException;
032import org.jivesoftware.smack.SmackException.NotConnectedException;
033import org.jivesoftware.smack.XMPPConnection;
034import org.jivesoftware.smack.XMPPException;
035import org.jivesoftware.smack.XMPPException.StreamErrorException;
036import org.jivesoftware.smack.packet.Element;
037import org.jivesoftware.smack.packet.IQ;
038import org.jivesoftware.smack.packet.Message;
039import org.jivesoftware.smack.packet.Nonza;
040import org.jivesoftware.smack.packet.Presence;
041import org.jivesoftware.smack.packet.Stanza;
042import org.jivesoftware.smack.packet.StanzaError;
043import org.jivesoftware.smack.sasl.packet.SaslStreamElements.SASLFailure;
044import org.jivesoftware.smack.sasl.packet.SaslStreamElements.Success;
045import org.jivesoftware.smack.util.PacketParserUtils;
046
047import org.igniterealtime.jbosh.AbstractBody;
048import org.igniterealtime.jbosh.BOSHClient;
049import org.igniterealtime.jbosh.BOSHClientConfig;
050import org.igniterealtime.jbosh.BOSHClientConnEvent;
051import org.igniterealtime.jbosh.BOSHClientConnListener;
052import org.igniterealtime.jbosh.BOSHClientRequestListener;
053import org.igniterealtime.jbosh.BOSHClientResponseListener;
054import org.igniterealtime.jbosh.BOSHException;
055import org.igniterealtime.jbosh.BOSHMessageEvent;
056import org.igniterealtime.jbosh.BodyQName;
057import org.igniterealtime.jbosh.ComposableBody;
058
059import org.jxmpp.jid.DomainBareJid;
060import org.jxmpp.jid.parts.Resourcepart;
061import org.xmlpull.v1.XmlPullParser;
062import org.xmlpull.v1.XmlPullParserFactory;
063
064/**
065 * Creates a connection to an XMPP server via HTTP binding.
066 * This is specified in the XEP-0206: XMPP Over BOSH.
067 *
068 * @see XMPPConnection
069 * @author Guenther Niess
070 */
071public class XMPPBOSHConnection extends AbstractXMPPConnection {
072    private static final Logger LOGGER = Logger.getLogger(XMPPBOSHConnection.class.getName());
073
074    /**
075     * The XMPP Over Bosh namespace.
076     */
077    public static final String XMPP_BOSH_NS = "urn:xmpp:xbosh";
078
079    /**
080     * The BOSH namespace from XEP-0124.
081     */
082    public static final String BOSH_URI = "http://jabber.org/protocol/httpbind";
083
084    /**
085     * The used BOSH client from the jbosh library.
086     */
087    private BOSHClient client;
088
089    /**
090     * Holds the initial configuration used while creating the connection.
091     */
092    @SuppressWarnings("HidingField")
093    private final BOSHConfiguration config;
094
095    // Some flags which provides some info about the current state.
096    private boolean isFirstInitialization = true;
097    private boolean done = false;
098
099    // The readerPipe and consumer thread are used for the debugger.
100    private PipedWriter readerPipe;
101    private Thread readerConsumer;
102
103    /**
104     * The session ID for the BOSH session with the connection manager.
105     */
106    protected String sessionID = null;
107
108    private boolean notified;
109
110    /**
111     * Create a HTTP Binding connection to an XMPP server.
112     *
113     * @param username the username to use.
114     * @param password the password to use.
115     * @param https true if you want to use SSL
116     *             (e.g. false for http://domain.lt:7070/http-bind).
117     * @param host the hostname or IP address of the connection manager
118     *             (e.g. domain.lt for http://domain.lt:7070/http-bind).
119     * @param port the port of the connection manager
120     *             (e.g. 7070 for http://domain.lt:7070/http-bind).
121     * @param filePath the file which is described by the URL
122     *             (e.g. /http-bind for http://domain.lt:7070/http-bind).
123     * @param xmppServiceDomain the XMPP service name
124     *             (e.g. domain.lt for the user alice@domain.lt)
125     */
126    public XMPPBOSHConnection(String username, String password, boolean https, String host, int port, String filePath, DomainBareJid xmppServiceDomain) {
127        this(BOSHConfiguration.builder().setUseHttps(https).setHost(host)
128                .setPort(port).setFile(filePath).setXmppDomain(xmppServiceDomain)
129                .setUsernameAndPassword(username, password).build());
130    }
131
132    /**
133     * Create a HTTP Binding connection to an XMPP server.
134     *
135     * @param config The configuration which is used for this connection.
136     */
137    public XMPPBOSHConnection(BOSHConfiguration config) {
138        super(config);
139        this.config = config;
140    }
141
142    @Override
143    protected void connectInternal() throws SmackException, InterruptedException {
144        done = false;
145        notified = false;
146        try {
147            // Ensure a clean starting state
148            if (client != null) {
149                client.close();
150                client = null;
151            }
152            sessionID = null;
153
154            // Initialize BOSH client
155            BOSHClientConfig.Builder cfgBuilder = BOSHClientConfig.Builder
156                    .create(config.getURI(), config.getXMPPServiceDomain().toString());
157            if (config.isProxyEnabled()) {
158                cfgBuilder.setProxy(config.getProxyAddress(), config.getProxyPort());
159            }
160            for (Map.Entry<String, String> h : config.getHttpHeaders().entrySet()) {
161                cfgBuilder.addHttpHeader(h.getKey(), h.getValue());
162            }
163            client = BOSHClient.create(cfgBuilder.build());
164
165            client.addBOSHClientConnListener(new BOSHConnectionListener());
166            client.addBOSHClientResponseListener(new BOSHPacketReader());
167
168            // Initialize the debugger
169            if (debugger != null) {
170                initDebugger();
171            }
172
173            // Send the session creation request
174            client.send(ComposableBody.builder()
175                    .setNamespaceDefinition("xmpp", XMPP_BOSH_NS)
176                    .setAttribute(BodyQName.createWithPrefix(XMPP_BOSH_NS, "version", "xmpp"), "1.0")
177                    .build());
178        } catch (Exception e) {
179            throw new ConnectionException(e);
180        }
181
182        // Wait for the response from the server
183        synchronized (this) {
184            if (!connected) {
185                final long deadline = System.currentTimeMillis() + getReplyTimeout();
186                while (!notified) {
187                    final long now = System.currentTimeMillis();
188                    if (now >= deadline) break;
189                    wait(deadline - now);
190                }
191            }
192        }
193
194        // If there is no feedback, throw an remote server timeout error
195        if (!connected && !done) {
196            done = true;
197            String errorMessage = "Timeout reached for the connection to "
198                    + getHost() + ":" + getPort() + ".";
199            throw new SmackException(errorMessage);
200        }
201    }
202
203    @Override
204    public boolean isSecureConnection() {
205        // TODO: Implement SSL usage
206        return false;
207    }
208
209    @Override
210    public boolean isUsingCompression() {
211        // TODO: Implement compression
212        return false;
213    }
214
215    @Override
216    protected void loginInternal(String username, String password, Resourcepart resource) throws XMPPException,
217                    SmackException, IOException, InterruptedException {
218        // Authenticate using SASL
219        saslAuthentication.authenticate(username, password, config.getAuthzid(), null);
220
221        bindResourceAndEstablishSession(resource);
222
223        afterSuccessfulLogin(false);
224    }
225
226    @Override
227    public void sendNonza(Nonza element) throws NotConnectedException {
228        if (done) {
229            throw new NotConnectedException();
230        }
231        sendElement(element);
232    }
233
234    @Override
235    protected void sendStanzaInternal(Stanza packet) throws NotConnectedException {
236        sendElement(packet);
237    }
238
239    private void sendElement(Element element) {
240        try {
241            send(ComposableBody.builder().setPayloadXML(element.toXML(BOSH_URI).toString()).build());
242            if (element instanceof Stanza) {
243                firePacketSendingListeners((Stanza) element);
244            }
245        }
246        catch (BOSHException e) {
247            LOGGER.log(Level.SEVERE, "BOSHException in sendStanzaInternal", e);
248        }
249    }
250
251    /**
252     * Closes the connection by setting presence to unavailable and closing the
253     * HTTP client. The shutdown logic will be used during a planned disconnection or when
254     * dealing with an unexpected disconnection. Unlike {@link #disconnect()} the connection's
255     * BOSH stanza reader will not be removed; thus connection's state is kept.
256     *
257     */
258    @Override
259    protected void shutdown() {
260
261        if (client != null) {
262            try {
263                client.disconnect();
264            } catch (Exception e) {
265                LOGGER.log(Level.WARNING, "shutdown", e);
266            }
267            client = null;
268        }
269
270        instantShutdown();
271    }
272
273    @Override
274    public void instantShutdown() {
275        setWasAuthenticated();
276        sessionID = null;
277        done = true;
278        authenticated = false;
279        connected = false;
280        isFirstInitialization = false;
281
282        // Close down the readers and writers.
283        if (readerPipe != null) {
284            try {
285                readerPipe.close();
286            }
287            catch (Throwable ignore) { /* ignore */ }
288            reader = null;
289        }
290        if (reader != null) {
291            try {
292                reader.close();
293            }
294            catch (Throwable ignore) { /* ignore */ }
295            reader = null;
296        }
297        if (writer != null) {
298            try {
299                writer.close();
300            }
301            catch (Throwable ignore) { /* ignore */ }
302            writer = null;
303        }
304
305        readerConsumer = null;
306    }
307
308    /**
309     * Send a HTTP request to the connection manager with the provided body element.
310     *
311     * @param body the body which will be sent.
312     * @throws BOSHException
313     */
314    protected void send(ComposableBody body) throws BOSHException {
315        if (!connected) {
316            throw new IllegalStateException("Not connected to a server!");
317        }
318        if (body == null) {
319            throw new NullPointerException("Body mustn't be null!");
320        }
321        if (sessionID != null) {
322            body = body.rebuild().setAttribute(
323                    BodyQName.create(BOSH_URI, "sid"), sessionID).build();
324        }
325        client.send(body);
326    }
327
328    /**
329     * Initialize the SmackDebugger which allows to log and debug XML traffic.
330     */
331    @Override
332    protected void initDebugger() {
333        // TODO: Maybe we want to extend the SmackDebugger for simplification
334        //       and a performance boost.
335
336        // Initialize a empty writer which discards all data.
337        writer = new Writer() {
338            @Override
339            public void write(char[] cbuf, int off, int len) {
340                /* ignore */ }
341
342            @Override
343            public void close() {
344                /* ignore */ }
345
346            @Override
347            public void flush() {
348                /* ignore */ }
349        };
350
351        // Initialize a pipe for received raw data.
352        try {
353            readerPipe = new PipedWriter();
354            reader = new PipedReader(readerPipe);
355        }
356        catch (IOException e) {
357            // Ignore
358        }
359
360        // Call the method from the parent class which initializes the debugger.
361        super.initDebugger();
362
363        // Add listeners for the received and sent raw data.
364        client.addBOSHClientResponseListener(new BOSHClientResponseListener() {
365            @Override
366            public void responseReceived(BOSHMessageEvent event) {
367                if (event.getBody() != null) {
368                    try {
369                        readerPipe.write(event.getBody().toXML());
370                        readerPipe.flush();
371                    } catch (Exception e) {
372                        // Ignore
373                    }
374                }
375            }
376        });
377        client.addBOSHClientRequestListener(new BOSHClientRequestListener() {
378            @Override
379            public void requestSent(BOSHMessageEvent event) {
380                if (event.getBody() != null) {
381                    try {
382                        writer.write(event.getBody().toXML());
383                    } catch (Exception e) {
384                        // Ignore
385                    }
386                }
387            }
388        });
389
390        // Create and start a thread which discards all read data.
391        readerConsumer = new Thread() {
392            private Thread thread = this;
393            private int bufferLength = 1024;
394
395            @Override
396            public void run() {
397                try {
398                    char[] cbuf = new char[bufferLength];
399                    while (readerConsumer == thread && !done) {
400                        reader.read(cbuf, 0, bufferLength);
401                    }
402                } catch (IOException e) {
403                    // Ignore
404                }
405            }
406        };
407        readerConsumer.setDaemon(true);
408        readerConsumer.start();
409    }
410
411    /**
412     * Sends out a notification that there was an error with the connection
413     * and closes the connection.
414     *
415     * @param e the exception that causes the connection close event.
416     */
417    protected void notifyConnectionError(Exception e) {
418        // Closes the connection temporary. A reconnection is possible
419        shutdown();
420        callConnectionClosedOnErrorListener(e);
421    }
422
423    /**
424     * A listener class which listen for a successfully established connection
425     * and connection errors and notifies the BOSHConnection.
426     *
427     * @author Guenther Niess
428     */
429    private class BOSHConnectionListener implements BOSHClientConnListener {
430
431        /**
432         * Notify the BOSHConnection about connection state changes.
433         * Process the connection listeners and try to login if the
434         * connection was formerly authenticated and is now reconnected.
435         */
436        @Override
437        public void connectionEvent(BOSHClientConnEvent connEvent) {
438            try {
439                if (connEvent.isConnected()) {
440                    connected = true;
441                    if (isFirstInitialization) {
442                        isFirstInitialization = false;
443                    }
444                    else {
445                            if (wasAuthenticated) {
446                                try {
447                                    login();
448                                }
449                                catch (Exception e) {
450                                    throw new RuntimeException(e);
451                                }
452                            }
453                    }
454                }
455                else {
456                    if (connEvent.isError()) {
457                        // TODO Check why jbosh's getCause returns Throwable here. This is very
458                        // unusual and should be avoided if possible
459                        Throwable cause = connEvent.getCause();
460                        Exception e;
461                        if (cause instanceof Exception) {
462                            e = (Exception) cause;
463                        } else {
464                            e = new Exception(cause);
465                        }
466                        notifyConnectionError(e);
467                    }
468                    connected = false;
469                }
470            }
471            finally {
472                notified = true;
473                synchronized (XMPPBOSHConnection.this) {
474                    XMPPBOSHConnection.this.notifyAll();
475                }
476            }
477        }
478    }
479
480    /**
481     * Listens for XML traffic from the BOSH connection manager and parses it into
482     * stanza objects.
483     *
484     * @author Guenther Niess
485     */
486    private class BOSHPacketReader implements BOSHClientResponseListener {
487
488        /**
489         * Parse the received packets and notify the corresponding connection.
490         *
491         * @param event the BOSH client response which includes the received packet.
492         */
493        @Override
494        public void responseReceived(BOSHMessageEvent event) {
495            AbstractBody body = event.getBody();
496            if (body != null) {
497                try {
498                    if (sessionID == null) {
499                        sessionID = body.getAttribute(BodyQName.create(XMPPBOSHConnection.BOSH_URI, "sid"));
500                    }
501                    if (streamId == null) {
502                        streamId = body.getAttribute(BodyQName.create(XMPPBOSHConnection.BOSH_URI, "authid"));
503                    }
504                    final XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
505                    parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
506                    parser.setInput(new StringReader(body.toXML()));
507                    int eventType = parser.getEventType();
508                    do {
509                        eventType = parser.next();
510                        switch (eventType) {
511                        case XmlPullParser.START_TAG:
512                            String name = parser.getName();
513                            switch (name) {
514                            case Message.ELEMENT:
515                            case IQ.IQ_ELEMENT:
516                            case Presence.ELEMENT:
517                                parseAndProcessStanza(parser);
518                                break;
519                            case "challenge":
520                                // The server is challenging the SASL authentication
521                                // made by the client
522                                final String challengeData = parser.nextText();
523                                getSASLAuthentication().challengeReceived(challengeData);
524                                break;
525                            case "success":
526                                send(ComposableBody.builder().setNamespaceDefinition("xmpp",
527                                                XMPPBOSHConnection.XMPP_BOSH_NS).setAttribute(
528                                                BodyQName.createWithPrefix(XMPPBOSHConnection.XMPP_BOSH_NS, "restart",
529                                                                "xmpp"), "true").setAttribute(
530                                                BodyQName.create(XMPPBOSHConnection.BOSH_URI, "to"), getXMPPServiceDomain().toString()).build());
531                                Success success = new Success(parser.nextText());
532                                getSASLAuthentication().authenticated(success);
533                                break;
534                            case "features":
535                                parseFeatures(parser);
536                                break;
537                            case "failure":
538                                if ("urn:ietf:params:xml:ns:xmpp-sasl".equals(parser.getNamespace(null))) {
539                                    final SASLFailure failure = PacketParserUtils.parseSASLFailure(parser);
540                                    getSASLAuthentication().authenticationFailed(failure);
541                                }
542                                break;
543                            case "error":
544                                // Some BOSH error isn't stream error.
545                                if ("urn:ietf:params:xml:ns:xmpp-streams".equals(parser.getNamespace(null))) {
546                                    throw new StreamErrorException(PacketParserUtils.parseStreamError(parser));
547                                } else {
548                                    StanzaError.Builder builder = PacketParserUtils.parseError(parser);
549                                    throw new XMPPException.XMPPErrorException(null, builder.build());
550                                }
551                            }
552                            break;
553                        }
554                    }
555                    while (eventType != XmlPullParser.END_DOCUMENT);
556                }
557                catch (Exception e) {
558                    if (isConnected()) {
559                        notifyConnectionError(e);
560                    }
561                }
562            }
563        }
564    }
565}