package com.limegroup.gnutella;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Random;
import java.util.Set;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cybergarage.upnp.Action;
import org.cybergarage.upnp.Argument;
import org.cybergarage.upnp.ControlPoint;
import org.cybergarage.upnp.Device;
import org.cybergarage.upnp.DeviceList;
import org.cybergarage.upnp.Service;
import org.cybergarage.upnp.device.DeviceChangeListener;
import com.limegroup.gnutella.settings.ApplicationSettings;
import com.limegroup.gnutella.settings.ConnectionSettings;
import com.limegroup.gnutella.util.ManagedThread;
import com.limegroup.gnutella.util.NetworkUtils;
import com.limegroup.gnutella.util.ThreadFactory;
/**
* Manages the mapping of ports to limewire on UPnP-enabled routers.
*
* According to the UPnP Standards, Internet Gateway Devices must have a
* specific hierarchy. The parts of that hierarchy that we care about are:
*
* Device: urn:schemas-upnp-org:device:InternetGatewayDevice:1
* SubDevice: urn:schemas-upnp-org:device:WANDevice:1
* SubDevice: urn:schemas-upnp-org:device:WANConnectionDevice:1
* Service: urn:schemas-upnp-org:service:WANIPConnection:1
*
* Every port mapping is a tuple of:
* - External address ("" is wildcard)
* - External port
* - Internal address
* - Internal port
* - Protocol (TCP|UDP)
* - Description
*
* Port mappings can be removed, but that is a blocking network operation which will
* slow down the shutdown process of Limewire. It is safe to let port mappings persist
* between limewire sessions. In the meantime however, the NAT may assign a different
* ip address to the local node. That's why we need to find any previous mappings
* the node has created and update them with our new address. In order to uniquely
* distinguish which mappings were made by us, we put part of our client GUID in the
* description field.
*
* For the TCP mapping, we use the following description: "Lime/TCP:<cliengGUID>"
* For the UDP mapping, we use "Lime/UDP:<clientGUID>"
*
* NOTES:
*
* Not all NATs support mappings with different external port and internal ports. Therefore
* if we were unable to map our desired port but were able to map another one, we should
* pass this information on to Acceptor.
*
* Some buggy NATs do not distinguish mappings by the Protocol field. Therefore
* we first map the UDP port, and then the TCP port since it is more important should the
* first mapping get overwritten.
*
* The cyberlink library uses an internal thread that tries to discover any UPnP devices.
* After we discover a router or give up on trying to, we should call stop().
*
*/
public class UPnPManager extends ControlPoint implements DeviceChangeListener {
private static final Log LOG = LogFactory.getLog(UPnPManager.class);
/** some schemas */
private static final String ROUTER_DEVICE=
"urn:schemas-upnp-org:device:InternetGatewayDevice:1";
private static final String WAN_DEVICE =
"urn:schemas-upnp-org:device:WANDevice:1";
private static final String WANCON_DEVICE=
"urn:schemas-upnp-org:device:WANConnectionDevice:1";
private static final String SERVICE_TYPE =
"urn:schemas-upnp-org:service:WANIPConnection:1";
/** prefixes and a suffix for the descriptions of our TCP and UDP mappings */
private static final String TCP_PREFIX = "LimeTCP";
private static final String UDP_PREFIX = "LimeUDP";
private String _guidSuffix;
/** amount of time to wait while looking for a NAT device. */
private static final int WAIT_TIME = 3 * 1000; // 3 seconds
private static final UPnPManager INSTANCE = new UPnPManager();
public static UPnPManager instance() {
return INSTANCE;
}
/**
* the router we have and the sub-device necessary for port mapping
* LOCKING: DEVICE_LOCK
*/
private volatile Device _router;
/**
* The port-mapping service we'll use. LOCKING: DEVICE_LOCK
*/
private volatile Service _service;
/** The tcp and udp mappings created this session */
private volatile Mapping _tcp, _udp;
/**
* Lock that everything uses.
*/
private final Object DEVICE_LOCK = new Object();
private UPnPManager() {
super();
addDeviceChangeListener(this);
}
public boolean start() {
LOG.debug("Starting UPnP Manager.");
synchronized(DEVICE_LOCK) {
try {
return super.start();
} catch(Exception bad) {
ConnectionSettings.DISABLE_UPNP.setValue(true);
ErrorService.error(bad);
return false;
}
}
}
/**
* @return whether we are behind an UPnP-enabled NAT/router
*/
public boolean isNATPresent() {
return _router != null && _service != null;
}
/**
* @return whether we have created mappings this session
*/
public boolean mappingsExist() {
return _tcp != null || _udp != null;
}
/**
* @return the external address the NAT thinks we have. Blocking.
* null if we can't find it.
*/
public InetAddress getNATAddress() throws UnknownHostException {
if (!isNATPresent())
return null;
Action getIP = _service.getAction("GetExternalIPAddress");
if(getIP == null) {
LOG.debug("Couldn't find GetExternalIPAddress action!");
return null;
}
if (!getIP.postControlAction()) {
LOG.debug("couldn't get our external address");
return null;
}
Argument ret = getIP.getOutputArgumentList().getArgument("NewExternalIPAddress");
return InetAddress.getByName(ret.getValue());
}
/**
* Waits for a small amount of time before the device is discovered.
*/
public void waitForDevice() {
if(isNATPresent())
return;
synchronized(DEVICE_LOCK) {
// already have it.
// otherwise, wait till we grab it.
try {
DEVICE_LOCK.wait(WAIT_TIME);
} catch(InterruptedException ie) {}
}
}
/**
* this method will be called when we discover a UPnP device.
*/
public void deviceAdded(Device dev) {
if (isNATPresent())
return;
synchronized(DEVICE_LOCK) {
if(LOG.isTraceEnabled())
LOG.trace("Device added: " + dev.getFriendlyName());
// did we find a router?
if (dev.getDeviceType().equals(ROUTER_DEVICE) && dev.isRootDevice())
_router = dev;
if (_router == null) {
LOG.debug("didn't get router device");
return;
}
discoverService();
// did we find the service we need?
if (_service == null) {
LOG.debug("couldn't find service");
_router=null;
} else {
if(LOG.isDebugEnabled())
LOG.debug("Found service, router: " + _router.getFriendlyName() + ", service: " + _service);
DEVICE_LOCK.notify();
stop();
}
}
}
/**
* Traverses the structure of the router device looking for
* the port mapping service.
*/
private void discoverService() {
for (Iterator iter = _router.getDeviceList().iterator();iter.hasNext();) {
Device current = (Device)iter.next();
if (!current.getDeviceType().equals(WAN_DEVICE))
continue;
DeviceList l = current.getDeviceList();
if (LOG.isDebugEnabled())
LOG.debug("found "+current.getDeviceType()+", size: "+l.size() + ", on: " + current.getFriendlyName());
for (int i=0;i<current.getDeviceList().size();i++) {
Device current2 = l.getDevice(i);
if (!current2.getDeviceType().equals(WANCON_DEVICE))
continue;
if (LOG.isDebugEnabled())
LOG.debug("found "+current2.getDeviceType() + ", on: " + current2.getFriendlyName());
_service = current2.getService(SERVICE_TYPE);
return;
}
}
}
/**
* adds a mapping on the router to the specified port
* @return the external port that was actually mapped. 0 if failed
*/
public int mapPort(int port) {
if(LOG.isTraceEnabled())
LOG.trace("Attempting to map port: " + port);
Random gen=null;
String localAddress = NetworkUtils.ip2string(
RouterService.getAcceptor().getAddress(false));
int localPort = port;
// try adding new mappings with the same port
Mapping udp = new Mapping("",
port,
localAddress,
localPort,
"UDP",
UDP_PREFIX + getGUIDSuffix());
// add udp first in case it gets overwritten.
// if we can't add, update or find an appropriate port
// give up after 20 tries
int tries = 20;
while (!addMapping(udp)) {
if (tries<0)
break;
tries--;
// try a random port
if (gen == null)
gen = new Random();
port = gen.nextInt(50000)+2000;
udp = new Mapping("",
port,
localAddress,
localPort,
"UDP",
UDP_PREFIX + getGUIDSuffix());
}
if (tries < 0) {
LOG.debug("couldn't map a port :(");
return 0;
}
// at this stage, the variable port will point to the port the UDP mapping
// got mapped to. Since we have to have the same port for UDP and tcp,
// we can't afford to change the port here. So if mapping to this port on tcp
// fails, we give up and clean up the udp mapping.
// Note: Phillipe reported that on some routers adding an UDP mapping will also
// create a TCP mapping. So we no longer delete the UDP mapping if the TCP one
// fails.
Mapping tcp = new Mapping("",
port,
localAddress,
localPort,
"TCP",
TCP_PREFIX + getGUIDSuffix());
if (!addMapping(tcp)) {
LOG.debug(" couldn't map tcp to whatever udp was mapped. leaving udp around...");
tcp = null;
}
// save a ref to the mappings
synchronized(DEVICE_LOCK) {
_tcp = tcp;
_udp = udp;
}
// we're good - start a thread to clean up any potentially stale mappings
ThreadFactory.startThread(new StaleCleaner(), "Stale Mapping Cleaner");
return port;
}
/**
* @param m Port mapping to send to the NAT
* @return the error code
*/
private boolean addMapping(Mapping m) {
if (LOG.isDebugEnabled())
LOG.debug("adding "+m);
Action add = _service.getAction("AddPortMapping");
if(add == null) {
LOG.debug("Couldn't find AddPortMapping action!");
return false;
}
add.setArgumentValue("NewRemoteHost",m._externalAddress);
add.setArgumentValue("NewExternalPort",m._externalPort);
add.setArgumentValue("NewInternalClient",m._internalAddress);
add.setArgumentValue("NewInternalPort",m._internalPort);
add.setArgumentValue("NewProtocol",m._protocol);
add.setArgumentValue("NewPortMappingDescription",m._description);
add.setArgumentValue("NewEnabled","1");
add.setArgumentValue("NewLeaseDuration",0);
boolean success = add.postControlAction();
if(LOG.isTraceEnabled())
LOG.trace("Post succeeded: " + success);
return success;
}
/**
* @param m the mapping to remove from the NAT
* @return whether it worked or not
*/
private boolean removeMapping(Mapping m) {
if (LOG.isDebugEnabled())
LOG.debug("removing "+m);
Action remove = _service.getAction("DeletePortMapping");
if(remove == null) {
LOG.debug("Couldn't find DeletePortMapping action!");
return false;
}
remove.setArgumentValue("NewRemoteHost",m._externalAddress);
remove.setArgumentValue("NewExternalPort",m._externalPort);
remove.setArgumentValue("NewProtocol",m._protocol);
boolean success = remove.postControlAction();
if(LOG.isDebugEnabled())
LOG.debug("Remove succeeded: " + success);
return success;
}
/**
* schedules a shutdown hook which will clear the mappings created
* this session.
*/
public void clearMappingsOnShutdown() {
final Mapping tcp, udp;
synchronized(DEVICE_LOCK) {
tcp = _tcp;
udp = _udp;
}
Thread waiter = new Thread("UPnP Waiter") {
public void run() {
Thread cleaner = new Thread("UPnP Cleaner") {
public void run() {
LOG.debug("start cleaning");
if (tcp != null) removeMapping(tcp);
if (udp != null) removeMapping(udp);
LOG.debug("done cleaning");
}
};
cleaner.setDaemon(true);
cleaner.start();
Thread.yield();
try {
LOG.debug("waiting for UPnP cleaners to finish");
cleaner.join(30000); // wait at most 30 seconds.
} catch(InterruptedException ignored){}
LOG.debug("UPnP cleaners done");
}
};
RouterService.addShutdownItem(waiter);
}
public void finalize() {
stop();
}
private String getGUIDSuffix() {
synchronized(DEVICE_LOCK) {
if (_guidSuffix == null)
_guidSuffix = ApplicationSettings.CLIENT_ID.getValue().substring(0,10);
return _guidSuffix;
}
}
/**
* stub
*/
public void deviceRemoved(Device dev) {}
private final class Mapping {
public final String _externalAddress;
public final int _externalPort;
public final String _internalAddress;
public final int _internalPort;
public final String _protocol,_description;
// network constructor
public Mapping(String externalAddress,String externalPort,
String internalAddress, String internalPort,
String protocol, String description) throws NumberFormatException{
_externalAddress=externalAddress;
_externalPort=Integer.parseInt(externalPort);
_internalAddress=internalAddress;
_internalPort=Integer.parseInt(internalPort);
_protocol=protocol;
_description=description;
}
// internal constructor
public Mapping(String externalAddress,int externalPort,
String internalAddress, int internalPort,
String protocol, String description) {
if ( !NetworkUtils.isValidPort(externalPort) ||
!NetworkUtils.isValidPort(internalPort))
throw new IllegalArgumentException();
_externalAddress=externalAddress;
_externalPort=externalPort;
_internalAddress=internalAddress;
_internalPort=internalPort;
_protocol=protocol;
_description=description;
}
public String toString() {
return _externalAddress+":"+_externalPort+"->"+_internalAddress+":"+_internalPort+
"@"+_protocol+" desc: "+_description;
}
}
/**
* This thread reads all the existing mappings on the NAT and if it finds
* a mapping which appears to be created by us but points to a different
* address (i.e. is stale) it removes the mapping.
*
* It can take several minutes to finish, depending on how many mappings there are.
*/
private class StaleCleaner implements Runnable {
// TODO: remove
private String list(java.util.List l) {
String s = "";
for(Iterator i = l.iterator(); i.hasNext(); ) {
Argument next = (Argument)i.next();
s += next.getName() + "->" + next.getValue() + ", ";
}
return s;
}
public void run() {
LOG.debug("Looking for stale mappings...");
Set mappings = new HashSet();
Action getGeneric = _service.getAction("GetGenericPortMappingEntry");
if(getGeneric == null) {
LOG.debug("Couldn't find GetGenericPortMappingEntry action!");
return;
}
// get all the mappings
try {
for (int i=0;;i++) {
getGeneric.setArgumentValue("NewPortMappingIndex",i);
if(LOG.isDebugEnabled())
LOG.debug("Stale Iteration: " + i + ", generic.input: " + list(getGeneric.getInputArgumentList()) + ", generic.output: " + list(getGeneric.getOutputArgumentList()));
if (!getGeneric.postControlAction())
break;
mappings.add(new Mapping(
getGeneric.getArgumentValue("NewRemoteHost"),
getGeneric.getArgumentValue("NewExternalPort"),
getGeneric.getArgumentValue("NewInternalClient"),
getGeneric.getArgumentValue("NewInternalPort"),
getGeneric.getArgumentValue("NewProtocol"),
getGeneric.getArgumentValue("NewPortMappingDescription")));
// TODO: erase output arguments.
}
}catch(NumberFormatException bad) {
LOG.error("NFE reading mappings!", bad);
//router broke.. can't do anything.
return;
}
if (LOG.isDebugEnabled())
LOG.debug("Stale cleaner found "+mappings.size()+ " total mappings");
// iterate and clean up
for (Iterator iter = mappings.iterator();iter.hasNext();) {
Mapping current = (Mapping)iter.next();
if(LOG.isDebugEnabled())
LOG.debug("Analyzing: " + current);
if(current._description == null)
continue;
// does it have our description?
if (current._description.equals(TCP_PREFIX+getGUIDSuffix()) ||
current._description.equals(UDP_PREFIX+getGUIDSuffix())) {
// is it not the same as the mappings we created this session?
synchronized(DEVICE_LOCK) {
if (_udp != null &&
current._externalPort == _udp._externalPort &&
current._internalAddress.equals(_udp._internalAddress) &&
current._internalPort == _udp._internalPort)
continue;
}
// remove it.
if (LOG.isDebugEnabled())
LOG.debug("mapping "+current+" appears to be stale");
removeMapping(current);
}
}
}
}
}