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}