diff --git a/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java index 6cbeca5456e..33f4c5d8be5 100644 --- a/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java +++ b/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java @@ -36,6 +36,7 @@ import java.util.Objects; import java.util.Set; import java.util.StringTokenizer; +import java.util.UUID; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -54,6 +55,7 @@ import org.openhab.core.config.discovery.addon.AddonFinder; import org.openhab.core.config.discovery.addon.BaseAddonFinder; import org.openhab.core.net.NetUtil; +import org.openhab.core.util.StringUtils; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; import org.osgi.service.component.annotations.Reference; @@ -100,9 +102,28 @@ * * * + * {@code listenPort} + * port to use for listening to responses (optional) + * privileged ports ({@code <1024}) not allowed + * + * * {@code request} * description of request frame as hex bytes separated by spaces (e.g. 0x01 0x02 ...) - * dynamic replacement of variables $srcIp and $srcPort, no others implemented yet + * dynamic replacement of variables $srcIp, $srcPort and $uuid, no others implemented yet + * + * + * {@code requestPlain} + * description of request frame as plaintext string + * dynamic replacement of variables $srcIp, $srcPort and $uuid, no others implemented yet; + * there are five XML special characters which need to be escaped: + * + *
{@code
+ * & - &
+ * < - <
+ * > - >
+ * " - "
+ * ' - '
+ * }
* * * {@code timeoutMs} @@ -111,7 +132,25 @@ * * *

- * Packets are sent out on ever available network interface. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
dynamic replacement (in {@code request*})value
{@code $srcIp}source IP address
{@code $srcPort}source port
{@code $uuid}String returned by {@code java.util.UUID.randomUUID()}
+ *

+ * Packets are sent out on every available network interface. *

