001package jmri.util.zeroconf;
002
003import java.io.IOException;
004import java.net.IDN;
005import java.net.Inet4Address;
006import java.net.Inet6Address;
007import java.net.InetAddress;
008import java.net.NetworkInterface;
009import java.net.SocketException;
010import java.util.Collection;
011import java.util.Date;
012import java.util.Enumeration;
013import java.util.HashMap;
014import java.util.HashSet;
015import java.util.Locale;
016import java.util.Set;
017import java.util.concurrent.CountDownLatch;
018
019import javax.annotation.Nonnull;
020import javax.jmdns.JmDNS;
021import javax.jmdns.JmmDNS;
022import javax.jmdns.NetworkTopologyEvent;
023import javax.jmdns.NetworkTopologyListener;
024import javax.jmdns.ServiceInfo;
025
026import jmri.Disposable;
027import jmri.InstanceManager;
028import jmri.InstanceManagerAutoDefault;
029import jmri.ShutDownManager;
030import jmri.profile.ProfileManager;
031import jmri.util.SystemType;
032import jmri.util.node.NodeIdentity;
033import jmri.web.server.WebServerPreferences;
034
035/**
036 * A ZeroConfServiceManager object manages zeroConf network service
037 * advertisements.
038 * <p>
039 * ZeroConfService objects encapsulate zeroConf network services created using
040 * JmDNS, providing methods to start and stop service advertisements and to
041 * query service state. Typical usage would be:
042 * <pre>
043 * ZeroConfService myService = ZeroConfService.create("_withrottle._tcp.local.", port);
044 * myService.publish();
045 * </pre> or, if you do not wish to retain the ZeroConfService object:
046 * <pre>
047 * ZeroConfService.create("_http._tcp.local.", port).publish();
048 * </pre> ZeroConfService objects can also be created with a HashMap of
049 * properties that are included in the TXT record for the service advertisement.
050 * This HashMap should remain small, but it could include information such as
051 * the default path (for a web server), a specific protocol version, or other
052 * information. Note that all service advertisements include the JMRI version,
053 * using the key "version", and the JMRI version numbers in a string
054 * "major.minor.test" with the key "jmri"
055 * <p>
056 * All ZeroConfServices are published with the computer's hostname as the mDNS
057 * hostname (unless it cannot be determined by JMRI), as well as the JMRI node
058 * name in the TXT record with the key "node".
059 * <p>
060 * All ZeroConfServices are automatically stopped when the JMRI application
061 * shuts down. Use {@link #allServices() } to get a collection of all published
062 * ZeroConfService objects.
063 * <hr>
064 * This file is part of JMRI.
065 * <p>
066 * JMRI is free software; you can redistribute it and/or modify it under the
067 * terms of version 2 of the GNU General Public License as published by the Free
068 * Software Foundation. See the "COPYING" file for a copy of this license.
069 * <p>
070 * JMRI is distributed in the hope that it will be useful, but WITHOUT ANY
071 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
072 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
073 *
074 * @author Randall Wood Copyright (C) 2011, 2013, 2018
075 * @see javax.jmdns.JmDNS
076 * @see javax.jmdns.ServiceInfo
077 */
078public class ZeroConfServiceManager implements InstanceManagerAutoDefault, Disposable {
079
080    public enum Protocol {
081        IPv4, IPv6, All
082    }
083    // static data objects
084    /**
085     * There can only be <strong>one</strong> {@link javax.jmdns.JmDNS} object
086     * per {@link java.net.InetAddress} per JVM, so this collection of JmDNS
087     * objects is static. All access <strong>must</strong> be through
088     * {@link #getDNSes() } to ensure this is populated correctly.
089     */
090    static final HashMap<InetAddress, JmDNS> JMDNS_SERVICES = new HashMap<>();
091
092    // class data objects
093    protected final HashMap<String, ZeroConfService> services = new HashMap<>();
094    protected final NetworkListener networkListener = new NetworkListener(this);
095    protected final Runnable shutDownTask = () -> dispose(this);
096
097    public static final String DNS_CLOSE_THREAD_NAME = "dns.close in ZerConfServiceManager#stopAll";
098
099    protected final ZeroConfPreferences preferences = new ZeroConfPreferences(ProfileManager.getDefault().getActiveProfile());
100
101    /**
102     * Create a ZeroConfService with the minimal required settings. This method
103     * calls {@link #create(java.lang.String, int, java.util.HashMap)} with an
104     * empty props HashMap.
105     *
106     * @param type The service protocol
107     * @param port The port the service runs over
108     * @return A new unpublished ZeroConfService, or an existing service
109     * @see #create(java.lang.String, java.lang.String, int, int, int,
110     * java.util.HashMap)
111     */
112    public ZeroConfService create(String type, int port) {
113        return create(type, port, new HashMap<>());
114    }
115
116    /**
117     * Create a ZeroConfService with an automatically detected server name. This
118     * method calls
119     * {@link #create(java.lang.String, java.lang.String, int, int, int, java.util.HashMap)}
120     * with the default weight and priority, and with the result of
121     * {@link jmri.web.server.WebServerPreferences#getRailroadName()}
122     * reformatted to replace dots and dashes with spaces.
123     *
124     * @param type       The service protocol
125     * @param port       The port the service runs over
126     * @param properties Additional information to be listed in service
127     *                   advertisement
128     * @return A new unpublished ZeroConfService, or an existing service
129     */
130    public ZeroConfService create(String type, int port, HashMap<String, String> properties) {
131        return create(type, InstanceManager.getDefault(WebServerPreferences.class).getRailroadName(), port, 0, 0, properties);
132    }
133
134    /**
135     * Create a ZeroConfService. The property <i>version</i> is added or
136     * replaced with the current JMRI version as its value. The property
137     * <i>jmri</i> is added or replaced with the JMRI major.minor.test version
138     * string as its value.
139     * <p>
140     * If a service with the same getKey as the new service is already
141     * published, the original service is returned unmodified.
142     *
143     * @param type       The service protocol
144     * @param name       The name of the JMRI server listed on client devices
145     * @param port       The port the service runs over
146     * @param weight     Default value is 0
147     * @param priority   Default value is 0
148     * @param properties Additional information to be listed in service
149     *                   advertisement
150     * @return A new unpublished ZeroConfService, or an existing service
151     */
152    public ZeroConfService create(String type, String name, int port, int weight, int priority, HashMap<String, String> properties) {
153        ZeroConfService s;
154        String key = key(type, name);
155        if (services.containsKey(key)) {
156            s = services.get(key);
157            log.debug("Using existing ZeroConfService {}", s.getKey());
158        } else {
159            properties.put("version", jmri.Version.name());
160            // use the major.minor.test version string for jmri since we have potentially
161            // tight space constraints in terms of the number of bytes that properties
162            // can use, and there are some unconstrained properties that we would like to use.
163            properties.put("jmri", jmri.Version.getCanonicalVersion());
164            properties.put("node", NodeIdentity.networkIdentity());
165            s = new ZeroConfService(ServiceInfo.create(type, name, port, weight, priority, properties));
166            log.debug("Creating new ZeroConfService {} with properties {}", s.getKey(), properties);
167        }
168        return s;
169    }
170
171    /**
172     * Generate a ZeroConfService getKey for searching in the HashMap of running
173     * services.
174     *
175     * @param type the service type (usually a protocol name or mapping)
176     * @param name the service name (usually the JMRI railroad name or system
177     *             host name)
178     * @return The combination of the name and type of the service.
179     */
180    protected String key(String type, String name) {
181        return (name + "." + type).toLowerCase();
182    }
183
184    /**
185     * Start advertising the service.
186     *
187     * @param service The service to publish
188     */
189    public void publish(ZeroConfService service) {
190        if (!isPublished(service)) {
191            //get current preference values
192            services.put(service.getKey(), service);
193            service.getListeners().stream().forEach((listener) -> {
194                listener.serviceQueued(new ZeroConfServiceEvent(service, null));
195            });
196            for (JmDNS dns : getDNSes().values()) {
197                ZeroConfServiceEvent event;
198                ServiceInfo info;
199                try {
200                    final InetAddress address = dns.getInetAddress();
201                    if (address instanceof Inet6Address && !preferences.isUseIPv6()) {
202                        // Skip if address is IPv6 and should not be advertised on
203                        log.debug("Ignoring IPv6 address {}", address.getHostAddress());
204                        continue;
205                    }
206                    if (address instanceof Inet4Address && !preferences.isUseIPv4()) {
207                        // Skip if address is IPv4 and should not be advertised on
208                        log.debug("Ignoring IPv4 address {}", address.getHostAddress());
209                        continue;
210                    }
211                    if (address.isLinkLocalAddress() && !preferences.isUseLinkLocal()) {
212                        // Skip if address is LinkLocal and should not be advertised on
213                        log.debug("Ignoring link-local address {}", address.getHostAddress());
214                        continue;
215                    }
216                    if (address.isLoopbackAddress() && !preferences.isUseLoopback()) {
217                        // Skip if address is loopback and should not be advertised on
218                        log.debug("Ignoring loopback address {}", address.getHostAddress());
219                        continue;
220                    }
221                    log.debug("Publishing ZeroConfService for '{}' on {}", service.getKey(), address.getHostAddress());
222                    // JmDNS requires a 1-to-1 mapping of getServiceInfo to InetAddress
223                    if (!service.containsServiceInfo(address)) {
224                        try {
225                            info = service.addServiceInfo(address);
226                            dns.registerService(info);
227                            log.debug("Register service '{}' on {} successful.", service.getKey(), address.getHostAddress());
228                        } catch (IllegalStateException ex) {
229                            // thrown if the reference getServiceInfo object is in use
230                            try {
231                                log.debug("Initial attempt to register '{}' on {} failed.", service.getKey(), address.getHostAddress());
232                                info = service.addServiceInfo(address);
233                                log.debug("Retrying register '{}' on {}.", service.getKey(), address.getHostAddress());
234                                dns.registerService(info);
235                            } catch (IllegalStateException ex1) {
236                                // thrown if service gets registered on interface by
237                                // the networkListener before this loop on interfaces
238                                // completes, so we only ensure a later notification
239                                // is not posted continuing to next interface in list
240                                log.debug("'{}' is already registered on {}.", service.getKey(), address.getHostAddress());
241                                continue;
242                            }
243                        }
244                    } else {
245                        log.debug("skipping '{}' on {}, already in serviceInfos.", service.getKey(), address.getHostAddress());
246                    }
247                    event = new ZeroConfServiceEvent(service, dns);
248                } catch (IOException ex) {
249                    log.error("Unable to publish service for '{}': {}", service.getKey(), ex.getMessage());
250                    continue;
251                }
252                service.getListeners().stream().forEach((listener) -> {
253                    listener.servicePublished(event);
254                });
255            }
256        }
257    }
258
259    /**
260     * Stop advertising the service.
261     *
262     * @param service The service to stop advertising
263     */
264    public void stop(ZeroConfService service) {
265        log.debug("Stopping ZeroConfService {}", service.getKey());
266        if (services.containsKey(service.getKey())) {
267            getDNSes().values().parallelStream().forEach((dns) -> {
268                try {
269                    final InetAddress address = dns.getInetAddress();
270                    try {
271                        log.debug("Unregistering {} from {}", service.getKey(), address);
272                        dns.unregisterService(service.getServiceInfo(address));
273                        service.removeServiceInfo(address);
274                        service.getListeners().stream().forEach((listener) -> {
275                            listener.serviceUnpublished(new ZeroConfServiceEvent(service, dns));
276                        });
277                    } catch (NullPointerException ex) {
278                        log.debug("{} already unregistered from {}", service.getKey(), address);
279                    }
280                } catch (IOException ex) {
281                    log.error("Unable to stop ZeroConfService {}. {}", service.getKey(), ex.getLocalizedMessage());
282                }
283            });
284            services.remove(service.getKey());
285        }
286    }
287
288    /**
289     * Stop advertising all services.
290     */
291    public void stopAll() {
292        stopAll(false);
293    }
294
295    private void stopAll(final boolean close) {
296        log.debug("Stopping all ZeroConfServices");
297        CountDownLatch zcLatch = new CountDownLatch(services.size());
298        new HashMap<>(services).values().parallelStream().forEach(service -> {
299            stop(service);
300            zcLatch.countDown();
301        });
302        try {
303            zcLatch.await();
304        } catch (InterruptedException ex) {
305            log.warn("ZeroConfService stop threads interrupted.", ex);
306        }
307        CountDownLatch nsLatch = new CountDownLatch(getDNSes().size());
308        new HashMap<>(getDNSes()).values().parallelStream().forEach(dns -> {
309            Thread t = new Thread(() -> {
310                dns.unregisterAllServices();
311                if (close) {
312                    try {
313                        dns.close();
314                    } catch (IOException ex) {
315                        log.debug("jmdns.close() returned IOException: {}", ex.getMessage());
316                    }
317                }
318                nsLatch.countDown();
319            });
320            t.setName(DNS_CLOSE_THREAD_NAME);
321            t.start();
322        });
323        try {
324            zcLatch.await();
325        } catch (InterruptedException ex) {
326            log.warn("JmDNS unregister threads interrupted.", ex);
327        }
328        services.clear();
329    }
330
331    /**
332     * A list of published ZeroConfServices
333     *
334     * @return Collection of ZeroConfServices
335     */
336    public Collection<ZeroConfService> allServices() {
337        return services.values();
338    }
339
340    /**
341     * The list of JmDNS handlers. This is package private.
342     *
343     * @return a {@link java.util.HashMap} of {@link javax.jmdns.JmDNS} objects,
344     *         accessible by {@link java.net.InetAddress} keys.
345     */
346    synchronized HashMap<InetAddress, JmDNS> getDNSes() {
347        if (JMDNS_SERVICES.isEmpty()) {
348            log.debug("JmDNS version: {}", JmDNS.VERSION);
349            String name = hostName(NodeIdentity.networkIdentity());
350            try {
351                Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces();
352                while (nis.hasMoreElements()) {
353                    NetworkInterface ni = nis.nextElement();
354                    try {
355                        if (ni.isUp()) {
356                            Enumeration<InetAddress> niAddresses = ni.getInetAddresses();
357                            while (niAddresses.hasMoreElements()) {
358                                InetAddress address = niAddresses.nextElement();
359                                // explicitly pass a valid host name, since null causes a very long lookup on some networks
360                                log.debug("Calling JmDNS.create({}, '{}')", address.getHostAddress(), name);
361                                try {
362                                    JMDNS_SERVICES.put(address, JmDNS.create(address, name));
363                                } catch (IOException ex) {
364                                    log.warn("Unable to create JmDNS with error", ex);
365                                }
366                            }
367                        }
368                    } catch (SocketException ex) {
369                        log.error("Unable to read network interface {}.", ni, ex);
370                    }
371                }
372            } catch (SocketException ex) {
373                log.error("Unable to get network interfaces.", ex);
374            }
375            if (!SystemType.isMacOSX()) {
376                JmmDNS.Factory.getInstance().addNetworkTopologyListener(networkListener);
377            }
378            InstanceManager.getDefault(ShutDownManager.class).register(shutDownTask);
379        }
380        return new HashMap<>(JMDNS_SERVICES);
381    }
382
383    /**
384     * Get all addresses that JmDNS instances can be created for excluding
385     * loopback addresses.
386     *
387     * @return the addresses
388     * @see #getAddresses(jmri.util.zeroconf.ZeroConfServiceManager.Protocol)
389     * @see #getAddresses(jmri.util.zeroconf.ZeroConfServiceManager.Protocol,
390     * boolean, boolean)
391     */
392    @Nonnull
393    public Set<InetAddress> getAddresses() {
394        return getAddresses(Protocol.All);
395    }
396
397    /**
398     * Get all addresses that JmDNS instances can be created for excluding
399     * loopback addresses.
400     *
401     * @param protocol the Internet protocol
402     * @return the addresses
403     * @see #getAddresses()
404     * @see #getAddresses(jmri.util.zeroconf.ZeroConfServiceManager.Protocol,
405     * boolean, boolean)
406     */
407    @Nonnull
408    public Set<InetAddress> getAddresses(Protocol protocol) {
409        return getAddresses(protocol, true, false);
410    }
411
412    /**
413     * Get all addresses of a specific IP protocol that JmDNS instances can be
414     * created for.
415     *
416     * @param protocol     the IP protocol addresses to return
417     * @param useLinkLocal true to include link-local addresses; false otherwise
418     * @param useLoopback  true to include loopback addresses; false otherwise
419     * @return the addresses
420     * @see #getAddresses()
421     * @see #getAddresses(jmri.util.zeroconf.ZeroConfServiceManager.Protocol)
422     */
423    @Nonnull
424    public Set<InetAddress> getAddresses(Protocol protocol, boolean useLinkLocal, boolean useLoopback) {
425        Set<InetAddress> set = new HashSet<>();
426        if (protocol == Protocol.All) {
427            set.addAll(getDNSes().keySet());
428        } else {
429            getDNSes().keySet().forEach((address) -> {
430                if (address instanceof Inet4Address && protocol == Protocol.IPv4) {
431                    set.add(address);
432                }
433                if (address instanceof Inet6Address && protocol == Protocol.IPv6) {
434                    set.add(address);
435                }
436            });
437        }
438        if (!useLinkLocal || !useLoopback) {
439            new HashSet<>(set).forEach((address) -> {
440                if ((address.isLinkLocalAddress() && !useLinkLocal)
441                        || (address.isLoopbackAddress() && !useLoopback)) {
442                    set.remove(address);
443                }
444            });
445        }
446        return set;
447    }
448
449    /**
450     * Return an RFC 1123 compliant host name in all lower-case punycode from a
451     * given string.
452     * <p>
453     * RFC 1123 mandates that host names contain only the ASCII characters a-z, digits,
454     * minus signs ("-") and that the host name be not longer than 63 characters.
455     * <p>
456     * Punycode converts non-ASCII characters into an ASCII encoding per RFC 3492, so
457     * this method repeatedly converts the name into punycode, shortening the name, until
458     * the punycode converted name is 63 characters or less in length.
459     * <p>
460     * If the input string cannot be converted to puny code, or is an empty string,
461     * the input is replaced with {@link jmri.util.node.NodeIdentity#networkIdentity()}.
462     * <p>
463     * The algorithm for converting the input is:
464     * <ol>
465     * <li>Convert to lower case using the {@link java.util.Locale#ROOT} locale.</li>
466     * <li>Remove any leading whitespace, dots ("."), underscores ("_"), and minus signs ("-")</li>
467     * <li>Truncate to 63 characters if necessary</li>
468     * <li>Convert whitespace, dots ("."), and underscores ("_") to minus signs ("-")</li>
469     * <li>Repeatedly convert to punycode, removing the last character as needed until
470     * the punycode is 63 characters or less</li>
471     * <li>Repeat process with NodeIdentity#networkIdentity() as input if above never
472     * yields a usable host name</li>
473     * </ol>
474     *
475     * @param string String to convert to host name
476     * @return An RFC 1123 compliant host name
477     */
478    @Nonnull
479    public static String hostName(@Nonnull String string) {
480        String puny = null;
481        String name = string.toLowerCase(Locale.ROOT);
482        name = name.replaceFirst("^[_\\.\\s]+", "");
483        if (string.isEmpty()) {
484            name = NodeIdentity.networkIdentity();
485        }
486        if (name.length() > 63) {
487            name = name.substring(0, 63);
488        }
489        name = name.replaceAll("[_\\.\\s]", "-");
490        while (puny == null || puny.length() > 63) {
491            log.debug("name is \"{}\" prior to conversion", name);
492            try {
493                puny = IDN.toASCII(name, IDN.ALLOW_UNASSIGNED);
494                if (puny.isEmpty()) {
495                    name = NodeIdentity.networkIdentity();
496                    puny = null;
497                }
498            } catch (IllegalArgumentException ex) {
499                puny = null;
500            }
501            if (name.length() > 1) {
502                name = name.substring(0, name.length() - 2);
503            } else {
504                name = NodeIdentity.networkIdentity();
505            }
506        }
507        return puny;
508    }
509
510    /**
511     * Return the system name or "computer" if the system name cannot be
512     * determined. This method returns the first part of the fully qualified
513     * domain name from {@link #FQDN}.
514     *
515     * @param address The {@link java.net.InetAddress} for the host name.
516     * @return The hostName associated with the first interface encountered.
517     */
518    public String hostName(InetAddress address) {
519        String hostName = FQDN(address) + ".";
520        // we would have to check for the existence of . if we did not add .
521        // to the string above.
522        return hostName.substring(0, hostName.indexOf('.'));
523    }
524
525    /**
526     * Return the fully qualified domain name or "computer" if the system name
527     * cannot be determined. This method uses the
528     * {@link javax.jmdns.JmDNS#getHostName()} method to get the name.
529     *
530     * @param address The {@link java.net.InetAddress} for the FQDN.
531     * @return The fully qualified domain name.
532     */
533    public String FQDN(InetAddress address) {
534        return getDNSes().get(address).getHostName();
535    }
536
537    public ZeroConfPreferences getPreferences() {
538        return preferences;
539    }
540
541    public boolean isPublished(ZeroConfService service) {
542        return services.containsKey(service.getKey());
543    }
544
545    @Override
546    public void dispose() {
547        dispose(this);
548        InstanceManager.getDefault(ShutDownManager.class).deregister(shutDownTask);
549    }
550
551    private static void dispose(ZeroConfServiceManager manager) {
552        Date start = new Date();
553        if (!SystemType.isMacOSX()) {
554            JmmDNS.Factory.getInstance().removeNetworkTopologyListener(manager.networkListener);
555            log.debug("Removed network topology listener in {} milliseconds", new Date().getTime() - start.getTime());
556        }
557        start = new Date();
558        log.debug("Starting to stop services...");
559        manager.stopAll(true);
560        log.debug("Stopped all services in {} milliseconds", new Date().getTime() - start.getTime());
561    }
562
563    protected static class NetworkListener implements NetworkTopologyListener {
564
565        private final ZeroConfServiceManager manager;
566
567        public NetworkListener(ZeroConfServiceManager manager) {
568            this.manager = manager;
569        }
570
571        @Override
572        public void inetAddressAdded(NetworkTopologyEvent nte) {
573            //get current preference values
574            final InetAddress address = nte.getInetAddress();
575            if (address instanceof Inet6Address && !manager.preferences.isUseIPv6()) {
576                log.debug("Ignoring IPv6 address {}", address.getHostAddress());
577            } else if (address instanceof Inet4Address && !manager.preferences.isUseIPv4()) {
578                log.debug("Ignoring IPv4 address {}", address.getHostAddress());
579            } else if (address.isLinkLocalAddress() && !manager.preferences.isUseLinkLocal()) {
580                log.debug("Ignoring link-local address {}", address.getHostAddress());
581            } else if (address.isLoopbackAddress() && !manager.preferences.isUseLoopback()) {
582                log.debug("Ignoring loopback address {}", address.getHostAddress());
583            } else if (!JMDNS_SERVICES.containsKey(address)) {
584                log.debug("Adding address {}", address.getHostAddress());
585                JmDNS dns = nte.getDNS();
586                JMDNS_SERVICES.put(address, dns);
587                manager.allServices().stream().forEach((service) -> {
588                    try {
589                        if (!service.containsServiceInfo(address)) {
590                            log.debug("Publishing zeroConf service for '{}' on {}", service.getKey(), address.getHostAddress());
591                            dns.registerService(service.addServiceInfo(address));
592                            service.getListeners().stream().forEach((listener) -> {
593                                listener.servicePublished(new ZeroConfServiceEvent(service, dns));
594                            });
595                        }
596                    } catch (IOException ex) {
597                        log.error("IOException adding address {}",address, ex);
598                    }
599                });
600            } else {
601                log.debug("Address {} already known.", address.getHostAddress());
602            }
603        }
604
605        @Override
606        public void inetAddressRemoved(NetworkTopologyEvent nte) {
607            final InetAddress address = nte.getInetAddress();
608            JmDNS dns = nte.getDNS();
609            log.debug("Removing address {}", address);
610            JMDNS_SERVICES.remove(address);
611            dns.unregisterAllServices();
612            manager.allServices().stream().forEach((service) -> {
613                service.removeServiceInfo(address);
614                service.getListeners().stream().forEach((listener) -> {
615                    listener.servicePublished(new ZeroConfServiceEvent(service, dns));
616                });
617            });
618        }
619
620    }
621
622    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ZeroConfServiceManager.class);
623
624}