Skip to content

Commit

Permalink
Port UPnPUtil to jupnp
Browse files Browse the repository at this point in the history
This uses the jupnp stack to discover and map ports.
It does not use jupnp's built-in PortMappingListener due to
shortcomings:

1. We need to determine which address to request mappings to dynamically
   instead of specifying the address to map in advance
   since we can only know which address is appropriate after we
   determine which interface has an IGD on.
   We instead use the address that the IGD was discovered from.
   This may not be the most preferred address to use, but it does
   guarantee that it works with routers that reject requests to map ports
   to addresses that the request didn't come from.
2. We need some additional callbacks to be able to wait for some device
   to be discovered and some port to be mapped in order to present
   appropriate error messages.
3. My router doesn't support InternetGatewayDevice:1,
   only InternetGatewayDevice:2, so I needed to add additional checks.
   This is fine because PortMappingAdd is supported on both.

The jupnp data model isn't ideal, since it broadcasts from every IPv4
address and some routers (i.e. mine) will respond to discovery
broadcasts from every address it has, including a few IPv6 ones,
and it assumes that if it gets a response from a different address
then the UPnP device has changed address and so is a new device,
triggering device removed and new device callbacks,
which we have to request a new port mapping for because we can't tell if
it's a new device or not.

As a result, port mapping involves a number of port mapping requests
equal to the number of non-loopback IPv4 addresses you have
multiplied by the number of addresses the IGD responds from.
  • Loading branch information
