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