/** * 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.lang.ref.WeakReference; import java.net.Socket; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Random; import java.util.WeakHashMap; 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.DiscoverItems; import org.jivesoftware.smackx.packet.DiscoverInfo.Identity; 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() { public void connectionCreated(final Connection connection) { final Socks5BytestreamManager manager; manager = Socks5BytestreamManager.getBytestreamManager(connection); // register shutdown listener connection.addConnectionListener(new AbstractConnectionListener() { public void connectionClosed() { manager.disableService(); } public void connectionClosedOnError(Exception e) { manager.disableService(); } public void reconnectionSuccessful() { managers.put(connection, manager); } }); } }); } /** * 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 WeakHashMap<Connection, Socks5BytestreamManager>(); /* 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 List<String> ignoredBytestreamRequests = Collections.synchronizedList(new LinkedList<String>()); /** * 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; } /** * Private constructor. * * @param connection the XMPP connection */ private Socks5BytestreamManager(Connection connection) { this.connection = connection; this.initiationListener = new InitiationListener(this); } /** * 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 */ public void addIncomingBytestreamListener(BytestreamListener listener) { this.allRequestListeners.add(listener); } /** * Removes the given listener from the list of listeners for all incoming SOCKS5 Bytestream * requests. * * @param listener the listener to remove */ public void removeIncomingBytestreamListener(BytestreamListener listener) { this.allRequestListeners.remove(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 */ public void addIncomingBytestreamListener(BytestreamListener listener, String initiatorJID) { this.userListeners.put(initiatorJID, listener); } /** * Removes the listener for the given user. * * @param initiatorJID the JID of the user the listener should be removed */ public void removeIncomingBytestreamListener(String initiatorJID) { this.userListeners.remove(initiatorJID); } /** * 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) { this.ignoredBytestreamRequests.add(sessionID); } /** * 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 this.connection.removePacketListener(this.initiationListener); // shutdown threads this.initiationListener.shutdown(); // clear listeners this.allRequestListeners.clear(); this.userListeners.clear(); // reset internal state this.lastWorkingProxy = null; this.proxyBlacklist.clear(); this.ignoredBytestreamRequests.clear(); // remove manager from static managers map managers.remove(this.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 ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(this.connection); // check if service discovery is not already disposed by connection shutdown if (serviceDiscoveryManager != null) { serviceDiscoveryManager.removeFeature(NAMESPACE); } } /** * 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 (this.targetResponseTimeout <= 0) { this.targetResponseTimeout = 10000; } return targetResponseTimeout; } /** * 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 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 (this.proxyConnectionTimeout <= 0) { this.proxyConnectionTimeout = 10000; } return proxyConnectionTimeout; } /** * 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; } /** * 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; } /** * 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; } /** * 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 */ public Socks5BytestreamSession establishSession(String targetJID) throws XMPPException, IOException, InterruptedException { 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 */ public Socks5BytestreamSession establishSession(String targetJID, String sessionID) throws XMPPException, IOException, InterruptedException { XMPPException discoveryException = null; // check if target supports SOCKS5 Bytestream if (!supportsSocks5(targetJID)) { throw new XMPPException(targetJID + " doesn't support SOCKS5 Bytestream"); } List<String> proxies = new ArrayList<String>(); // determine SOCKS5 proxies from XMPP-server try { proxies.addAll(determineProxies()); } catch (XMPPException e) { // don't abort here, just remember the exception thrown by determineProxies() // determineStreamHostInfos() will at least add the local Socks5 proxy (if enabled) discoveryException = e; } // determine address and port of each proxy List<StreamHost> streamHosts = determineStreamHostInfos(proxies); if (streamHosts.isEmpty()) { throw discoveryException != null ? discoveryException : new XMPPException("no SOCKS5 proxies available"); } // compute digest String digest = Socks5Utils.createDigest(sessionID, this.connection.getUser(), targetJID); // prioritize last working SOCKS5 proxy if exists if (this.proxyPrioritizationEnabled && this.lastWorkingProxy != null) { StreamHost selectedStreamHost = null; for (StreamHost streamHost : streamHosts) { if (streamHost.getJID().equals(this.lastWorkingProxy)) { selectedStreamHost = streamHost; break; } } if (selectedStreamHost != null) { streamHosts.remove(selectedStreamHost); streamHosts.add(0, selectedStreamHost); } } Socks5Proxy socks5Proxy = Socks5Proxy.getSocks5Proxy(); try { // add transfer digest to local proxy to make transfer valid socks5Proxy.addTransfer(digest); // create initiation packet Bytestream initiation = createBytestreamInitiation(sessionID, targetJID, streamHosts); // send initiation packet Packet response = SyncPacketSend.getReply(this.connection, initiation, getTargetResponseTimeout()); // extract used stream host from response StreamHostUsed streamHostUsed = ((Bytestream) response).getUsedHost(); StreamHost usedStreamHost = initiation.getStreamHost(streamHostUsed.getJID()); if (usedStreamHost == null) { throw new XMPPException("Remote user responded with unknown host"); } // build SOCKS5 client Socks5Client socks5Client = new Socks5ClientForInitiator(usedStreamHost, digest, this.connection, sessionID, targetJID); // establish connection to proxy Socket socket = socks5Client.getSocket(getProxyConnectionTimeout()); // remember last working SOCKS5 proxy to prioritize it for next request this.lastWorkingProxy = usedStreamHost.getJID(); // negotiation successful, return the output stream return new Socks5BytestreamSession(socket, usedStreamHost.getJID().equals( this.connection.getUser())); } catch (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 <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 { ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(this.connection); DiscoverInfo discoverInfo = serviceDiscoveryManager.discoverInfo(targetJID); return discoverInfo.containsFeature(NAMESPACE); } /** * 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 { ServiceDiscoveryManager serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(this.connection); List<String> proxies = new ArrayList<String>(); // get all items form XMPP server DiscoverItems discoverItems = serviceDiscoveryManager.discoverItems(this.connection.getServiceName()); Iterator<Item> itemIterator = discoverItems.getItems(); // query all items if they are SOCKS5 proxies while (itemIterator.hasNext()) { Item item = itemIterator.next(); // skip blacklisted servers if (this.proxyBlacklist.contains(item.getEntityID())) { continue; } try { DiscoverInfo proxyInfo; proxyInfo = serviceDiscoveryManager.discoverInfo(item.getEntityID()); Iterator<Identity> identities = proxyInfo.getIdentities(); // item must have category "proxy" and type "bytestream" while (identities.hasNext()) { 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 */ this.proxyBlacklist.add(item.getEntityID()); } } catch (XMPPException e) { // blacklist errornous server this.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) { List<StreamHost> streamHosts = new ArrayList<StreamHost>(); // add local proxy on first position if exists List<StreamHost> localProxies = getLocalStreamHost(); if (localProxies != null) { streamHosts.addAll(localProxies); } // query SOCKS5 proxies for network settings for (String proxy : proxies) { Bytestream streamHostRequest = createStreamHostRequest(proxy); try { Bytestream response = (Bytestream) SyncPacketSend.getReply(this.connection, streamHostRequest); streamHosts.addAll(response.getStreamHosts()); } catch (XMPPException e) { // blacklist errornous proxies this.proxyBlacklist.add(proxy); } } return streamHosts; } /** * 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) { Bytestream request = new Bytestream(); request.setType(IQ.Type.GET); request.setTo(proxy); return request; } /** * 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 Socks5Proxy socks5Server = Socks5Proxy.getSocks5Proxy(); if (socks5Server.isRunning()) { List<String> addresses = socks5Server.getLocalAddresses(); int port = socks5Server.getPort(); if (addresses.size() >= 1) { List<StreamHost> streamHosts = new ArrayList<StreamHost>(); for (String address : addresses) { StreamHost streamHost = new StreamHost(this.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 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) { Bytestream initiation = new Bytestream(sessionID); // add all stream hosts for (StreamHost streamHost : streamHosts) { initiation.addStreamHost(streamHost); } initiation.setType(IQ.Type.SET); initiation.setTo(targetJID); return initiation; } /** * 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) { XMPPError xmppError = new XMPPError(XMPPError.Condition.no_acceptable); IQ errorIQ = IQ.createErrorResponse(packet, xmppError); this.connection.sendPacket(errorIQ); } /** * Activates the Socks5BytestreamManager by registering the SOCKS5 Bytestream initialization * listener and enabling the SOCKS5 Bytestream feature. */ private void activate() { // register bytestream initiation packet listener this.connection.addPacketListener(this.initiationListener, this.initiationListener.getFilter()); // enable SOCKS5 feature enableService(); } /** * Adds the SOCKS5 Bytestream feature to the service discovery. */ private void enableService() { ServiceDiscoveryManager manager = ServiceDiscoveryManager.getInstanceFor(this.connection); if (!manager.includesFeature(NAMESPACE)) { manager.addFeature(NAMESPACE); } } /** * Returns a new unique session ID. * * @return a new unique session ID */ private String getNextSessionID() { StringBuilder buffer = new StringBuilder(); buffer.append(SESSION_ID_PREFIX); buffer.append(Math.abs(randomGenerator.nextLong())); return buffer.toString(); } /** * Returns the XMPP connection. * * @return the XMPP connection */ protected Connection getConnection() { return this.connection; } /** * 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 this.userListeners.get(initiator); } /** * 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 this.allRequestListeners; } /** * Returns the list of session IDs that should be ignored by the InitialtionListener * * @return list of session IDs */ protected List<String> getIgnoredBytestreamRequests() { return ignoredBytestreamRequests; } }