Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port UPnPUtil to use Jupnp #5124

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
Loading