m = (Map,?>)o;
169 | StringBuilder sb = new StringBuilder();
170 | sb.append('{');
171 | boolean first = true;
172 | for (Map.Entry,?> e : m.entrySet()) {
173 | if (!first) {
174 | sb.append(',');
175 | }
176 | sb.append(toString((String)e.getKey()));
177 | sb.append(':');
178 | sb.append(toString(e.getValue()));
179 | first = false;
180 | }
181 | sb.append('}');
182 | return sb.toString();
183 | } else if (o instanceof List) {
184 | List> l = (List>)o;
185 | StringBuilder sb = new StringBuilder();
186 | sb.append('[');
187 | boolean first = true;
188 | for (Object p : l) {
189 | if (!first) {
190 | sb.append(',');
191 | }
192 | sb.append(toString(p));
193 | first = false;
194 | }
195 | sb.append(']');
196 | return sb.toString();
197 | } else if (o instanceof Number || o instanceof Boolean) {
198 | return o.toString();
199 | } else {
200 | return o.toString(); // Any special processing would go here
201 | }
202 | }
203 |
204 | }
205 |
--------------------------------------------------------------------------------
/src/main/com/bfo/zeroconf/Zeroconf.java:
--------------------------------------------------------------------------------
1 | package com.bfo.zeroconf;
2 |
3 | import java.io.*;
4 | import java.nio.*;
5 | import java.nio.channels.*;
6 | import java.net.*;
7 | import java.util.*;
8 | import java.util.concurrent.*;
9 | import java.util.concurrent.atomic.*;
10 |
11 | /**
12 | *
13 | * This is the root class for the Service Discovery object, which can be used to announce a {@link Service},
14 | * listen for announcements or both. Typical use to announce a Sevice:
15 | *
16 | *
17 | * Zeroconf zc = new Zeroconf();
18 | * Service service = new Service.Builder().setName("MyWeb").setType("_http._tcp").setPort(8080).put("path", "/path/toservice").build(zc);
19 | * service.announce();
20 | * // time passes
21 | * service.cancel();
22 | * // time passes
23 | * zc.close();
24 | *
25 | * And to retrieve records, either add a {@link ZeroconfListener} by calling {@link #addListener},
26 | * or simply traverse the Collection returned from {@link #getServices}. Services will be added when
27 | * they are heard on the network - to ask the network to announce them, see {@link #query}.
28 | *
29 | * Zeroconf zc = new Zeroconf();
30 | * zc.addListener(new ZeroconfListener() {
31 | * public void serviceNamed(String type, String name) {
32 | * if ("_http._tcp.local".equals(type)) {
33 | * // Ask for details on any announced HTTP services
34 | * zc.query(type, name);
35 | * }
36 | * }
37 | * });
38 | * // Ask for any HTTP services
39 | * zc.query("_http._tcp.local", null);
40 | * // time passes
41 | * for (Service s : zc.getServices()) {
42 | * if (s.getType().equals("_http._tcp") {
43 | * // We've found an HTTP service
44 | * }
45 | * }
46 | * // time passes
47 | * zc.close();
48 | *
49 | *
50 | * This class does not have any fancy hooks to clean up. The {@link #close} method should be called when the
51 | * class is to be discarded, but failing to do so won't break anything. Announced services will expire in
52 | * their own time, which is typically two minutes - although during this time, conforming implementations
53 | * should refuse to republish any duplicate services.
54 | *
55 | */
56 | public class Zeroconf {
57 |
58 | private static final int PORT = 5353;
59 | private static final String DISCOVERY = "_services._dns-sd._udp.local";
60 | private static final InetSocketAddress BROADCAST4, BROADCAST6;
61 | private static final int RECOVERYTIME = 10000; // If a packet send fails in a particular NIC, how long before we retry that NIC
62 | static {
63 | try {
64 | BROADCAST4 = new InetSocketAddress(InetAddress.getByName("224.0.0.251"), PORT);
65 | BROADCAST6 = new InetSocketAddress(InetAddress.getByName("FF02::FB"), PORT);
66 | } catch (IOException e) {
67 | throw new RuntimeException(e);
68 | }
69 | }
70 |
71 | private final ListenerThread thread;
72 | private String hostname, domain;
73 | private InetAddress address;
74 | private boolean enable_ipv4 = true, enable_ipv6 = true;
75 | private final CopyOnWriteArrayList listeners;
76 | private final Map announceServices;
77 | private final Map heardServices; // keyed on FQDN and also "!" + hostname
78 | private final Collection heardServiceTypes, heardServiceNames;
79 | private final Map expiry;
80 | private final Collection nics;
81 |
82 | /**
83 | * Create a new Zeroconf object
84 | */
85 | public Zeroconf() {
86 | setDomain(".local");
87 | try {
88 | setLocalHostName(InetAddress.getLocalHost().getHostName());
89 | } catch (IOException e) {
90 | // Not worthy of an IOException
91 | }
92 | listeners = new CopyOnWriteArrayList();
93 | announceServices = new ConcurrentHashMap();
94 | heardServices = new ConcurrentHashMap();
95 | heardServiceTypes = new CopyOnWriteArraySet();
96 | heardServiceNames = new CopyOnWriteArraySet();
97 | expiry = new HashMap();
98 | thread = new ListenerThread();
99 |
100 | nics = new AbstractCollection() {
101 | private Set mynics = new HashSet();
102 | @Override public int size() {
103 | return mynics.size();
104 | }
105 | @Override public boolean add(NetworkInterface nic) {
106 | if (nic == null) {
107 | throw new IllegalArgumentException("NIC is null");
108 | }
109 | if (mynics.add(nic)) {
110 | try {
111 | thread.addNetworkInterface(nic);
112 | } catch (IOException e) {
113 | throw new RuntimeException(e);
114 | }
115 | return true;
116 | } else {
117 | return false;
118 | }
119 | }
120 | @Override public Iterator iterator() {
121 | return new Iterator() {
122 | private Iterator i = mynics.iterator();
123 | private NetworkInterface cur;
124 | @Override public boolean hasNext() {
125 | return i.hasNext();
126 | }
127 | @Override public NetworkInterface next() {
128 | return cur = i.next();
129 | }
130 | @Override public void remove() {
131 | try {
132 | thread.removeNetworkInterface(cur);
133 | i.remove();
134 | cur = null;
135 | } catch (RuntimeException e) {
136 | throw e;
137 | } catch (Exception e) {
138 | throw new RuntimeException(e);
139 | }
140 | }
141 | };
142 | }
143 | };
144 | try {
145 | for (Enumeration e = NetworkInterface.getNetworkInterfaces();e.hasMoreElements();) {
146 | nics.add(e.nextElement());
147 | }
148 | } catch (Exception e) {
149 | log("Can't add NetworkInterfaces", e);
150 | }
151 | }
152 |
153 | /**
154 | * Close down this Zeroconf object and cancel any services it has advertised.
155 | * Once closed, a Zeroconf object cannot be reopened.
156 | * @throws InterruptedException if we couldn't rejoin the listener thread
157 | */
158 | public void close() throws InterruptedException {
159 | for (Service service : announceServices.keySet()) {
160 | unannounce(service);
161 | }
162 | thread.close();
163 | }
164 |
165 | /**
166 | * Add a {@link ZeroconfListener} to the list of listeners notified of events
167 | * @param listener the listener
168 | * @return this
169 | */
170 | public Zeroconf addListener(ZeroconfListener listener) {
171 | listeners.addIfAbsent(listener);
172 | return this;
173 | }
174 |
175 | /**
176 | * Remove a previously added {@link ZeroconfListener} from the list of listeners notified of events
177 | * @param listener the listener
178 | * @return this
179 | */
180 | public Zeroconf removeListener(ZeroconfListener listener) {
181 | listeners.remove(listener);
182 | return this;
183 | }
184 |
185 | /**
186 | *
187 | * Return a modifiable Collection containing the interfaces that send and received Service Discovery Packets.
188 | * All the interface's IP addresses will be added to the {@link #getLocalAddresses} list,
189 | * and if that list changes or the interface goes up or down, the list will be updated automatically.
190 | *
191 | * The default list is everything from {@link NetworkInterface#getNetworkInterfaces}.
192 | * Interfaces that don't {@link NetworkInterface#supportsMulticast support Multicast} or that
193 | * are {@link NetworkInterface#isLoopback loopbacks} are silently ignored. Interfaces that have both
194 | * IPv4 and IPv6 addresses will listen on both protocols if possible.
195 | *
196 | * @return a modifiable collection of {@link NetworkInterface} objects
197 | */
198 | public Collection getNetworkInterfaces() {
199 | return nics;
200 | }
201 |
202 | /**
203 | * Get the Service Discovery Domain, which is set by {@link #setDomain}. It defaults to ".local",
204 | * but can be set by {@link #setDomain}
205 | * @return the domain
206 | */
207 | public String getDomain() {
208 | return domain;
209 | }
210 |
211 | /**
212 | * Set the Service Discovery Domain
213 | * @param domain the domain
214 | * @return this
215 | */
216 | public Zeroconf setDomain(String domain) {
217 | if (domain == null) {
218 | throw new NullPointerException("Domain cannot be null");
219 | }
220 | this.domain = domain;
221 | return this;
222 | }
223 |
224 | /**
225 | * Set whether announcements should be made on IPv4 addresses (default=true)
226 | * @param ipv4 whether to announce on IPv4 addresses
227 | * @return this
228 | * @since 1.0.1
229 | */
230 | public Zeroconf setIPv4(boolean ipv4) {
231 | this.enable_ipv4 = ipv4;
232 | return this;
233 | }
234 |
235 | /**
236 | * Set whether announcements should be made on IPv6 addresses (default=true)
237 | * @param ipv6 whether to announce on IPv4 addresses
238 | * @return this
239 | * @since 1.0.1
240 | */
241 | public Zeroconf setIPv6(boolean ipv6) {
242 | this.enable_ipv6 = ipv6;
243 | return this;
244 | }
245 |
246 | /**
247 | * Get the local hostname, which defaults to InetAddress.getLocalHost().getHostName()
248 | * @return the local host name
249 | */
250 | public String getLocalHostName() {
251 | if (hostname == null) {
252 | throw new IllegalStateException("Hostname cannot be determined");
253 | }
254 | return hostname;
255 | }
256 |
257 | /**
258 | * Set the local hostname, as returned by {@link #getLocalHostName}.
259 | * It should not have a dot - the fully-qualified name will be created by appending
260 | * this value to {@link #getDomain}
261 | * @param name the hostname, which should be undotted
262 | * @return this
263 | */
264 | public Zeroconf setLocalHostName(String name) {
265 | if (name == null) {
266 | throw new NullPointerException("Hostname cannot be null");
267 | }
268 | this.hostname = name;
269 | return this;
270 | }
271 |
272 | /**
273 | * Return a list of InetAddresses which the Zeroconf object considers to be "local". These
274 | * are the all the addresses of all the {@link NetworkInterface} objects added to this
275 | * object. The returned list is a copy, it can be modified and will not be updated
276 | * by this object.
277 | * @return a List of local {@link InetAddress} objects
278 | */
279 | public Collection getLocalAddresses() {
280 | return thread.getLocalAddresses().keySet();
281 | }
282 |
283 | /**
284 | * Return the list of all Services that have been {@link Service#announce announced}
285 | * by this object.
286 | * The returned Collection is read-only, thread-safe without synchronization and live - it will be updated by this object.
287 | * @return the Collection of announced Services
288 | */
289 | public Collection getAnnouncedServices() {
290 | return Collections.unmodifiableCollection(announceServices.keySet());
291 | }
292 |
293 | /**
294 | * Return the list of all Services that have been heard by this object.
295 | * It may contain Services that are also in {@link #getAnnouncedServices}
296 | * The returned Collection is read-only, thread-safe without synchronization and live - it will be updated by this object.
297 | * @return the Collection of Services
298 | */
299 | public Collection getServices() {
300 | return Collections.unmodifiableCollection(heardServices.values());
301 | }
302 |
303 | /**
304 | * Return the list of type names that have been heard by this object, eg "_http._tcp.local"
305 | * The returned Collection is read-only, thread-safe without synchronization and live - it will be updated by this object.
306 | * @return the Collection of type names.
307 | */
308 | public Collection getServiceTypes() {
309 | return Collections.unmodifiableCollection(heardServiceTypes);
310 | }
311 |
312 | /**
313 | * Return the list of fully-qualified service names that have been heard by this object
314 | * The returned Collection is read-only, thread-safe without synchronization and live - it will be updated by this object.
315 | * @return the Collection of fully-qualfied service names.
316 | */
317 | public Collection getServiceNames() {
318 | return Collections.unmodifiableCollection(heardServiceNames);
319 | }
320 |
321 | /**
322 | * Send a query to the network to probe for types or services.
323 | * Any responses will trigger changes to the list of services, and usually arrive within a second or two.
324 | * @param type the service type, eg "_http._tcp" ({@link #getDomain} will be appended if necessary), or null to query for known types
325 | * @param name the service instance name, or null to discover services of the specified type
326 | */
327 | public void query(String type, String name) {
328 | query(type, name, Record.TYPE_SRV);
329 | }
330 |
331 | void query(String type, String name, int recordType) {
332 | if (type == null) {
333 | send(new Packet(Record.newQuestion(Record.TYPE_PTR, DISCOVERY)));
334 | } else {
335 | if (type != null && type.endsWith(".")) {
336 | throw new IllegalArgumentException("Type " + Stringify.toString(type) + " should not end with a dot");
337 | }
338 | int ix = type.indexOf(".");
339 | if (ix > 0 && type.indexOf('.', ix + 1) < 0) {
340 | type += getDomain();
341 | }
342 | if (name == null) {
343 | send(new Packet(Record.newQuestion(Record.TYPE_PTR, type)));
344 | } else {
345 | StringBuilder sb = new StringBuilder();
346 | for (int i=0;i sendq;
444 | private final List channels;
445 | private final Map> localAddresses;
446 | private final Selector selector;
447 |
448 | ListenerThread() {
449 | setDaemon(true);
450 | sendq = new ArrayDeque();
451 | channels = new ArrayList();
452 | localAddresses = new HashMap>();
453 | Selector selector = null;
454 | try {
455 | selector = Selector.open();
456 | } catch (IOException e) {
457 | throw new RuntimeException(e);
458 | }
459 | this.selector = selector;
460 | }
461 |
462 | /**
463 | * Stop the thread and rejoin
464 | */
465 | synchronized void close() throws InterruptedException {
466 | if (state == STATE_RUNNING) {
467 | state = STATE_CANCELLED;
468 | selector.wakeup();
469 | join();
470 | } else {
471 | state = STATE_CANCELLED;
472 | }
473 | }
474 |
475 | /**
476 | * Add a packet to the send queue
477 | */
478 | synchronized void push(Packet packet) {
479 | sendq.addLast(packet);
480 | selector.wakeup();
481 | }
482 |
483 | /**
484 | * Pop a packet from the send queue or return null if none available
485 | */
486 | private synchronized Packet pop() {
487 | return sendq.pollFirst();
488 | }
489 |
490 | /**
491 | * Add a NetworkInterface.
492 | */
493 | void addNetworkInterface(NetworkInterface nic) throws IOException {
494 | boolean changed = false;
495 | synchronized(this) {
496 | if (!localAddresses.containsKey(nic) && nic.supportsMulticast() && !nic.isLoopback()) {
497 | localAddresses.put(nic, new ArrayList());
498 | changed = processTopologyChange(nic, false);
499 | if (changed) {
500 | if (state == STATE_NEW) {
501 | state = STATE_RUNNING;
502 | start();
503 | }
504 | selector.wakeup();
505 | }
506 | }
507 | }
508 | if (changed) {
509 | for (ZeroconfListener listener : listeners) {
510 | try {
511 | listener.topologyChange(nic);
512 | } catch (Exception e) {
513 | log("Listener exception", e);
514 | }
515 | }
516 | }
517 | }
518 |
519 | void removeNetworkInterface(NetworkInterface nic) throws IOException, InterruptedException {
520 | boolean changed = false;
521 | synchronized(this) {
522 | if (localAddresses.containsKey(nic)) {
523 | changed = processTopologyChange(nic, true);
524 | localAddresses.remove(nic);
525 | if (changed) {
526 | selector.wakeup();
527 | }
528 | }
529 | }
530 | if (changed) {
531 | for (ZeroconfListener listener : listeners) {
532 | try {
533 | listener.topologyChange(nic);
534 | } catch (Exception e) {
535 | log("Listener exception", e);
536 | }
537 | }
538 | }
539 | }
540 |
541 |
542 | private boolean processTopologyChange(NetworkInterface nic, boolean remove) throws IOException {
543 | List oldlist = localAddresses.get(nic);
544 | List newlist = new ArrayList();
545 | boolean ipv4 = false, ipv6 = false;
546 | boolean up;
547 | try {
548 | up = nic.isUp();
549 | } catch (Exception e) { // Seen this: "Device not configured (getFlags() failed).
550 | up = false;
551 | }
552 | if (up && !remove) {
553 | for (Enumeration e = nic.getInetAddresses();e.hasMoreElements();) {
554 | InetAddress a = e.nextElement();
555 | if (!a.isLoopbackAddress() && !a.isMulticastAddress()) {
556 | if (a instanceof Inet4Address && enable_ipv4) {
557 | ipv4 = true;
558 | newlist.add(a);
559 | } else if (a instanceof Inet6Address && enable_ipv6) {
560 | ipv6 = true;
561 | newlist.add(a);
562 | }
563 | }
564 | }
565 | }
566 | boolean changed = false;
567 | if (oldlist.isEmpty() && !newlist.isEmpty()) {
568 | if (ipv4) {
569 | try {
570 | DatagramChannel channel = DatagramChannel.open(StandardProtocolFamily.INET);
571 | channel.configureBlocking(false);
572 | channel.setOption(StandardSocketOptions.SO_REUSEADDR, true);
573 | channel.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 255);
574 | channel.setOption(StandardSocketOptions.IP_MULTICAST_IF, nic);
575 | channel.bind(new InetSocketAddress(PORT));
576 | channel.join(BROADCAST4.getAddress(), nic);
577 | channels.add(new NicSelectionKey(nic, BROADCAST4, channel.register(selector, SelectionKey.OP_READ, nic)));
578 | } catch (Exception e) {
579 | // Don't report, this method is called regularly and what is the user going to do about it?
580 | // e.printStackTrace();
581 | ipv4 = false;
582 | for (int i=0;i i = oldlist.iterator();i.hasNext();) {
627 | InetAddress a = i.next();
628 | if (!newlist.contains(a)) {
629 | i.remove();
630 | changed = true;
631 | }
632 | }
633 | for (Iterator i = newlist.iterator();i.hasNext();) {
634 | InetAddress a = i.next();
635 | if (!oldlist.contains(a)) {
636 | oldlist.add(a);
637 | changed = true;
638 | }
639 | }
640 | }
641 | return changed;
642 | }
643 |
644 | synchronized Map getLocalAddresses() {
645 | Map map = new HashMap();
646 | for (Map.Entry> e : localAddresses.entrySet()) {
647 | for (InetAddress address : e.getValue()) {
648 | if (!map.containsKey(address)) {
649 | map.put(address, e.getKey());
650 | }
651 | }
652 | }
653 | return map;
654 | }
655 |
656 | public void run() {
657 | ByteBuffer buf = ByteBuffer.allocate(65536);
658 | buf.order(ByteOrder.BIG_ENDIAN);
659 | try {
660 | // This bodge is to cater for the special case where someone does
661 | // Zeroconf zc = new Zeroconf();
662 | // zc.getInterfaces().clear();
663 | // We don't want to start then stop, so give it a fraction of a second.
664 | // Not the end of the world if it happens
665 | Thread.sleep(100);
666 | } catch (InterruptedException e) {}
667 | while (state == STATE_RUNNING) {
668 | ((Buffer)buf).clear();
669 | try {
670 | Packet packet = pop();
671 | if (packet != null) {
672 | // Packet to send.
673 | // * If it is a response to one we received, reply only on the NIC it was received on
674 | // * If it contains addresses that are local addresses (assigned to a NIC on this machine)
675 | // then send only those addresses that apply to the NIC we are sending on.
676 | NetworkInterface nic = packet.getNetworkInterface();
677 | Collection nics;
678 | synchronized(this) {
679 | nics = new HashSet(localAddresses.keySet());
680 | }
681 | for (NicSelectionKey nsk : channels) {
682 | if (nsk.nic.isUp() && !nsk.isDisabled()) {
683 | DatagramChannel channel = (DatagramChannel)nsk.key.channel();
684 | if (nic == null || nic.equals(nsk.nic)) {
685 | Packet dup = packet.appliedTo(nsk.nic, nics);
686 | if (dup != null) {
687 | ((Buffer)buf).clear();
688 | dup.write(buf);
689 | ((Buffer)buf).flip();
690 | try {
691 | channel.send(buf, nsk.broadcast);
692 | // System.out.println("# Sending " + dup + " to " + nsk.broadcast + " on " + nsk.nic.getName());
693 | nsk.packetSent();
694 | for (ZeroconfListener listener : listeners) {
695 | try {
696 | listener.packetSent(dup);
697 | } catch (Exception e) {
698 | log("Listener exception", e);
699 | }
700 | }
701 | } catch (SocketException e) {
702 | nsk.packetFailed(nic != null, e);
703 | }
704 | }
705 | }
706 | }
707 | }
708 | }
709 |
710 | // Could wait indefinitely, but waking every 5s isn't going to do much harm
711 | selector.select(5000);
712 | for (Iterator i=selector.selectedKeys().iterator();i.hasNext();) {
713 | SelectionKey key = i.next();
714 | i.remove();
715 | // We know selected keys are readable
716 | DatagramChannel channel = (DatagramChannel)key.channel();
717 | InetSocketAddress address = (InetSocketAddress)channel.receive(buf);
718 | if (buf.position() != 0) {
719 | ((Buffer)buf).flip();
720 | NetworkInterface nic = (NetworkInterface)key.attachment();
721 | packet = new Packet(buf, nic);
722 | // System.out.println("# RX: on " + nic.getName() + ": " + packet);
723 | processPacket(packet);
724 | }
725 | }
726 |
727 | processExpiry();
728 | List changed = null;
729 | synchronized(this) {
730 | for (NetworkInterface nic : localAddresses.keySet()) {
731 | if (processTopologyChange(nic, false)) {
732 | if (changed == null) {
733 | changed = new ArrayList();
734 | }
735 | changed.add(nic);
736 | }
737 | }
738 | }
739 | if (changed != null) { // Reannounce all services
740 | for (Service service : getAnnouncedServices()) {
741 | reannounce(service);
742 | }
743 | for (NetworkInterface nic : changed) {
744 | for (ZeroconfListener listener : listeners) {
745 | try {
746 | listener.topologyChange(nic);
747 | } catch (Exception e) {
748 | log("Listener exception", e);
749 | }
750 | }
751 | }
752 | }
753 | } catch (Exception e) {
754 | log("ListenerThread exception", e);
755 | }
756 | }
757 | }
758 | }
759 |
760 | private static class NicSelectionKey {
761 | final NetworkInterface nic;
762 | final InetSocketAddress broadcast;
763 | final SelectionKey key;
764 | private long disabledUntil;
765 | private int packetSent;
766 | NicSelectionKey(NetworkInterface nic, InetSocketAddress broadcast, SelectionKey key) {
767 | this.nic = nic;
768 | this.broadcast = broadcast;
769 | this.key = key;
770 | }
771 | // On macOS at least (as of 202502) there seems to be issues with
772 | // bad NICs - they claim to be up, but trying to call sendmsg(1)
773 | // returns ENOBUFS. We want to silently disable these, but this is
774 | // also an error that can occur under heavy load in which case we
775 | // do want to report them (not that our load should be that heavy).
776 | // Solution for now: if the first send to it fails, disable permanently
777 | // and silently if the NIC had been added automatically, otherwise
778 | // disable for RECOVERYTIME and log.
779 | //
780 | boolean isDisabled() {
781 | return System.currentTimeMillis() < disabledUntil;
782 | }
783 | void packetSent() {
784 | packetSent = Math.max(1, packetSent + 1); // Catch wraps
785 | }
786 | /**
787 | * Note that a packet has failed to send to this NIC
788 | * @param log true if we really want to log this
789 | * @param e the exception
790 | */
791 | void packetFailed(boolean log, SocketException e) {
792 | if (packetSent > 0 || log) {
793 | // We have sent to it once before, or it was added manually. Log it
794 | log("Send to \"" + nic.getName() + "\" failed with \"" + e.getMessage() + "\", disabling interface for " + (RECOVERYTIME/1000) + "s ", null);
795 | disabledUntil = System.currentTimeMillis() + RECOVERYTIME;
796 | } else {
797 | // Failed first time. Probably faulty. Disable but don't log
798 | disabledUntil = System.currentTimeMillis() + RECOVERYTIME;
799 | }
800 | }
801 | public String toString() {
802 | return "{nic:"+nic+",broadcast:"+broadcast+",key:"+key+"}";
803 | }
804 | }
805 |
806 | private void processPacket(Packet packet) {
807 | for (ZeroconfListener listener : listeners) {
808 | try {
809 | listener.packetReceived(packet);
810 | } catch (Exception e) {
811 | log("Listener exception", e);
812 | }
813 | }
814 | processQuestions(packet);
815 | Collection mod = null, add = null;
816 | // answers-ptr, additionals-ptr, answers-srv, additionals-srv, answers-other, additionals-other
817 | for (int pass=0;pass<6;pass++) {
818 | for (Record r : pass == 0 || pass == 2 || pass == 4 ? packet.getAnswers() : packet.getAdditionals()) {
819 | boolean ok = false;
820 | switch (pass) {
821 | case 0:
822 | case 1:
823 | ok = r.getType() == Record.TYPE_PTR;
824 | break;
825 | case 2:
826 | case 3:
827 | ok = r.getType() == Record.TYPE_SRV;
828 | break;
829 | default:
830 | ok = r.getType() != Record.TYPE_SRV && r.getType() != Record.TYPE_PTR;
831 | }
832 | if (ok) {
833 | for (Service service : processAnswer(r, packet, null)) {
834 | if (heardServices.putIfAbsent(service.getFQDN(), service) != null) {
835 | if (mod == null) {
836 | mod = new LinkedHashSet();
837 | }
838 | if (!mod.contains(service)) {
839 | mod.add(service);
840 | }
841 | } else {
842 | if (add == null) {
843 | add = new ArrayList();
844 | }
845 | if (!add.contains(service)) {
846 | add.add(service);
847 | }
848 | }
849 | }
850 | }
851 | }
852 | }
853 | if (mod != null) {
854 | if (add != null) {
855 | mod.removeAll(add);
856 | }
857 | for (Service service : mod) {
858 | for (ZeroconfListener listener : listeners) {
859 | try {
860 | listener.serviceModified(service);
861 | } catch (Exception e) {
862 | log("Listener exception", e);
863 | }
864 | }
865 | }
866 | }
867 | if (add != null) {
868 | for (Service service : add) {
869 | for (ZeroconfListener listener : listeners) {
870 | try {
871 | listener.serviceAnnounced(service);
872 | } catch (Exception e) {
873 | log("Listener exception", e);
874 | }
875 | }
876 | }
877 | }
878 | }
879 |
880 | private void processQuestions(Packet packet) {
881 | final NetworkInterface nic = packet.getNetworkInterface();
882 | List answers = null, additionals = null;
883 | for (Record question : packet.getQuestions()) {
884 | if (question.getName().equals(DISCOVERY) && (question.getType() == Record.TYPE_PTR || question.getType() == Record.TYPE_ANY)) {
885 | Map ttlmap = new HashMap();
886 | // When announcing service types, set the TTL to the max TTL of all the services of that type we're announcing
887 | for (Service s : announceServices.keySet()) {
888 | String type = s.getType();
889 | int ttl = s.getTTL_PTR();
890 | Integer t = ttlmap.get(type);
891 | ttlmap.put(type, Integer.valueOf(t == null ? ttl : Math.max(ttl, t.intValue())));
892 | }
893 | for (Service s : announceServices.keySet()) {
894 | if (answers == null) {
895 | answers = new ArrayList();
896 | }
897 | answers.add(Record.newPtr(ttlmap.get(s.getType()), DISCOVERY, s.getType()));
898 | }
899 | } else {
900 | for (Packet p : announceServices.values()) {
901 | for (Record answer : p.getAnswers()) {
902 | if (!question.getName().equals(answer.getName())) {
903 | continue;
904 | }
905 | if (question.getType() != answer.getType() && question.getType() != Record.TYPE_ANY) {
906 | continue;
907 | }
908 | if (answers == null) {
909 | answers = new ArrayList();
910 | }
911 | if (additionals == null) {
912 | additionals = new ArrayList();
913 | }
914 | answers.add(answer);
915 | List l = new ArrayList();
916 | l.addAll(p.getAnswers());
917 | l.addAll(p.getAdditionals());
918 | if (answer.getType() == Record.TYPE_PTR && question.getType() != Record.TYPE_ANY) {
919 | // When including a DNS-SD Service Instance Enumeration or Selective
920 | // Instance Enumeration (subtype) PTR record in a response packet, the
921 | // server/responder SHOULD include the following additional records:
922 | // * The SRV record(s) named in the PTR rdata.
923 | // * The TXT record(s) named in the PTR rdata.
924 | // * All address records (type "A" and "AAAA") named in the SRV rdata.
925 | for (Record a : l) {
926 | if (a.getType() == Record.TYPE_SRV || a.getType() == Record.TYPE_A || a.getType() == Record.TYPE_AAAA || a.getType() == Record.TYPE_TXT) {
927 | additionals.add(a);
928 | }
929 | }
930 | } else if (answer.getType() == Record.TYPE_SRV && question.getType() != Record.TYPE_ANY) {
931 | // When including an SRV record in a response packet, the
932 | // server/responder SHOULD include the following additional records:
933 | // * All address records (type "A" and "AAAA") named in the SRV rdata.
934 | for (Record a : l) {
935 | if (a.getType() == Record.TYPE_A || a.getType() == Record.TYPE_AAAA || a.getType() == Record.TYPE_TXT) {
936 | additionals.add(a);
937 | }
938 | }
939 | }
940 | }
941 | }
942 | }
943 | }
944 | if (answers != null) {
945 | Packet response = new Packet(packet, answers, additionals);
946 | send(response);
947 | }
948 | }
949 |
950 | private List processAnswer(final Record r, final Packet packet, Service service) {
951 | if (isDebug()) debug("# processAnswer(record=" + r + " s=" + service + ")");
952 | List out = null;
953 | final boolean expiring = r.getTTL() == 0; // If this record is expiring, don't change or announce stuff. https://github.com/faceless2/zeroconf/issues/13
954 | if (r.getType() == Record.TYPE_PTR && r.getName().equals(DISCOVERY)) {
955 | String type = r.getPtrValue();
956 | if (expiring || heardServiceTypes.add(type)) {
957 | if (!expiring) {
958 | for (ZeroconfListener listener : listeners) {
959 | try {
960 | listener.typeNamed(type);
961 | } catch (Exception e) {
962 | log("Listener exception", e);
963 | }
964 | }
965 | }
966 | expire(type, r.getTTL(), new Runnable() {
967 | public void run() {
968 | heardServiceTypes.remove(type);
969 | for (ZeroconfListener listener : listeners) {
970 | try {
971 | listener.typeNameExpired(type);
972 | } catch (Exception e) {
973 | log("Listener exception", e);
974 | }
975 | }
976 | }
977 | public String toString() {
978 | return "[expiring type-name \"" + type + "\"]";
979 | }
980 | });
981 | }
982 | } else if (r.getType() == Record.TYPE_PTR) {
983 | final String type = r.getName();
984 | final String fqdn = r.getPtrValue(); // Will be a service FQDN
985 | if (expiring || heardServiceTypes.add(type)) {
986 | if (!expiring) {
987 | for (ZeroconfListener listener : listeners) {
988 | try {
989 | listener.typeNamed(type);
990 | } catch (Exception e) {
991 | log("Listener exception", e);
992 | }
993 | }
994 | }
995 | expire(type, r.getTTL(), new Runnable() {
996 | public void run() {
997 | heardServiceTypes.remove(type);
998 | for (ZeroconfListener listener : listeners) {
999 | try {
1000 | listener.typeNameExpired(type);
1001 | } catch (Exception e) {
1002 | log("Listener exception", e);
1003 | }
1004 | }
1005 | }
1006 | public String toString() {
1007 | return "[expiring type-name \"" + type + "\"]";
1008 | }
1009 | });
1010 | }
1011 | if (expiring || heardServiceNames.add(fqdn)) {
1012 | if (fqdn.endsWith(type)) {
1013 | final String name = fqdn.substring(0, fqdn.length() - type.length() - 1);
1014 | if (!expiring) {
1015 | for (ZeroconfListener listener : listeners) {
1016 | try {
1017 | listener.serviceNamed(type, name);
1018 | } catch (Exception e) {
1019 | log("Listener exception", e);
1020 | }
1021 | }
1022 | }
1023 | expire(fqdn, r.getTTL(), new Runnable() {
1024 | public void run() {
1025 | heardServiceNames.remove(fqdn);
1026 | for (ZeroconfListener listener : listeners) {
1027 | try {
1028 | listener.serviceNameExpired(type, name);
1029 | } catch (Exception e) {
1030 | log("Listener exception", e);
1031 | }
1032 | }
1033 | }
1034 | public String toString() {
1035 | return "[expiring service-name \"" + name + "\"]";
1036 | }
1037 | });
1038 | } else {
1039 | for (ZeroconfListener listener : listeners) {
1040 | try {
1041 | listener.packetError(packet, "PTR name " + Stringify.toString(fqdn) + " doesn't end with type " + Stringify.toString(type));
1042 | } catch (Exception e) {
1043 | log("Listener exception", e);
1044 | }
1045 | }
1046 | service = null;
1047 | }
1048 | }
1049 | } else if (r.getType() == Record.TYPE_SRV) {
1050 | final String fqdn = r.getName();
1051 | service = heardServices.get(fqdn);
1052 | boolean modified = false;
1053 | if (service == null) {
1054 | List l = Service.splitFQDN(fqdn);
1055 | if (l != null) {
1056 | for (Service s : getAnnouncedServices()) {
1057 | if (s.getFQDN().equals(fqdn)) {
1058 | service = s;
1059 | modified = true;
1060 | break;
1061 | }
1062 | }
1063 | if (service == null && !expiring) {
1064 | service = new Service(Zeroconf.this, fqdn, l.get(0), l.get(1), l.get(2));
1065 | modified = true;
1066 | }
1067 | } else {
1068 | for (ZeroconfListener listener : listeners) {
1069 | try {
1070 | listener.packetError(packet, "Couldn't split SRV name " + Stringify.toString(fqdn));
1071 | } catch (Exception e) {
1072 | log("Listener exception", e);
1073 | }
1074 | }
1075 | }
1076 | }
1077 | if (service != null) {
1078 | final Service fservice = service;
1079 | if (getAnnouncedServices().contains(service)) {
1080 | int ttl = r.getTTL();
1081 | ttl = Math.min(ttl * 9/10, ttl - 5); // Refresh at 90% of expiry or at least 5s before
1082 | expire(service, ttl, new Runnable() {
1083 | public void run() {
1084 | if (getAnnouncedServices().contains(fservice)) {
1085 | reannounce(fservice);
1086 | }
1087 | }
1088 | public String toString() {
1089 | return "[reannouncing service " + fservice + "]";
1090 | }
1091 | });
1092 | } else {
1093 | if (!expiring && service.setHost(r.getSrvHost(), r.getSrvPort()) && !modified) {
1094 | modified = true;
1095 | }
1096 | expire(service, r.getTTL(), new Runnable() {
1097 | public void run() {
1098 | heardServices.remove(fqdn);
1099 | for (ZeroconfListener listener : listeners) {
1100 | try {
1101 | listener.serviceExpired(fservice);
1102 | } catch (Exception e) {
1103 | log("Listener exception", e);
1104 | }
1105 | }
1106 | }
1107 | public String toString() {
1108 | return "[expiring service " + fservice + "]";
1109 | }
1110 | });
1111 | if (!modified) {
1112 | service = null;
1113 | }
1114 | }
1115 | }
1116 | } else if (r.getType() == Record.TYPE_TXT) {
1117 | final String fqdn = r.getName();
1118 | if (service == null) {
1119 | service = heardServices.get(fqdn);
1120 | if (service != null) {
1121 | if (processAnswer(r, packet, service) == null) {
1122 | service = null;
1123 | }
1124 | }
1125 | } else if (fqdn.equals(service.getFQDN()) && !getAnnouncedServices().contains(service)) {
1126 | final Service fservice = service;
1127 | if (expiring || !service.setText(r.getText())) {
1128 | service = null;
1129 | }
1130 | expire("txt " + fqdn, r.getTTL(), new Runnable() {
1131 | public void run() {
1132 | if (fservice.setText(null)) {
1133 | for (ZeroconfListener listener : listeners) {
1134 | try {
1135 | listener.serviceModified(fservice);
1136 | } catch (Exception e) {
1137 | log("Listener exception", e);
1138 | }
1139 | }
1140 | }
1141 | }
1142 | public String toString() {
1143 | return "[expiring TXT for service " + fservice + "]";
1144 | }
1145 | });
1146 | }
1147 | } else if (r.getType() == Record.TYPE_A || r.getType() == Record.TYPE_AAAA) {
1148 | final String host = r.getName();
1149 | if (service == null) {
1150 | out = new ArrayList();
1151 | for (Service s : heardServices.values()) {
1152 | if (host.equals(s.getHost())) {
1153 | if (processAnswer(r, packet, s) != null) {
1154 | out.add(s);
1155 | }
1156 | }
1157 | }
1158 | } else if (host.equals(service.getHost()) && !getAnnouncedServices().contains(service)) {
1159 | final Service fservice = service;
1160 | InetAddress address = r.getAddress();
1161 | if (expiring || !service.addAddress(address, packet.getNetworkInterface())) {
1162 | service = null;
1163 | }
1164 | expire(host + " " + address, r.getTTL(), new Runnable() {
1165 | public void run() {
1166 | if (fservice.removeAddress(address)) {
1167 | for (ZeroconfListener listener : listeners) {
1168 | try {
1169 | listener.serviceModified(fservice);
1170 | } catch (Exception e) {
1171 | log("Listener exception", e);
1172 | }
1173 | }
1174 | }
1175 | }
1176 | public String toString() {
1177 | return "[expiring A " + address + " for service " + fservice + "]";
1178 | }
1179 | });
1180 | }
1181 | }
1182 | if (out == null) {
1183 | out = service == null ? Collections.emptyList() : Collections.singletonList(service);
1184 | }
1185 | return out;
1186 | }
1187 |
1188 | private void processExpiry() {
1189 | // Poor mans ScheduledExecutorQueue - we won't have many of these and we're interrupting
1190 | // regularly anyway, so expire then when we wake.
1191 | long now = System.currentTimeMillis();
1192 | for (Iterator i = expiry.values().iterator();i.hasNext();) {
1193 | ExpiryTask e = i.next();
1194 | if (now > e.expiry) {
1195 | i.remove();
1196 | if (isDebug()) System.out.println("# expiry: processing " + e.task);
1197 | e.task.run();
1198 | }
1199 | }
1200 | }
1201 |
1202 | private void expire(Object key, int ttl, Runnable task) {
1203 | ExpiryTask newtask = new ExpiryTask(System.currentTimeMillis() + ttl * 1000, task);
1204 | ExpiryTask oldtask = expiry.put(key, newtask);
1205 | if (isDebug()) {
1206 | if (oldtask != null) {
1207 | System.out.println("# expire(\"" + key + "\"): queueing " + newtask + ", replacing " + oldtask);
1208 | } else {
1209 | System.out.println("# expire(\"" + key + "\"): queueing " + newtask);
1210 | }
1211 | }
1212 | }
1213 |
1214 | private static class ExpiryTask {
1215 | final long expiry;
1216 | final Runnable task;
1217 | ExpiryTask(long expiry, Runnable task) {
1218 | this.expiry = expiry;
1219 | this.task = task;
1220 | }
1221 | public String toString() {
1222 | return (expiry - System.currentTimeMillis()) + "ms " + task;
1223 | }
1224 | }
1225 |
1226 | //-------------- Logging below here --------
1227 |
1228 | private static Boolean DEBUG;
1229 | private synchronized static boolean isDebug() {
1230 | if (DEBUG == null) {
1231 | try {
1232 | DEBUG = System.getLogger(Zeroconf.class.getName()).isLoggable(System.Logger.Level.TRACE);
1233 | } catch (Throwable ex) {
1234 | DEBUG = java.util.logging.Logger.getLogger(Zeroconf.class.getName()).isLoggable(java.util.logging.Level.FINER);
1235 | }
1236 | }
1237 | return DEBUG.booleanValue();
1238 | }
1239 |
1240 | private static void debug(String message) {
1241 | try {
1242 | System.getLogger(Zeroconf.class.getName()).log(System.Logger.Level.TRACE, message);
1243 | } catch (Throwable ex) {
1244 | java.util.logging.Logger.getLogger(Zeroconf.class.getName()).log(java.util.logging.Level.FINER, message);
1245 | }
1246 | }
1247 |
1248 | private static void log(String message, Exception e) {
1249 | try {
1250 | System.getLogger(Zeroconf.class.getName()).log(System.Logger.Level.ERROR, message, e);
1251 | } catch (Throwable ex) {
1252 | java.util.logging.Logger.getLogger(Zeroconf.class.getName()).log(java.util.logging.Level.SEVERE, message, e);
1253 | }
1254 | }
1255 |
1256 | }
1257 |
--------------------------------------------------------------------------------
/src/main/com/bfo/zeroconf/ZeroconfListener.java:
--------------------------------------------------------------------------------
1 | package com.bfo.zeroconf;
2 |
3 | import java.net.NetworkInterface;
4 |
5 | /**
6 | * An interface that can be added to the Zeroconf class to be notified about events.
7 | * All methods on the interface have a default implementation which does nothing.
8 | * @see Zeroconf#addListener
9 | * @see Zeroconf#removeListener
10 | */
11 | public interface ZeroconfListener {
12 |
13 | /**
14 | * Called with the Zeroconf class sends a packet.
15 | * @param packet the packet
16 | */
17 | public default void packetSent(Packet packet) {}
18 |
19 | /**
20 | * Called with the Zeroconf class receives a packet.
21 | * @param packet the packet
22 | */
23 | public default void packetReceived(Packet packet) {}
24 |
25 | /**
26 | * Called with the Zeroconf class receives a packet it isn't able to process.
27 | * @param packet the packet
28 | * @param message the error message
29 | */
30 | public default void packetError(Packet packet, String message) {}
31 |
32 | /**
33 | * Called when the Zeroconf class detects a network topology change on an interdace
34 | * @param nic the NIC
35 | */
36 | public default void topologyChange(NetworkInterface nic) {}
37 |
38 | /**
39 | * Called when the Zeroconf class is notified of a new service type
40 | * @param type the type, eg "_http._tcp.local"
41 | */
42 | public default void typeNamed(String type) {}
43 |
44 | /**
45 | * Called when the Zeroconf class expires a type it was previously notified about
46 | * @param type the type, eg "_http._tcp.local"
47 | */
48 | public default void typeNameExpired(String type) {}
49 |
50 | /**
51 | * Called when the Zeroconf class is notified of a new service name
52 | * @param type the type, eg "_http._tcp.local"
53 | * @param name the instance name
54 | */
55 | public default void serviceNamed(String type, String name) {}
56 |
57 | /**
58 | * Called when the Zeroconf class expires a service name it was previously notified about
59 | * @param type the type, eg "_http._tcp.local"
60 | * @param name the instance name
61 | */
62 | public default void serviceNameExpired(String type, String name) {}
63 |
64 | /**
65 | * Called when the Zeroconf class is notified of a new service
66 | * @param service the service
67 | */
68 | public default void serviceAnnounced(Service service) {}
69 |
70 | /**
71 | * Called when the Zeroconf class modifies a service that has previously been announced, perhaps to change a network address
72 | * @param service the service
73 | */
74 | public default void serviceModified(Service service) {}
75 |
76 | /**
77 | * Called when the Zeroconf class exoires a service name that was previously announced.
78 | * @param service the service
79 | */
80 | public default void serviceExpired(Service service) {}
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/src/test/com/bfo/zeroconf/Test.java:
--------------------------------------------------------------------------------
1 | package com.bfo.zeroconf;
2 |
3 | import java.time.*;
4 | import java.util.*;
5 | import java.net.*;
6 |
7 | public class Test {
8 |
9 | private final static String now() {
10 | return Instant.now().toString() + ": ";
11 | }
12 |
13 | public static void main(String[] args) throws Exception {
14 | Zeroconf zeroconf = new Zeroconf().setIPv4(true).setIPv6(true);
15 | // List all = new ArrayList(zeroconf.getNetworkInterfaces());
16 | // zeroconf.getNetworkInterfaces().clear();
17 | // zeroconf.getNetworkInterfaces().add(NetworkInterface.getByName("en0"));
18 | // zeroconf.getNetworkInterfaces().addAll(all);
19 | zeroconf.addListener(new ZeroconfListener() {
20 | @Override public void packetSent(Packet packet) {
21 | System.out.println(now() + "packetSend: " + packet);
22 | }
23 | @Override public void packetReceived(Packet packet) {
24 | System.out.println(now() + "packetReceived: " + packet);
25 | }
26 | @Override public void topologyChange(NetworkInterface nic) {
27 | System.out.println(now() + "toplogyChange " + nic);
28 | }
29 | @Override public void typeNamed(String type) {
30 | System.out.println(now() + "typeNamed: \"" + type + "\"");
31 | }
32 | @Override public void serviceNamed(String type, String name) {
33 | System.out.println(now() + "serviceNamed: \"" + type + "\", \"" + name + "\"");
34 | }
35 | @Override public void typeNameExpired(String type) {
36 | System.out.println(now() + "typeNameExpire: \"" + type + "\"");
37 | }
38 | @Override public void serviceNameExpired(String type, String name) {
39 | System.out.println(now() + "serviceNameExpire: \"" + type + "\", \"" + name + "\"");
40 | }
41 | @Override public void serviceAnnounced(Service service) {
42 | System.out.println(now() + "serviceAnnounced: " + service);
43 | }
44 | @Override public void serviceModified(Service service) {
45 | System.out.println(now() + "serviceModified: " + service);
46 | }
47 | @Override public void serviceExpired(Service service) {
48 | System.out.println(now() + "serviceExpired: " + service);
49 | }
50 | @Override public void packetError(Packet packet, String msg) {
51 | System.out.println(now() + "ERROR: " + msg + " " + packet);
52 | }
53 | });
54 |
55 | Service s = new Service.Builder().setName("Goblin").setType("_http._tcp").setPort(8080).put("this", "that").build(zeroconf);
56 | s.announce();
57 | zeroconf.query("_http._tcp.local", null);
58 |
59 | Thread.sleep(5000);
60 | System.out.println("Cancelling service");
61 | s.cancel();
62 | Thread.sleep(25000);
63 | zeroconf.close();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------