fishface60 committed Dec 28, 2024
1 parent be26a55 commit 1adba36
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 167 deletions.
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,13 @@ dependencies {
// find running instances in LAN
implementation 'net.tsc.servicediscovery:servicediscovery:1.0.b5'

//maybe replace with jupnp
implementation 'javax.servlet:servlet-api:2.4'
implementation 'org.eclipse.jetty:jetty-client:9.4.56.v20240826'
implementation 'org.eclipse.jetty:jetty-server:9.4.56.v20240826'
implementation 'org.eclipse.jetty:jetty-servlet:9.4.56.v20240826'
implementation 'org.jupnp:org.jupnp:3.0.2'
implementation 'org.jupnp:org.jupnp.support:3.0.2'
// upnplib still used for by SysInfoProvider
implementation 'commons-jxpath:commons-jxpath:1.3'
implementation 'net.sbbi.upnp:upnplib:1.0.9-nodebug'

Expand Down
350 changes: 184 additions & 166 deletions src/main/java/net/rptools/maptool/util/UPnPUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,195 +14,213 @@
*/
package net.rptools.maptool.util;

import java.io.IOException;
import java.net.Inet4Address;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import net.rptools.maptool.client.AppPreferences;
import net.rptools.maptool.client.MapTool;
import net.sbbi.upnp.Discovery;
import net.sbbi.upnp.impls.InternetGatewayDevice;
import net.sbbi.upnp.messages.ActionResponse;
import net.sbbi.upnp.messages.UPNPResponseException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jupnp.DefaultUpnpServiceConfiguration;
import org.jupnp.UpnpService;
import org.jupnp.UpnpServiceImpl;
import org.jupnp.model.action.ActionInvocation;
import org.jupnp.model.message.UpnpResponse;
import org.jupnp.model.message.header.STAllHeader;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.model.meta.Service;
import org.jupnp.model.types.UDADeviceType;
import org.jupnp.model.types.UDAServiceType;
import org.jupnp.registry.DefaultRegistryListener;
import org.jupnp.registry.Registry;
import org.jupnp.support.igd.callback.PortMappingAdd;
import org.jupnp.support.igd.callback.PortMappingDelete;
import org.jupnp.support.model.PortMapping;

/**
* @author Phil Wright
* @author Richard Maw - Rewritten to use jupnp
*/
public class UPnPUtil {
private static final Logger log = LogManager.getLogger(UPnPUtil.class);
private static Map<InternetGatewayDevice, NetworkInterface> igds;
private static List<InternetGatewayDevice> mappings;

public static boolean findIGDs() {
igds = new HashMap<InternetGatewayDevice, NetworkInterface>();
try {
Enumeration<NetworkInterface> e = NetworkInterface.getNetworkInterfaces();
while (e.hasMoreElements()) {
NetworkInterface ni = e.nextElement();
try {
var addresses = Collections.list(ni.getInetAddresses());
if (addresses.isEmpty()) {
log.info("UPnP: Rejecting interface '{}' as it has no addresses", ni.getDisplayName());
} else if (ni.isLoopback()) {
log.info(
"UPnP: Rejecting interface '{}' [{}] as it is a loopback",
ni.getDisplayName(),
addresses);
} else if (ni.isVirtual()) {
log.info(
"UPnP: Rejecting interface '{}' [{}] as it is virtual",
ni.getDisplayName(),
addresses);
} else if (!ni.isUp()) {
log.info(
"UPnP: Rejecting interface '{}' [{}] as it is not up",
ni.getDisplayName(),
addresses);
} else {
int found = 0;
try {
log.info(
"UPnP: Looking for gateway devices on interface '{}' [{}]",
ni.getDisplayName(),
addresses);
InternetGatewayDevice[] thisNI;
thisNI =
InternetGatewayDevice.getDevices(
AppPreferences.upnpDiscoveryTimeout.get(),
Discovery.DEFAULT_TTL,
Discovery.DEFAULT_MX,
ni);
if (thisNI != null) {
for (InternetGatewayDevice igd : thisNI) {
found++;
log.info("UPnP: Found IGD: {}", igd.getIGDRootDevice().getModelName());
if (igds.put(igd, ni) != null) {
// There was a previous mapping for this IGD! It's unlikely to have two NICs on
// the
// the same network segment, but it IS possible. For example, both a wired and
// wireless connection using the same router as the gateway. For our purposes it
// doesn't really matter which one we use, but in the future we should give the
// user a choice.
// FIXME We SHOULD be using the "networking binding order" (Windows)
// or "network service order" on OSX.
log.info("UPnP: This was not the first time this IGD was found!");
}
}
}
} catch (IOException ex) {
// some IO Exception occurred during communication with device
log.warn("While searching for internet gateway devices", ex);
private static final UDADeviceType INTERNET_GATEWAY_DEVICE_V1 =
new UDADeviceType("InternetGatewayDevice", 1);
private static final UDADeviceType INTERNET_GATEWAY_DEVICE_V2 =
new UDADeviceType("InternetGatewayDevice", 2);
private static final UDAServiceType WAN_IP_CONNECTION_V1 =
new UDAServiceType("WANIPConnection", 1);
private static final UDAServiceType WAN_IP_CONNECTION_V2 =
new UDAServiceType("WANIPConnection", 2);
private static final UDAServiceType WAN_PPP_CONNECTION_V1 =
new UDAServiceType("WANPPPConnection", 1);

private record MappingInfo(
UpnpService upnpService, CompletableFuture<Boolean> somePortUnmapped) {}

private static Map<Integer, MappingInfo> mappingServices = new HashMap<Integer, MappingInfo>();

/**
* Maps the provided port to a heuristically chosen address for every discovered IGD.
*
* @return true if any port was mapped within the timeout, false if none were discovered or
* weren't mappable within the timeout.
*/
public static boolean openPort(int port) {
UpnpService upnpService = new UpnpServiceImpl(new DefaultUpnpServiceConfiguration());
upnpService.startup();

var someDeviceFound = new CompletableFuture<Void>();
var somePortMapped = new CompletableFuture<Void>();
var somePortUnmapped = new CompletableFuture<Boolean>();
var listener =
new DefaultRegistryListener() {
private record MappedServiceInfo(
Service<?, ?> connectionService, PortMapping portMapping) {}

private Map<RemoteDevice, MappedServiceInfo> mappedIgds = null;

private Service<?, ?> getIgdService(RemoteDevice device) {
var deviceType = device.getType();
if (!deviceType.equals(INTERNET_GATEWAY_DEVICE_V1)
&& !deviceType.equals(INTERNET_GATEWAY_DEVICE_V2)) {
return null;
}

Service<?, ?> connectionService = device.findService(WAN_IP_CONNECTION_V2);
if (connectionService == null) {
log.debug("Device {} does not have service: {}", device, WAN_IP_CONNECTION_V2);
connectionService = device.findService(WAN_IP_CONNECTION_V1);
}
if (connectionService == null) {
log.debug("Device {} does not have service: {}", device, WAN_IP_CONNECTION_V1);
connectionService = device.findService(WAN_PPP_CONNECTION_V1);
}
log.info("Found {} IGDs on interface {}", found, ni.getDisplayName());
if (connectionService == null) {
log.debug("Device {} does not have service: {}", device, WAN_PPP_CONNECTION_V1);
}

return connectionService;
}
} catch (SocketException se) {
continue;
}
}
} catch (SocketException se) {
// Nothing to do, but we DO want the 'mappings' member to be initialized
}
mappings = new ArrayList<InternetGatewayDevice>(igds.size());
return !igds.isEmpty();
}

public static boolean openPort(int port) {
if (igds == null || igds.isEmpty()) {
findIGDs();
}
if (igds == null || igds.isEmpty()) {
MapTool.showError("msg.error.server.upnp.noigd");
return false;
}
for (var entry : igds.entrySet()) {
InternetGatewayDevice gd = entry.getKey();
NetworkInterface ni = entry.getValue();
String localHostIP = "(NULL)";
try {
switch (ni.getInterfaceAddresses().size()) {
case 0:
log.error("IGD shows up in list of IGDs, but no NICs stored therein?!");
break;
case 1:
localHostIP = ni.getInterfaceAddresses().get(0).getAddress().getHostAddress();
break;
default:
for (InterfaceAddress ifAddr : ni.getInterfaceAddresses()) {
if (ifAddr.getAddress() instanceof Inet4Address) {
localHostIP = ifAddr.getAddress().getHostAddress();
log.info("IP address {} on interface {}", localHostIP, ni.getDisplayName());
@Override
public void remoteDeviceAdded(Registry registry, RemoteDevice device) {
var connectionService = getIgdService(device);
if (connectionService == null) {
return;
}
var deviceIdentity = device.getIdentity();

log.debug(
"Added IGD {} with address {}",
device,
deviceIdentity.getDescriptorURL().getHost());

// remoteDeviceAdded may be called multiple times for the same IGD
// either because jupnp discovered it from multiple addresses
// or because the service was brought down and reappeared.
// Since it may or may not remember the port we must try to add it anyway.
synchronized (this) {
if (mappedIgds == null) {
mappedIgds = new HashMap<RemoteDevice, MappedServiceInfo>();
}
someDeviceFound.complete(null);

var portMapping =
new PortMapping(
port,
device.getIdentity().getDiscoveredOnLocalAddress().getHostAddress(),
PortMapping.Protocol.TCP,
"MapTool");
new PortMappingAdd(
connectionService, registry.getUpnpService().getControlPoint(), portMapping) {
@Override
public void success(ActionInvocation invocation) {
log.debug("Mapped port {} on IGD {}", port, device);
mappedIgds.put(device, new MappedServiceInfo(connectionService, portMapping));
somePortMapped.complete(null);
}

@Override
public void failure(
ActionInvocation invocation, UpnpResponse res, String defaultMsg) {
log.warn("Failed to map port {} on IGD {}: {}", port, device, defaultMsg);
}
}.run();
}
}

@Override
public void beforeShutdown(Registry registry) {
log.debug("Shutting down port {} mapping service", port);
// jupnp considers a device appearing to change IP address as a new device
// and calls removed and added callbacks, and it may still have mappings after that
// so we can't use remoteDeviceRemoved to remove already unmapped mappings
// and have to just try removing everything we mapped
for (var entry : mappedIgds.entrySet()) {
var device = entry.getKey();
var value = entry.getValue();
new PortMappingDelete(
value.connectionService(),
registry.getUpnpService().getControlPoint(),
value.portMapping()) {
@Override
public void success(ActionInvocation invocation) {
log.debug("Unmapped port {} on IGD {}", port, device);
somePortUnmapped.complete(true);
}

@Override
public void failure(
ActionInvocation invocation, UpnpResponse res, String defaultMsg) {
log.warn("Failed to unmap port {} on IGD {}: {}", port, device, defaultMsg);
}
}.run();
}
break;
}
boolean mapped = gd.addPortMapping("MapTool", null, port, port, localHostIP, 0, "TCP");
if (mapped) {
mappings.add(gd);
log.info(
"UPnP: Port {} mapped on {} at address {}", port, ni.getDisplayName(), localHostIP);
}
} catch (UPNPResponseException respEx) {
// oops the IGD did not like something !!
log.error(
"UPnP Error 1: Could not add port mapping on device "
+ ni.getDisplayName()
+ ", IP address "
+ localHostIP,
respEx);
} catch (IOException ioe) {
log.error(
"UPnP Error 2: Could not add port mapping on device "
+ ni.getDisplayName()
+ ", IP address "
+ localHostIP,
ioe);
}
};

upnpService.getRegistry().addListener(listener);

upnpService.getControlPoint().search(new STAllHeader());
try {
try {
someDeviceFound.get(AppPreferences.upnpDiscoveryTimeout.get(), TimeUnit.MILLISECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
MapTool.showError("msg.error.server.upnp.noigd");
throw e;
}
try {
somePortMapped.get(AppPreferences.upnpDiscoveryTimeout.get(), TimeUnit.MILLISECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
MapTool.showError("UPnP: found some IGDs but no port mapping succeeded!?");
throw e;
}
} catch (ExecutionException | InterruptedException | TimeoutException e) {
upnpService.shutdown();
return false;
}
if (mappings.isEmpty())
MapTool.showError("UPnP: found " + igds.size() + " IGDs but no port mapping succeeded!?");
return !mappings.isEmpty();
mappingServices.put(port, new MappingInfo(upnpService, somePortUnmapped));
return true;
}

/**
* Unmap the provided port from discovered IGDs.
*
* @return true if any mapped ports were successfully unmapped or there were no mappings, false if
* there were mappings that couldn't be unmapped.
*/
public static boolean closePort(int port) {
if (igds == null || igds.isEmpty()) return true;

int count = 0;
for (var iter = igds.entrySet().iterator(); iter.hasNext(); ) {
var entry = iter.next();
InternetGatewayDevice gd = entry.getKey();
try {
ActionResponse actResp = gd.getSpecificPortMappingEntry(null, port, "TCP");
if (actResp != null
&& "MapTool".equals(actResp.getOutActionArgumentValue("NewPortMappingDescription"))) {
// NewInternalPort=51234
// NewEnabled=1
// NewInternalClient=192.168.0.30
// NewLeaseDuration=0
// NewPortMappingDescription=MapTool
boolean unmapped = gd.deletePortMapping(null, port, "TCP");
if (unmapped) {
count++;
log.info("UPnP: Port unmapped from {}", entry.getValue().getDisplayName());
iter.remove();
} else {
log.info("UPnP: Failed to unmap port from {}", entry.getValue().getDisplayName());
}
}
} catch (IOException e) {
log.info("UPnP: IOException while talking to IGD", e);
} catch (UPNPResponseException e) {
log.info("UPnP: UPNPResponseException while talking to IGD", e);
}
if (!mappingServices.containsKey(port)) {
return true;
}
return count > 0;

var mappingInfo = mappingServices.get(port);
mappingInfo.upnpService().shutdown();
mappingServices.remove(port);
return mappingInfo.somePortUnmapped().getNow(false);
}
}

0 comments on commit 1adba36

Please sign in to comment.