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