]> git.basschouten.com Git - openhab-addons.git/blob
8be353bcc7412807a39e1adce6daaaef3b1002eb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.network.internal;
14
15 import java.io.IOException;
16 import java.net.Inet4Address;
17 import java.net.InetAddress;
18 import java.net.SocketException;
19 import java.net.UnknownHostException;
20 import java.util.HashSet;
21 import java.util.Set;
22 import java.util.concurrent.ExecutorService;
23 import java.util.concurrent.Executors;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.function.Consumer;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.network.internal.dhcp.DHCPListenService;
32 import org.openhab.binding.network.internal.dhcp.IPRequestReceivedCallback;
33 import org.openhab.binding.network.internal.toberemoved.cache.ExpiringCacheAsync;
34 import org.openhab.binding.network.internal.utils.NetworkUtils;
35 import org.openhab.binding.network.internal.utils.NetworkUtils.ArpPingUtilEnum;
36 import org.openhab.binding.network.internal.utils.NetworkUtils.IpPingMethodEnum;
37 import org.openhab.binding.network.internal.utils.PingResult;
38 import org.openhab.core.cache.ExpiringCache;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
41
42 /**
43  * The {@link PresenceDetection} handles the connection to the Device
44  *
45  * @author Marc Mettke - Initial contribution
46  * @author David Gräff, 2017 - Rewritten
47  * @author Jan N. Klug - refactored host name resolution
48  */
49 @NonNullByDefault
50 public class PresenceDetection implements IPRequestReceivedCallback {
51
52     public static final double NOT_REACHABLE = -1;
53     public static final int DESTINATION_TTL = 300 * 1000; // in ms, 300 s
54
55     NetworkUtils networkUtils = new NetworkUtils();
56     private final Logger logger = LoggerFactory.getLogger(PresenceDetection.class);
57
58     /// Configuration variables
59     private boolean useDHCPsniffing = false;
60     private String arpPingState = "Disabled";
61     private String ipPingState = "Disabled";
62     protected String arpPingUtilPath = "";
63     protected ArpPingUtilEnum arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
64     protected @Nullable IpPingMethodEnum pingMethod = null;
65     private boolean iosDevice;
66     private Set<Integer> tcpPorts = new HashSet<>();
67
68     private long refreshIntervalInMS = 60000;
69     private int timeoutInMS = 5000;
70     private long lastSeenInMS;
71
72     private @NonNullByDefault({}) String hostname;
73     private @NonNullByDefault({}) ExpiringCache<@Nullable InetAddress> destination;
74     private @Nullable InetAddress cachedDestination = null;
75
76     public boolean preferResponseTimeAsLatency;
77
78     /// State variables (cannot be final because of test dependency injections)
79     ExpiringCacheAsync<PresenceDetectionValue> cache;
80     private final PresenceDetectionListener updateListener;
81     private @Nullable ScheduledFuture<?> refreshJob;
82     protected @Nullable ExecutorService executorService;
83     private String dhcpState = "off";
84     Integer currentCheck = 0;
85     int detectionChecks;
86
87     public PresenceDetection(final PresenceDetectionListener updateListener, int cacheDeviceStateTimeInMS)
88             throws IllegalArgumentException {
89         this.updateListener = updateListener;
90         cache = new ExpiringCacheAsync<>(cacheDeviceStateTimeInMS, () -> {
91             performPresenceDetection(false);
92         });
93     }
94
95     public @Nullable String getHostname() {
96         return hostname;
97     }
98
99     public Set<Integer> getServicePorts() {
100         return tcpPorts;
101     }
102
103     public long getRefreshInterval() {
104         return refreshIntervalInMS;
105     }
106
107     public int getTimeout() {
108         return timeoutInMS;
109     }
110
111     public void setHostname(String hostname) {
112         this.hostname = hostname;
113         this.destination = new ExpiringCache<>(DESTINATION_TTL, () -> {
114             try {
115                 InetAddress destinationAddress = InetAddress.getByName(hostname);
116                 if (!destinationAddress.equals(cachedDestination)) {
117                     logger.trace("host name resolved to other address, (re-)setup presence detection");
118                     setUseArpPing(true, destinationAddress);
119                     if (useDHCPsniffing) {
120                         if (cachedDestination != null) {
121                             disableDHCPListen(cachedDestination);
122                         }
123                         enableDHCPListen(destinationAddress);
124                     }
125                     cachedDestination = destinationAddress;
126                 }
127                 return destinationAddress;
128             } catch (UnknownHostException e) {
129                 logger.trace("hostname resolution failed");
130                 if (cachedDestination != null) {
131                     disableDHCPListen(cachedDestination);
132                     cachedDestination = null;
133                 }
134                 return null;
135             }
136         });
137     }
138
139     public void setServicePorts(Set<Integer> ports) {
140         this.tcpPorts = ports;
141     }
142
143     public void setUseDhcpSniffing(boolean enable) {
144         this.useDHCPsniffing = enable;
145     }
146
147     public void setRefreshInterval(long refreshInterval) {
148         this.refreshIntervalInMS = refreshInterval;
149     }
150
151     public void setTimeout(int timeout) {
152         this.timeoutInMS = timeout;
153     }
154
155     public void setPreferResponseTimeAsLatency(boolean preferResponseTimeAsLatency) {
156         this.preferResponseTimeAsLatency = preferResponseTimeAsLatency;
157     }
158
159     /**
160      * Sets the ping method. This method will perform a feature test. If SYSTEM_PING
161      * does not work on this system, JAVA_PING will be used instead.
162      *
163      * @param useSystemPing Set to true to use a system ping method, false to use java ping and null to disable ICMP
164      *            pings.
165      */
166     public void setUseIcmpPing(@Nullable Boolean useSystemPing) {
167         if (useSystemPing == null) {
168             ipPingState = "Disabled";
169             pingMethod = null;
170         } else if (useSystemPing) {
171             final IpPingMethodEnum pingMethod = networkUtils.determinePingMethod();
172             this.pingMethod = pingMethod;
173             ipPingState = pingMethod == IpPingMethodEnum.JAVA_PING ? "System ping feature test failed. Using Java ping"
174                     : pingMethod.name();
175         } else {
176             pingMethod = IpPingMethodEnum.JAVA_PING;
177             ipPingState = "Java ping";
178         }
179     }
180
181     /**
182      * Enables or disables ARP pings. Will be automatically disabled if the destination
183      * is not an IPv4 address. If the feature test for the native arping utility fails,
184      * it will be disabled as well.
185      *
186      * @param enable Enable or disable ARP ping
187      * @param arpPingUtilPath c
188      */
189     private void setUseArpPing(boolean enable, @Nullable InetAddress destinationAddress) {
190         if (!enable || arpPingUtilPath.isEmpty()) {
191             arpPingState = "Disabled";
192             arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
193             return;
194         } else if (destinationAddress == null || !(destinationAddress instanceof Inet4Address)) {
195             arpPingState = "Destination is not a valid IPv4 address";
196             arpPingMethod = ArpPingUtilEnum.UNKNOWN_TOOL;
197             return;
198         }
199
200         switch (arpPingMethod) {
201             case UNKNOWN_TOOL: {
202                 arpPingState = "Unknown arping tool";
203                 break;
204             }
205             case THOMAS_HABERT_ARPING: {
206                 arpPingState = "Arping tool by Thomas Habets";
207                 break;
208             }
209             case THOMAS_HABERT_ARPING_WITHOUT_TIMEOUT: {
210                 arpPingState = "Arping tool by Thomas Habets (old version)";
211                 break;
212             }
213             case ELI_FULKERSON_ARP_PING_FOR_WINDOWS: {
214                 arpPingState = "Eli Fulkerson ARPing tool for Windows";
215                 break;
216             }
217             case IPUTILS_ARPING: {
218                 arpPingState = "Ipuitls Arping";
219                 break;
220             }
221         }
222     }
223
224     /**
225      * sets the path to arp ping
226      *
227      * @param enable Enable or disable ARP ping
228      * @param arpPingUtilPath enableDHCPListen(useDHCPsniffing);
229      */
230     public void setUseArpPing(boolean enable, String arpPingUtilPath, ArpPingUtilEnum arpPingUtilMethod) {
231         setUseArpPing(enable, destination.getValue());
232         this.arpPingUtilPath = arpPingUtilPath;
233         this.arpPingMethod = arpPingUtilMethod;
234     }
235
236     public String getArpPingState() {
237         return arpPingState;
238     }
239
240     public String getIPPingState() {
241         return ipPingState;
242     }
243
244     public String getDhcpState() {
245         return dhcpState;
246     }
247
248     /**
249      * Return true if the device presence detection is performed for an iOS device
250      * like iPhone or iPads. An additional port knock is performed before a ping.
251      */
252     public boolean isIOSdevice() {
253         return iosDevice;
254     }
255
256     /**
257      * Set to true if the device presence detection should be performed for an iOS device
258      * like iPhone or iPads. An additional port knock is performed before a ping.
259      */
260     public void setIOSDevice(boolean value) {
261         iosDevice = value;
262     }
263
264     /**
265      * Return the last seen value in milliseconds based on {@link System.currentTimeMillis()} or 0 if not seen yet.
266      */
267     public long getLastSeen() {
268         return lastSeenInMS;
269     }
270
271     /**
272      * Return asynchronously the value of the presence detection as a PresenceDetectionValue.
273      *
274      * @param callback A callback with the PresenceDetectionValue. The callback may
275      *            not happen immediately if the cached value expired, but as soon as a new
276      *            discovery took place.
277      */
278     public void getValue(Consumer<PresenceDetectionValue> callback) {
279         cache.getValue(callback);
280     }
281
282     public ExecutorService getThreadsFor(int threadCount) {
283         return Executors.newFixedThreadPool(threadCount);
284     }
285
286     /**
287      * Perform a presence detection with ICMP-, ARP ping and
288      * TCP connection attempts simultaneously. A fixed thread pool will be created with as many
289      * thread as necessary to perform all tests at once.
290      *
291      * This is a NO-OP, if there is already an ongoing detection or if the cached value
292      * is not expired yet.
293      *
294      * Please be aware of the following restrictions:
295      * - ARP pings are only executed on IPv4 addresses.
296      * - Non system / Java pings are not recommended at all
297      * (not interruptible, useless TCP echo service fall back)
298      *
299      * @param waitForDetectionToFinish If you want to synchronously wait for the result, set this to true
300      * @return Return true if a presence detection is performed and false otherwise.
301      */
302     public boolean performPresenceDetection(boolean waitForDetectionToFinish) {
303         if (executorService != null) {
304             logger.debug(
305                     "There is already an ongoing presence discovery for {} and a new one was issued by the scheduler! TCP Port {}",
306                     hostname, tcpPorts);
307             return false;
308         }
309
310         if (!cache.isExpired()) {
311             return false;
312         }
313
314         Set<String> interfaceNames = null;
315
316         currentCheck = 0;
317         detectionChecks = tcpPorts.size();
318         if (pingMethod != null) {
319             detectionChecks += 1;
320         }
321         if (arpPingMethod != ArpPingUtilEnum.UNKNOWN_TOOL) {
322             interfaceNames = networkUtils.getInterfaceNames();
323             detectionChecks += interfaceNames.size();
324         }
325
326         if (detectionChecks == 0) {
327             return false;
328         }
329
330         final ExecutorService executorService = getThreadsFor(detectionChecks);
331         this.executorService = executorService;
332
333         for (Integer tcpPort : tcpPorts) {
334             executorService.execute(() -> {
335                 Thread.currentThread().setName("presenceDetectionTCP_" + hostname + " " + String.valueOf(tcpPort));
336                 performServicePing(tcpPort);
337                 checkIfFinished();
338             });
339         }
340
341         // ARP ping for IPv4 addresses. Use single executor for Windows tool and
342         // each own executor for each network interface for other tools
343         if (arpPingMethod == ArpPingUtilEnum.ELI_FULKERSON_ARP_PING_FOR_WINDOWS) {
344             executorService.execute(() -> {
345                 Thread.currentThread().setName("presenceDetectionARP_" + hostname + " ");
346                 // arp-ping.exe tool capable of handling multiple interfaces by itself
347                 performARPping("");
348                 checkIfFinished();
349             });
350         } else if (interfaceNames != null) {
351             for (final String interfaceName : interfaceNames) {
352                 executorService.execute(() -> {
353                     Thread.currentThread().setName("presenceDetectionARP_" + hostname + " " + interfaceName);
354                     performARPping(interfaceName);
355                     checkIfFinished();
356                 });
357             }
358         }
359
360         // ICMP ping
361         if (pingMethod != null) {
362             executorService.execute(() -> {
363                 if (pingMethod != IpPingMethodEnum.JAVA_PING) {
364                     Thread.currentThread().setName("presenceDetectionICMP_" + hostname);
365                     performSystemPing();
366                 } else {
367                     performJavaPing();
368                 }
369                 checkIfFinished();
370             });
371         }
372
373         if (waitForDetectionToFinish) {
374             waitForPresenceDetection();
375         }
376
377         return true;
378     }
379
380     /**
381      * Calls updateListener.finalDetectionResult() with a final result value.
382      * Safe to be called from different threads. After a call to this method,
383      * the presence detection process is finished and all threads are forcefully
384      * shut down.
385      */
386     private synchronized void submitFinalResult() {
387         // Do nothing if we are not in a detection process
388         ExecutorService service = executorService;
389         if (service == null) {
390             return;
391         }
392         // Finish the detection process
393         service.shutdownNow();
394         executorService = null;
395         detectionChecks = 0;
396
397         PresenceDetectionValue v;
398
399         // The cache will be expired by now if cache_time < timeoutInMS. But the device might be actually reachable.
400         // Therefore use lastSeenInMS here and not cache.isExpired() to determine if we got a ping response.
401         if (lastSeenInMS + timeoutInMS + 100 < System.currentTimeMillis()) {
402             // We haven't seen the device in the detection process
403             v = new PresenceDetectionValue(hostname, -1);
404         } else {
405             // Make the cache valid again and submit the value.
406             v = cache.getExpiredValue();
407         }
408         cache.setValue(v);
409
410         if (!v.isReachable()) {
411             // if target can't be reached, check if name resolution need to be updated
412             destination.invalidateValue();
413         }
414         updateListener.finalDetectionResult(v);
415     }
416
417     /**
418      * This method is called after each individual check and increases a check counter.
419      * If the counter equals the total checks,the final result is submitted. This will
420      * happen way before the "timeoutInMS", if all checks were successful.
421      * Thread safe.
422      */
423     private synchronized void checkIfFinished() {
424         currentCheck += 1;
425         if (currentCheck < detectionChecks) {
426             return;
427         }
428         submitFinalResult();
429     }
430
431     /**
432      * Waits for the presence detection threads to finish. Returns immediately
433      * if no presence detection is performed right now.
434      */
435     public void waitForPresenceDetection() {
436         ExecutorService service = executorService;
437         if (service == null) {
438             return;
439         }
440         try {
441             // We may get interrupted here by cancelRefreshJob().
442             service.awaitTermination(timeoutInMS + 100, TimeUnit.MILLISECONDS);
443             submitFinalResult();
444         } catch (InterruptedException e) {
445             Thread.currentThread().interrupt(); // Reset interrupt flag
446             service.shutdownNow();
447             executorService = null;
448         }
449     }
450
451     /**
452      * If the cached PresenceDetectionValue has not expired yet, the cached version
453      * is returned otherwise a new reachable PresenceDetectionValue is created with
454      * a latency of 0.
455      *
456      * It is safe to call this method from multiple threads. The returned PresenceDetectionValue
457      * might be still be altered in other threads though.
458      *
459      * @param type The detection type
460      * @return The non expired or a new instance of PresenceDetectionValue.
461      */
462     synchronized PresenceDetectionValue updateReachableValue(PresenceDetectionType type, double latency) {
463         lastSeenInMS = System.currentTimeMillis();
464         PresenceDetectionValue v;
465         if (cache.isExpired()) {
466             v = new PresenceDetectionValue(hostname, 0);
467         } else {
468             v = cache.getExpiredValue();
469         }
470         v.updateLatency(latency);
471         v.addType(type);
472         cache.setValue(v);
473         return v;
474     }
475
476     protected void performServicePing(int tcpPort) {
477         logger.trace("Perform TCP presence detection for {} on port: {}", hostname, tcpPort);
478         try {
479             InetAddress destinationAddress = destination.getValue();
480             if (destinationAddress != null) {
481                 networkUtils.servicePing(destinationAddress.getHostAddress(), tcpPort, timeoutInMS).ifPresent(o -> {
482                     if (o.isSuccess()) {
483                         PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.TCP_CONNECTION,
484                                 getLatency(o, preferResponseTimeAsLatency));
485                         v.addReachableTcpService(tcpPort);
486                         updateListener.partialDetectionResult(v);
487                     }
488                 });
489             }
490         } catch (IOException e) {
491             // This should not happen and might be a user configuration issue, we log a warning message therefore.
492             logger.warn("Could not create a socket connection", e);
493         }
494     }
495
496     /**
497      * Performs an "ARP ping" (ARP request) on the given interface.
498      * If it is an iOS device, the {@see NetworkUtils.wakeUpIOS()} method is
499      * called before performing the ARP ping.
500      *
501      * @param interfaceName The interface name. You can request a list of interface names
502      *            from {@see NetworkUtils.getInterfaceNames()} for example.
503      */
504     protected void performARPping(String interfaceName) {
505         try {
506             logger.trace("Perform ARP ping presence detection for {} on interface: {}", hostname, interfaceName);
507             InetAddress destinationAddress = destination.getValue();
508             if (destinationAddress == null) {
509                 return;
510             }
511             if (iosDevice) {
512                 networkUtils.wakeUpIOS(destinationAddress);
513                 Thread.sleep(50);
514             }
515
516             networkUtils.nativeARPPing(arpPingMethod, arpPingUtilPath, interfaceName,
517                     destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
518                         if (o.isSuccess()) {
519                             PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ARP_PING,
520                                     getLatency(o, preferResponseTimeAsLatency));
521                             updateListener.partialDetectionResult(v);
522                         }
523                     });
524         } catch (IOException e) {
525             logger.trace("Failed to execute an arp ping for ip {}", hostname, e);
526         } catch (InterruptedException ignored) {
527             // This can be ignored, the thread will end anyway
528         }
529     }
530
531     /**
532      * Performs a java ping. It is not recommended to use this, as it is not interruptible,
533      * and will not work on windows systems reliably and will fall back from ICMP pings to
534      * the TCP echo service on port 7 which barely no device or server supports nowadays.
535      * (http://docs.oracle.com/javase/7/docs/api/java/net/InetAddress.html#isReachable%28int%29)
536      */
537     protected void performJavaPing() {
538         logger.trace("Perform java ping presence detection for {}", hostname);
539
540         InetAddress destinationAddress = destination.getValue();
541         if (destinationAddress == null) {
542             return;
543         }
544
545         networkUtils.javaPing(timeoutInMS, destinationAddress).ifPresent(o -> {
546             if (o.isSuccess()) {
547                 PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
548                         getLatency(o, preferResponseTimeAsLatency));
549                 updateListener.partialDetectionResult(v);
550             }
551         });
552     }
553
554     protected void performSystemPing() {
555         try {
556             logger.trace("Perform native ping presence detection for {}", hostname);
557             InetAddress destinationAddress = destination.getValue();
558             if (destinationAddress == null) {
559                 return;
560             }
561
562             networkUtils.nativePing(pingMethod, destinationAddress.getHostAddress(), timeoutInMS).ifPresent(o -> {
563                 if (o.isSuccess()) {
564                     PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.ICMP_PING,
565                             getLatency(o, preferResponseTimeAsLatency));
566                     updateListener.partialDetectionResult(v);
567                 }
568             });
569         } catch (IOException e) {
570             logger.trace("Failed to execute a native ping for ip {}", hostname, e);
571         } catch (InterruptedException e) {
572             // This can be ignored, the thread will end anyway
573         }
574     }
575
576     private double getLatency(PingResult pingResult, boolean preferResponseTimeAsLatency) {
577         logger.debug("Getting latency from ping result {} using latency mode {}", pingResult,
578                 preferResponseTimeAsLatency);
579         // Execution time is always set and this value is also the default. So lets use it first.
580         double latency = pingResult.getExecutionTimeInMS();
581
582         if (preferResponseTimeAsLatency && pingResult.getResponseTimeInMS().isPresent()) {
583             latency = pingResult.getResponseTimeInMS().get();
584         }
585
586         return latency;
587     }
588
589     @Override
590     public void dhcpRequestReceived(String ipAddress) {
591         PresenceDetectionValue v = updateReachableValue(PresenceDetectionType.DHCP_REQUEST, 0);
592         updateListener.partialDetectionResult(v);
593     }
594
595     /**
596      * Start/Restart a fixed scheduled runner to update the devices reach-ability state.
597      *
598      * @param scheduledExecutorService A scheduler to run pings periodically.
599      */
600     public void startAutomaticRefresh(ScheduledExecutorService scheduledExecutorService) {
601         ScheduledFuture<?> future = refreshJob;
602         if (future != null && !future.isDone()) {
603             future.cancel(true);
604         }
605         refreshJob = scheduledExecutorService.scheduleWithFixedDelay(() -> performPresenceDetection(true), 0,
606                 refreshIntervalInMS, TimeUnit.MILLISECONDS);
607     }
608
609     /**
610      * Return true if automatic refreshing is enabled.
611      */
612     public boolean isAutomaticRefreshing() {
613         return refreshJob != null;
614     }
615
616     /**
617      * Stop automatic refreshing.
618      */
619     public void stopAutomaticRefresh() {
620         ScheduledFuture<?> future = refreshJob;
621         if (future != null && !future.isDone()) {
622             future.cancel(true);
623             refreshJob = null;
624         }
625         if (cachedDestination != null) {
626             disableDHCPListen(cachedDestination);
627         }
628     }
629
630     /**
631      * Enables listing for dhcp packets to figure out if devices have entered the network. This does not work
632      * for iOS devices. The hostname of this network service object will be registered to the dhcp request packet
633      * listener if enabled and unregistered otherwise.
634      *
635      * @param destinationAddress the InetAddress to listen for.
636      */
637     private void enableDHCPListen(InetAddress destinationAddress) {
638         try {
639             if (DHCPListenService.register(destinationAddress.getHostAddress(), this).isUseUnprevilegedPort()) {
640                 dhcpState = "No access right for port 67. Bound to port 6767 instead. Port forwarding necessary!";
641             } else {
642                 dhcpState = "Running normally";
643             }
644         } catch (SocketException e) {
645             logger.warn("Cannot use DHCP sniffing.", e);
646             useDHCPsniffing = false;
647             dhcpState = "Cannot use DHCP sniffing: " + e.getLocalizedMessage();
648         }
649     }
650
651     private void disableDHCPListen(@Nullable InetAddress destinationAddress) {
652         if (destinationAddress != null) {
653             DHCPListenService.unregister(destinationAddress.getHostAddress());
654             dhcpState = "off";
655         }
656     }
657 }