* There is currently only one match-property defined: {@code response}. * It allows a regex match, but currently only ".*" is supported. @@ -146,10 +185,13 @@ public class IpAddonFinder extends BaseAddonFinder { private static final String MATCH_PROPERTY_RESPONSE = "response"; private static final String PARAMETER_DEST_IP = "destIp"; private static final String PARAMETER_DEST_PORT = "destPort"; + private static final String PARAMETER_LISTEN_PORT = "listenPort"; private static final String PARAMETER_REQUEST = "request"; + private static final String PARAMETER_REQUEST_PLAIN = "requestPlain"; private static final String PARAMETER_SRC_IP = "srcIp"; private static final String PARAMETER_SRC_PORT = "srcPort"; private static final String PARAMETER_TIMEOUT_MS = "timeoutMs"; + private static final String REPLACEMENT_UUID = "uuid"; private final Logger logger = LoggerFactory.getLogger(IpAddonFinder.class); private final ScheduledExecutorService scheduler = ThreadPoolManager @@ -191,6 +233,7 @@ private void startScan() { // At the same time we must make sure that a scheduled scan is rescheduled - or (after more than our delay) is // executed once more. stopScan(); + logger.trace("Scheduling new IP scan"); scanJob = scheduler.schedule(this::scan, 20, TimeUnit.SECONDS); } @@ -227,6 +270,13 @@ private void scan() { // parse standard set of parameters String type = Objects.toString(parameters.get("type"), ""); String request = Objects.toString(parameters.get(PARAMETER_REQUEST), ""); + String requestPlain = Objects.toString(parameters.get(PARAMETER_REQUEST_PLAIN), ""); + // xor + if (!("".equals(request) ^ "".equals(requestPlain))) { + logger.warn("{}: discovery-parameter '{}' or '{}' required", candidate.getUID(), PARAMETER_REQUEST, + PARAMETER_REQUEST_PLAIN); + continue; + } String response = Objects.toString(matchProperties.get(MATCH_PROPERTY_RESPONSE), ""); int timeoutMs; try { @@ -252,6 +302,22 @@ private void scan() { PARAMETER_DEST_PORT); continue; } + int listenPort = 0; // default, pick a non-privileged port + if (parameters.get(PARAMETER_LISTEN_PORT) != null) { + try { + listenPort = Integer.parseInt(Objects.toString(parameters.get(PARAMETER_LISTEN_PORT))); + } catch (NumberFormatException e) { + logger.warn("{}: discovery-parameter '{}' cannot be parsed", candidate.getUID(), + PARAMETER_LISTEN_PORT); + continue; + } + // do not allow privileged ports + if (listenPort < 1024) { + logger.warn("{}: discovery-parameter '{}' not allowed, privileged port", candidate.getUID(), + PARAMETER_LISTEN_PORT); + continue; + } + } // handle known types try { @@ -262,25 +328,33 @@ private void scan() { .map(a -> a.getAddress().getHostAddress()).toList(); for (String localIp : ipAddresses) { - try { - DatagramChannel channel = (DatagramChannel) DatagramChannel - .open(StandardProtocolFamily.INET) - .setOption(StandardSocketOptions.SO_REUSEADDR, true) - .bind(new InetSocketAddress(localIp, 0)) - .setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64) - .configureBlocking(false); - - byte[] requestArray = buildRequestArray(channel, Objects.toString(request)); + try (DatagramChannel channel = (DatagramChannel) DatagramChannel + .open(StandardProtocolFamily.INET) + .setOption(StandardSocketOptions.SO_REUSEADDR, true) + .bind(new InetSocketAddress(localIp, listenPort)) + .setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64).configureBlocking(false); + Selector selector = Selector.open()) { + byte[] requestArray = "".equals(requestPlain) + ? buildRequestArray(channel, Objects.toString(request)) + : buildRequestArrayPlain(channel, Objects.toString(requestPlain)); if (logger.isTraceEnabled()) { - logger.trace("{}: {}", candidate.getUID(), + InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress(); + String id = candidate.getUID(); + logger.trace("{}: probing {} -> {}:{}", id, localIp, + destIp != null ? destIp.getHostAddress() : "", destPort); + if (!"".equals(requestPlain)) { + logger.trace("{}: \'{}\'", id, new String(requestArray)); + } + logger.trace("{}: {}", id, HexFormat.of().withDelimiter(" ").formatHex(requestArray)); + logger.trace("{}: listening on {}:{} for {} ms", id, + sock.getAddress().getHostAddress(), sock.getPort(), timeoutMs); } channel.send(ByteBuffer.wrap(requestArray), new InetSocketAddress(destIp, destPort)); // listen to responses - Selector selector = Selector.open(); ByteBuffer buffer = ByteBuffer.wrap(new byte[50]); channel.register(selector, SelectionKey.OP_READ); selector.select(timeoutMs); @@ -296,7 +370,8 @@ private void scan() { suggestions.add(candidate); logger.debug("Suggested add-on found: {}", candidate.getUID()); } else { - logger.trace("{}: no response", candidate.getUID()); + logger.trace("{}: no response received on {}", candidate.getUID(), + localIp); } break; default: @@ -304,7 +379,6 @@ private void scan() { candidate.getUID(), type); break; // end loop } - } catch (IOException e) { logger.debug("{}: network error", candidate.getUID(), e); } @@ -322,6 +396,30 @@ private void scan() { logger.trace("IpAddonFinder::scan completed"); } + // build from plaintext string + private byte[] buildRequestArrayPlain(DatagramChannel channel, String request) + throws java.io.IOException, ParseException { + InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress(); + + // replace first + StringBuilder req = new StringBuilder(request); + int p; + while ((p = req.indexOf("$" + PARAMETER_SRC_IP)) != -1) { + req.replace(p, p + PARAMETER_SRC_IP.length() + 1, sock.getAddress().getHostAddress()); + } + while ((p = req.indexOf("$" + PARAMETER_SRC_PORT)) != -1) { + req.replace(p, p + PARAMETER_SRC_PORT.length() + 1, "" + sock.getPort()); + } + while ((p = req.indexOf("$" + REPLACEMENT_UUID)) != -1) { + req.replace(p, p + REPLACEMENT_UUID.length() + 1, UUID.randomUUID().toString()); + } + + @Nullable + String reqUnEscaped = StringUtils.unEscapeXml(req.toString()); + return reqUnEscaped != null ? reqUnEscaped.getBytes() : new byte[0]; + } + + // build from hex string private byte[] buildRequestArray(DatagramChannel channel, String request) throws java.io.IOException, ParseException { InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress(); @@ -342,6 +440,10 @@ private byte[] buildRequestArray(DatagramChannel channel, String request) requestFrame.write((byte) ((dPort >> 8) & 0xff)); requestFrame.write((byte) (dPort & 0xff)); break; + case "$" + REPLACEMENT_UUID: + String uuid = UUID.randomUUID().toString(); + requestFrame.write(uuid.getBytes()); + break; default: logger.warn("Unknown token in request frame \"{}\"", token); throw new ParseException(token, 0);