From 35fa524ddaa1249efae73deac5ddc05052c9b80b Mon Sep 17 00:00:00 2001 From: Richard Maw Date: Wed, 25 Dec 2024 11:56:32 +0000 Subject: [PATCH] Port UPnPUtil to jupnp 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. --- build.gradle | 9 +- .../net/rptools/maptool/util/UPnPUtil.java | 350 +++++++++--------- 2 files changed, 192 insertions(+), 167 deletions(-) diff --git a/build.gradle b/build.gradle index 10bd650044..87ced22211 100644 --- a/build.gradle +++ b/build.gradle @@ -122,6 +122,7 @@ spotless { exclude '**/JTextAreaAppender.java' exclude 'src/main/java/net/rptools/maptool/client/ui/themes/Flat*ContrastIJTheme.java' exclude 'src/main/java/net/rptools/maptool/client/ui/themes/Utils.java' + exclude 'src/main/java/net/rptools/maptool/util/upnp/PortMappingListener.java' } } } @@ -381,7 +382,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' diff --git a/src/main/java/net/rptools/maptool/util/UPnPUtil.java b/src/main/java/net/rptools/maptool/util/UPnPUtil.java index 34221c310e..a59bfce762 100644 --- a/src/main/java/net/rptools/maptool/util/UPnPUtil.java +++ b/src/main/java/net/rptools/maptool/util/UPnPUtil.java @@ -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 igds; - private static List mappings; - public static boolean findIGDs() { - igds = new HashMap(); - try { - Enumeration 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 somePortUnmapped) {} + + private static Map mappingServices = new HashMap(); + + /** + * 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(); + var somePortMapped = new CompletableFuture(); + var somePortUnmapped = new CompletableFuture(); + var listener = + new DefaultRegistryListener() { + private record MappedServiceInfo( + Service connectionService, PortMapping portMapping) {} + + private Map 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(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(); } + 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); } }