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}