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