/** * All rights reserved. Licensed 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.jivesoftware.smackx.bytestreams.socks5; import java.io.IOException; import java.net.Socket; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Random; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeoutException; import org.jivesoftware.smack.AbstractConnectionListener; import org.jivesoftware.smack.Connection; import org.jivesoftware.smack.ConnectionCreationListener; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Packet; import org.jivesoftware.smack.packet.XMPPError; import org.jivesoftware.smack.util.SyncPacketSend; import org.jivesoftware.smackx.ServiceDiscoveryManager; import org.jivesoftware.smackx.bytestreams.BytestreamListener; import org.jivesoftware.smackx.bytestreams.BytestreamManager; import org.jivesoftware.smackx.bytestreams.socks5.packet.Bytestream; import org.jivesoftware.smackx.bytestreams.socks5.packet.Bytestream.StreamHost; import org.jivesoftware.smackx.bytestreams.socks5.packet.Bytestream.StreamHostUsed; import org.jivesoftware.smackx.filetransfer.FileTransferManager; import org.jivesoftware.smackx.packet.DiscoverInfo; import org.jivesoftware.smackx.packet.DiscoverInfo.Identity; import org.jivesoftware.smackx.packet.DiscoverItems; import org.jivesoftware.smackx.packet.DiscoverItems.Item; /** * The Socks5BytestreamManager class handles establishing SOCKS5 Bytestreams as * specified in the <a * href="http://xmpp.org/extensions/xep-0065.html">XEP-0065</a>. * <p> * A SOCKS5 Bytestream is negotiated partly over the XMPP XML stream and partly * over a separate socket. The actual transfer though takes place over a * separately created socket. * <p> * A SOCKS5 Bytestream generally has three parties, the initiator, the target, * and the stream host. The stream host is a specialized SOCKS5 proxy setup on a * server, or, the initiator can act as the stream host. * <p> * To establish a SOCKS5 Bytestream invoke the {@link #establishSession(String)} * method. This will negotiate a SOCKS5 Bytestream with the given target JID and * return a socket. * <p> * If a session ID for the SOCKS5 Bytestream was already negotiated (e.g. while * negotiating a file transfer) invoke {@link #establishSession(String, String)}. * <p> * To handle incoming SOCKS5 Bytestream requests add an * {@link Socks5BytestreamListener} to the manager. There are two ways to add * this listener. If you want to be informed about incoming SOCKS5 Bytestreams * from a specific user add the listener by invoking * {@link #addIncomingBytestreamListener(BytestreamListener, String)}. If the * listener should respond to all SOCKS5 Bytestream requests invoke * {@link #addIncomingBytestreamListener(BytestreamListener)}. * <p> * Note that the registered {@link Socks5BytestreamListener} will NOT be * notified on incoming Socks5 bytestream requests sent in the context of <a * href="http://xmpp.org/extensions/xep-0096.html">XEP-0096</a> file transfer. * (See {@link FileTransferManager}) * <p> * If no {@link Socks5BytestreamListener}s are registered, all incoming SOCKS5 * Bytestream requests will be rejected by returning a <not-acceptable/> * error to the initiator. * * @author Henning Staib */ public final class Socks5BytestreamManager implements BytestreamManager { /* * create a new Socks5BytestreamManager and register a shutdown listener on * every established connection */ static { Connection .addConnectionCreationListener(new ConnectionCreationListener() { @Override public void connectionCreated(Connection connection) { final Socks5BytestreamManager manager; manager = Socks5BytestreamManager .getBytestreamManager(connection); // register shutdown listener connection .addConnectionListener(new AbstractConnectionListener() { @Override public void connectionClosed() { manager.disableService(); } }); } }); } /** * The XMPP namespace of the SOCKS5 Bytestream */ public static final String NAMESPACE = "http://jabber.org/protocol/bytestreams"; /* prefix used to generate session IDs */ private static final String SESSION_ID_PREFIX = "js5_"; /* random generator to create session IDs */ private final static Random randomGenerator = new Random(); /* stores one Socks5BytestreamManager for each XMPP connection */ private final static Map<Connection, Socks5BytestreamManager> managers = new HashMap<Connection, Socks5BytestreamManager>(); /** * Returns the Socks5BytestreamManager to handle SOCKS5 Bytestreams for a * given {@link Connection}. * <p> * If no manager exists a new is created and initialized. * * @param connection * the XMPP connection or <code>null</code> if given connection * is <code>null</code> * @return the Socks5BytestreamManager for the given XMPP connection */ public static synchronized Socks5BytestreamManager getBytestreamManager( Connection connection) { if (connection == null) { return null; } Socks5BytestreamManager manager = managers.get(connection); if (manager == null) { manager = new Socks5BytestreamManager(connection); managers.put(connection, manager); manager.activate(); } return manager; } /* XMPP connection */ private final Connection connection; /* * assigns a user to a listener that is informed if a bytestream request for * this user is received */ private final Map<String, BytestreamListener> userListeners = new ConcurrentHashMap<String, BytestreamListener>(); /* * list of listeners that respond to all bytestream requests if there are * not user specific listeners for that request */ private final List<BytestreamListener> allRequestListeners = Collections .synchronizedList(new LinkedList<BytestreamListener>()); /* listener that handles all incoming bytestream requests */ private final InitiationListener initiationListener; /* * timeout to wait for the response to the SOCKS5 Bytestream initialization * request */ private int targetResponseTimeout = 10000; /* timeout for connecting to the SOCKS5 proxy selected by the target */ private int proxyConnectionTimeout = 10000; /* blacklist of errornous SOCKS5 proxies */ private final List<String> proxyBlacklist = Collections .synchronizedList(new LinkedList<String>()); /* remember the last proxy that worked to prioritize it */ private String lastWorkingProxy = null; /* flag to enable/disable prioritization of last working proxy */ private boolean proxyPrioritizationEnabled = true; /* * list containing session IDs of SOCKS5 Bytestream initialization packets * that should be ignored by the InitiationListener */ private final List<String> ignoredBytestreamRequests = Collections .synchronizedList(new LinkedList<String>()); /** * Private constructor. * * @param connection * the XMPP connection */ private Socks5BytestreamManager(Connection connection) { this.connection = connection; initiationListener = new InitiationListener(this); } /** * Activates the Socks5BytestreamManager by registering the SOCKS5 * Bytestream initialization listener and enabling the SOCKS5 Bytestream * feature. */ private void activate() { // register bytestream initiation packet listener connection.addPacketListener(initiationListener, initiationListener.getFilter()); // enable SOCKS5 feature enableService(); } /** * Adds BytestreamListener that is called for every incoming SOCKS5 * Bytestream request unless there is a user specific BytestreamListener * registered. * <p> * If no listeners are registered all SOCKS5 Bytestream request are rejected * with a <not-acceptable/> error. * <p> * Note that the registered {@link BytestreamListener} will NOT be notified * on incoming Socks5 bytestream requests sent in the context of <a * href="http://xmpp.org/extensions/xep-0096.html">XEP-0096</a> file * transfer. (See {@link FileTransferManager}) * * @param listener * the listener to register */ @Override public void addIncomingBytestreamListener(BytestreamListener listener) { allRequestListeners.add(listener); } /** * Adds BytestreamListener that is called for every incoming SOCKS5 * Bytestream request from the given user. * <p> * Use this method if you are awaiting an incoming SOCKS5 Bytestream request * from a specific user. * <p> * If no listeners are registered all SOCKS5 Bytestream request are rejected * with a <not-acceptable/> error. * <p> * Note that the registered {@link BytestreamListener} will NOT be notified * on incoming Socks5 bytestream requests sent in the context of <a * href="http://xmpp.org/extensions/xep-0096.html">XEP-0096</a> file * transfer. (See {@link FileTransferManager}) * * @param listener * the listener to register * @param initiatorJID * the JID of the user that wants to establish a SOCKS5 * Bytestream */ @Override public void addIncomingBytestreamListener(BytestreamListener listener, String initiatorJID) { userListeners.put(initiatorJID, listener); } /** * Returns a SOCKS5 Bytestream initialization request packet with the given * session ID containing the given stream hosts for the given target JID. * * @param sessionID * the session ID for the SOCKS5 Bytestream * @param targetJID * the target JID of SOCKS5 Bytestream request * @param streamHosts * a list of SOCKS5 proxies the target should connect to * @return a SOCKS5 Bytestream initialization request packet */ private Bytestream createBytestreamInitiation(String sessionID, String targetJID, List<StreamHost> streamHosts) { final Bytestream initiation = new Bytestream(sessionID); // add all stream hosts for (final StreamHost streamHost : streamHosts) { initiation.addStreamHost(streamHost); } initiation.setType(IQ.Type.SET); initiation.setTo(targetJID); return initiation; } /** * Returns a IQ packet to query a SOCKS5 proxy its network settings. * * @param proxy * the proxy to query * @return IQ packet to query a SOCKS5 proxy its network settings */ private Bytestream createStreamHostRequest(String proxy) { final Bytestream request = new Bytestream(); request.setType(IQ.Type.GET); request.setTo(proxy); return request; } /** * Returns a list of JIDs of SOCKS5 proxies by querying the XMPP server. The * SOCKS5 proxies are in the same order as returned by the XMPP server. * * @return list of JIDs of SOCKS5 proxies * @throws XMPPException * if there was an error querying the XMPP server for SOCKS5 * proxies */ private List<String> determineProxies() throws XMPPException { final ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager .getInstanceFor(connection); final List<String> proxies = new ArrayList<String>(); // get all items form XMPP server final DiscoverItems discoverItems = serviceDiscoveryManager .discoverItems(connection.getServiceName()); final Iterator<Item> itemIterator = discoverItems.getItems(); // query all items if they are SOCKS5 proxies while (itemIterator.hasNext()) { final Item item = itemIterator.next(); // skip blacklisted servers if (proxyBlacklist.contains(item.getEntityID())) { continue; } try { DiscoverInfo proxyInfo; proxyInfo = serviceDiscoveryManager.discoverInfo(item .getEntityID()); final Iterator<Identity> identities = proxyInfo.getIdentities(); // item must have category "proxy" and type "bytestream" while (identities.hasNext()) { final Identity identity = identities.next(); if ("proxy".equalsIgnoreCase(identity.getCategory()) && "bytestreams".equalsIgnoreCase(identity .getType())) { proxies.add(item.getEntityID()); break; } /* * server is not a SOCKS5 proxy, blacklist server to skip * next time a Socks5 bytestream should be established */ proxyBlacklist.add(item.getEntityID()); } } catch (final XMPPException e) { // blacklist errornous server proxyBlacklist.add(item.getEntityID()); } } return proxies; } /** * Returns a list of stream hosts containing the IP address an the port for * the given list of SOCKS5 proxy JIDs. The order of the returned list is * the same as the given list of JIDs excluding all SOCKS5 proxies who's * network settings could not be determined. If a local SOCKS5 proxy is * running it will be the first item in the list returned. * * @param proxies * a list of SOCKS5 proxy JIDs * @return a list of stream hosts containing the IP address an the port */ private List<StreamHost> determineStreamHostInfos(List<String> proxies) { final List<StreamHost> streamHosts = new ArrayList<StreamHost>(); // add local proxy on first position if exists final List<StreamHost> localProxies = getLocalStreamHost(); if (localProxies != null) { streamHosts.addAll(localProxies); } // query SOCKS5 proxies for network settings for (final String proxy : proxies) { final Bytestream streamHostRequest = createStreamHostRequest(proxy); try { final Bytestream response = (Bytestream) SyncPacketSend .getReply(connection, streamHostRequest); streamHosts.addAll(response.getStreamHosts()); } catch (final XMPPException e) { // blacklist errornous proxies proxyBlacklist.add(proxy); } } return streamHosts; } /** * Disables the SOCKS5 Bytestream manager by removing the SOCKS5 Bytestream * feature from the service discovery, disabling the listener for SOCKS5 * Bytestream initiation requests and resetting its internal state. * <p> * To re-enable the SOCKS5 Bytestream feature invoke * {@link #getBytestreamManager(Connection)}. Using the file transfer API * will automatically re-enable the SOCKS5 Bytestream feature. */ public synchronized void disableService() { // remove initiation packet listener connection.removePacketListener(initiationListener); // shutdown threads initiationListener.shutdown(); // clear listeners allRequestListeners.clear(); userListeners.clear(); // reset internal state lastWorkingProxy = null; proxyBlacklist.clear(); ignoredBytestreamRequests.clear(); // remove manager from static managers map managers.remove(connection); // shutdown local SOCKS5 proxy if there are no more managers for other // connections if (managers.size() == 0) { Socks5Proxy.getSocks5Proxy().stop(); } // remove feature from service discovery final ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager .getInstanceFor(connection); // check if service discovery is not already disposed by connection // shutdown if (serviceDiscoveryManager != null) { serviceDiscoveryManager.removeFeature(NAMESPACE); } } /** * Adds the SOCKS5 Bytestream feature to the service discovery. */ private void enableService() { final ServiceDiscoveryManager manager = ServiceDiscoveryManager .getInstanceFor(connection); if (!manager.includesFeature(NAMESPACE)) { manager.addFeature(NAMESPACE); } } /** * Establishes a SOCKS5 Bytestream with the given user and returns the * Socket to send/receive data to/from the user. * <p> * Use this method to establish SOCKS5 Bytestreams to users accepting all * incoming Socks5 bytestream requests since this method doesn't provide a * way to tell the user something about the data to be sent. * <p> * To establish a SOCKS5 Bytestream after negotiation the kind of data to be * sent (e.g. file transfer) use {@link #establishSession(String, String)}. * * @param targetJID * the JID of the user a SOCKS5 Bytestream should be established * @return the Socket to send/receive data to/from the user * @throws XMPPException * if the user doesn't support or accept SOCKS5 Bytestreams, if * no Socks5 Proxy could be found, if the user couldn't connect * to any of the SOCKS5 Proxies * @throws IOException * if the bytestream could not be established * @throws InterruptedException * if the current thread was interrupted while waiting */ @Override public Socks5BytestreamSession establishSession(String targetJID) throws XMPPException, IOException, InterruptedException { final String sessionID = getNextSessionID(); return establishSession(targetJID, sessionID); } /** * Establishes a SOCKS5 Bytestream with the given user using the given * session ID and returns the Socket to send/receive data to/from the user. * * @param targetJID * the JID of the user a SOCKS5 Bytestream should be established * @param sessionID * the session ID for the SOCKS5 Bytestream request * @return the Socket to send/receive data to/from the user * @throws XMPPException * if the user doesn't support or accept SOCKS5 Bytestreams, if * no Socks5 Proxy could be found, if the user couldn't connect * to any of the SOCKS5 Proxies * @throws IOException * if the bytestream could not be established * @throws InterruptedException * if the current thread was interrupted while waiting */ @Override public Socks5BytestreamSession establishSession(String targetJID, String sessionID) throws XMPPException, IOException, InterruptedException { // check if target supports SOCKS5 Bytestream if (!supportsSocks5(targetJID)) { throw new XMPPException(targetJID + " doesn't support SOCKS5 Bytestream"); } // determine SOCKS5 proxies from XMPP-server final List<String> proxies = determineProxies(); // determine address and port of each proxy final List<StreamHost> streamHosts = determineStreamHostInfos(proxies); // compute digest final String digest = Socks5Utils.createDigest(sessionID, connection.getUser(), targetJID); if (streamHosts.isEmpty()) { throw new XMPPException("no SOCKS5 proxies available"); } // prioritize last working SOCKS5 proxy if exists if (proxyPrioritizationEnabled && lastWorkingProxy != null) { StreamHost selectedStreamHost = null; for (final StreamHost streamHost : streamHosts) { if (streamHost.getJID().equals(lastWorkingProxy)) { selectedStreamHost = streamHost; break; } } if (selectedStreamHost != null) { streamHosts.remove(selectedStreamHost); streamHosts.add(0, selectedStreamHost); } } final Socks5Proxy socks5Proxy = Socks5Proxy.getSocks5Proxy(); try { // add transfer digest to local proxy to make transfer valid socks5Proxy.addTransfer(digest); // create initiation packet final Bytestream initiation = createBytestreamInitiation(sessionID, targetJID, streamHosts); // send initiation packet final Packet response = SyncPacketSend.getReply(connection, initiation, getTargetResponseTimeout()); // extract used stream host from response final StreamHostUsed streamHostUsed = ((Bytestream) response) .getUsedHost(); final StreamHost usedStreamHost = initiation .getStreamHost(streamHostUsed.getJID()); if (usedStreamHost == null) { throw new XMPPException( "Remote user responded with unknown host"); } // build SOCKS5 client final Socks5Client socks5Client = new Socks5ClientForInitiator( usedStreamHost, digest, connection, sessionID, targetJID); // establish connection to proxy final Socket socket = socks5Client .getSocket(getProxyConnectionTimeout()); // remember last working SOCKS5 proxy to prioritize it for next // request lastWorkingProxy = usedStreamHost.getJID(); // negotiation successful, return the output stream return new Socks5BytestreamSession(socket, usedStreamHost.getJID() .equals(connection.getUser())); } catch (final TimeoutException e) { throw new IOException("Timeout while connecting to SOCKS5 proxy"); } finally { // remove transfer digest if output stream is returned or an // exception // occurred socks5Proxy.removeTransfer(digest); } } /** * Returns a list of {@link BytestreamListener} that are informed if there * are no listeners for a specific initiator. * * @return list of listeners */ protected List<BytestreamListener> getAllRequestListeners() { return allRequestListeners; } /** * Returns the XMPP connection. * * @return the XMPP connection */ protected Connection getConnection() { return connection; } /** * Returns the list of session IDs that should be ignored by the * InitialtionListener * * @return list of session IDs */ protected List<String> getIgnoredBytestreamRequests() { return ignoredBytestreamRequests; } /** * Returns the stream host information of the local SOCKS5 proxy containing * the IP address and the port or null if local SOCKS5 proxy is not running. * * @return the stream host information of the local SOCKS5 proxy or null if * local SOCKS5 proxy is not running */ private List<StreamHost> getLocalStreamHost() { // get local proxy singleton final Socks5Proxy socks5Server = Socks5Proxy.getSocks5Proxy(); if (socks5Server.isRunning()) { final List<String> addresses = socks5Server.getLocalAddresses(); final int port = socks5Server.getPort(); if (addresses.size() >= 1) { final List<StreamHost> streamHosts = new ArrayList<StreamHost>(); for (final String address : addresses) { final StreamHost streamHost = new StreamHost( connection.getUser(), address); streamHost.setPort(port); streamHosts.add(streamHost); } return streamHosts; } } // server is not running or local address could not be determined return null; } /** * Returns a new unique session ID. * * @return a new unique session ID */ private String getNextSessionID() { final StringBuilder buffer = new StringBuilder(); buffer.append(SESSION_ID_PREFIX); buffer.append(Math.abs(randomGenerator.nextLong())); return buffer.toString(); } /** * Returns the timeout for connecting to the SOCKS5 proxy selected by the * target. Default is 10000ms. * * @return the timeout for connecting to the SOCKS5 proxy selected by the * target */ public int getProxyConnectionTimeout() { if (proxyConnectionTimeout <= 0) { proxyConnectionTimeout = 10000; } return proxyConnectionTimeout; } /** * Returns the timeout to wait for the response to the SOCKS5 Bytestream * initialization request. Default is 10000ms. * * @return the timeout to wait for the response to the SOCKS5 Bytestream * initialization request */ public int getTargetResponseTimeout() { if (targetResponseTimeout <= 0) { targetResponseTimeout = 10000; } return targetResponseTimeout; } /** * Returns the {@link BytestreamListener} that should be informed if a * SOCKS5 Bytestream request from the given initiator JID is received. * * @param initiator * the initiator's JID * @return the listener */ protected BytestreamListener getUserListener(String initiator) { return userListeners.get(initiator); } /** * Use this method to ignore the next incoming SOCKS5 Bytestream request * containing the given session ID. No listeners will be notified for this * request and and no error will be returned to the initiator. * <p> * This method should be used if you are awaiting a SOCKS5 Bytestream * request as a reply to another packet (e.g. file transfer). * * @param sessionID * to be ignored */ public void ignoreBytestreamRequestOnce(String sessionID) { ignoredBytestreamRequests.add(sessionID); } /** * Returns if the prioritization of the last working SOCKS5 proxy on * successive SOCKS5 Bytestream connections is enabled. Default is * <code>true</code>. * * @return <code>true</code> if prioritization is enabled, * <code>false</code> otherwise */ public boolean isProxyPrioritizationEnabled() { return proxyPrioritizationEnabled; } /** * Removes the given listener from the list of listeners for all incoming * SOCKS5 Bytestream requests. * * @param listener * the listener to remove */ @Override public void removeIncomingBytestreamListener(BytestreamListener listener) { allRequestListeners.remove(listener); } /** * Removes the listener for the given user. * * @param initiatorJID * the JID of the user the listener should be removed */ @Override public void removeIncomingBytestreamListener(String initiatorJID) { userListeners.remove(initiatorJID); } /** * Responses to the given packet's sender with a XMPP error that a SOCKS5 * Bytestream is not accepted. * * @param packet * Packet that should be answered with a not-acceptable error */ protected void replyRejectPacket(IQ packet) { final XMPPError xmppError = new XMPPError( XMPPError.Condition.no_acceptable); final IQ errorIQ = IQ.createErrorResponse(packet, xmppError); connection.sendPacket(errorIQ); } /** * Sets the timeout for connecting to the SOCKS5 proxy selected by the * target. Default is 10000ms. * * @param proxyConnectionTimeout * the timeout to set */ public void setProxyConnectionTimeout(int proxyConnectionTimeout) { this.proxyConnectionTimeout = proxyConnectionTimeout; } /** * Enable/disable the prioritization of the last working SOCKS5 proxy on * successive SOCKS5 Bytestream connections. * * @param proxyPrioritizationEnabled * enable/disable the prioritization of the last working SOCKS5 * proxy */ public void setProxyPrioritizationEnabled(boolean proxyPrioritizationEnabled) { this.proxyPrioritizationEnabled = proxyPrioritizationEnabled; } /** * Sets the timeout to wait for the response to the SOCKS5 Bytestream * initialization request. Default is 10000ms. * * @param targetResponseTimeout * the timeout to set */ public void setTargetResponseTimeout(int targetResponseTimeout) { this.targetResponseTimeout = targetResponseTimeout; } /** * Returns <code>true</code> if the given target JID supports feature SOCKS5 * Bytestream. * * @param targetJID * the target JID * @return <code>true</code> if the given target JID supports feature SOCKS5 * Bytestream otherwise <code>false</code> * @throws XMPPException * if there was an error querying target for supported features */ private boolean supportsSocks5(String targetJID) throws XMPPException { final ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager .getInstanceFor(connection); final DiscoverInfo discoverInfo = serviceDiscoveryManager .discoverInfo(targetJID); return discoverInfo.containsFeature(NAMESPACE); } }