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