├── LICENSE ├── README.md ├── build.properties ├── build.xml └── src ├── main └── com │ └── bfo │ └── zeroconf │ ├── Packet.java │ ├── Record.java │ ├── Service.java │ ├── Stringify.java │ ├── Zeroconf.java │ └── ZeroconfListener.java └── test └── com └── bfo └── zeroconf └── Test.java /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 faceless2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zeroconf 2 | 3 | Zeroconf is a simple Java implementation of Multicast DNS Service Discovery, _aka_ the service discovery bit of Zeroconf. 4 | Originally written as a quick hack to avoid having to use [https://github.com/jmdns/jmdns](https://github.com/jmdns/jmdns), it has evolved into something 5 | that can both announce and listen for Services: 6 | 7 | * Listens on multiple interfaces (IPv4 and IPv6) 8 | * Network topology changes are handled transparently. 9 | * Sent packets include only the A and AAAA records that apply to the interface they're sent on 10 | * Requires Java 8+ and no other dependencies. 11 | * Javadocs at [https://faceless2.github.io/zeroconf/docs](https://faceless2.github.io/zeroconf/docs/) 12 | * Prebuilt binary at [https://faceless2.github.io/zeroconf/dist/zeroconf-1.0.2.jar](https://faceless2.github.io/zeroconf/dist/zeroconf-1.0.2.jar) 13 | 14 | Here's a simple example which announces a service on all interfaces on the local machine: 15 | 16 | ```java 17 | import com.bfo.zeroconf.*; 18 | 19 | Zeroconf zc = new Zeroconf(); 20 | Service service = new Service.Builder() 21 | .setName("MyWeb") 22 | .setType("_http._tcp") 23 | .setPort(8080) 24 | .put("path", "/path/to/service") 25 | .build(zc); 26 | service.announce(); 27 | // time passes 28 | service.cancel(); 29 | // time passes 30 | zc.close(); 31 | ``` 32 | 33 | To set custom TTLs for each mDNS record type: 34 | 35 | ```java 36 | import com.bfo.zeroconf.*; 37 | 38 | Zeroconf zc = new Zeroconf(); 39 | Service.Builder builder = new Service.Builder() 40 | .setName("MyWeb") 41 | .setType("_http._tcp") 42 | .setPort(8080) 43 | .put("path", "/path/to/service"); 44 | 45 | // Custom TTLs for each mDNS record type. 46 | int ttl = 120; 47 | builder.setTTL_PTR(ttl); 48 | builder.setTTL_SRV(ttl); 49 | builder.setTTL_TXT(ttl); 50 | builder.setTTL_A(ttl); 51 | builder.build(zc); 52 | 53 | // Announce the service. 54 | service.announce(); 55 | ``` 56 | 57 | And to listen, either add a Listener for events or use the live, thread-safe Collection of Services. 58 | 59 | ```java 60 | import com.bfo.zeroconf.*; 61 | 62 | Zeroconf zc = new Zeroconf(); 63 | zc.addListener(new ZeroconfListener() { 64 | public void serviceNamed(String type, String name) { 65 | if ("_http._tcp".equals(type)) { 66 | zc.query(type, name); // Ask for details on any announced HTTP services 67 | } 68 | } 69 | public void serviceAnnounced(Service service) { 70 | // A new service has just been announced 71 | } 72 | }); 73 | 74 | zc.query("_http._tcp", null); // Ask for any HTTP services 75 | 76 | // time passes 77 | for (Service s : zc.getServices()) { 78 | if (s.getType().equals("_http._tcp") { 79 | // A service has been announced at some point in the past, and has not yet expired. 80 | } 81 | } 82 | 83 | // time passes 84 | zc.close(); 85 | ``` 86 | 87 | To disable IPv6 support, and to only listen on a single network interface with a given IP address, use: 88 | 89 | ```java 90 | Zeroconf zc = new Zeroconf(); 91 | zc.setIPv6(false); 92 | zc.setLocalHostName(ip); 93 | 94 | // Add the single NIC with the given IP. 95 | NetworkInterface nic = NetworkUtils.findIPv4NICForIP(ip); 96 | if (nic != null) { 97 | // Remove all configured NICs. 98 | zc.getNetworkInterfaces().clear(); 99 | 100 | // Add the IPv4 NIC. 101 | zc.getNetworkInterfaces().add(nic); 102 | } 103 | ``` 104 | 105 | To build 106 | -- 107 | 108 | Run `ant` to build the Jar in the `build` directory, and javadoc in the `doc` directory. 109 | -------------------------------------------------------------------------------- /build.properties: -------------------------------------------------------------------------------- 1 | project=zeroconf 2 | version=1.0.2 3 | home=https://github.com/faceless2/zeroconf 4 | -------------------------------------------------------------------------------- /build.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 79 |
80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
97 | -------------------------------------------------------------------------------- /src/main/com/bfo/zeroconf/Packet.java: -------------------------------------------------------------------------------- 1 | package com.bfo.zeroconf; 2 | 3 | import java.net.*; 4 | import java.util.*; 5 | import java.nio.*; 6 | 7 | /** 8 | * A Service Dicovery Packet. This class is only of interest to developers, 9 | */ 10 | public class Packet { 11 | 12 | private final int id; 13 | private final int flags; 14 | private final long timestamp; 15 | private final List questions, answers, authorities, additionals; 16 | private final NetworkInterface nic; 17 | 18 | private static final int FLAG_RESPONSE = 15; 19 | private static final int FLAG_AA = 10; 20 | 21 | /** 22 | * Create a Packet from its String representation 23 | * @param tostring the packets string-format, in the same format as {@link #toString} 24 | * @throws IllegalArgumentException if the format is incorrec5 25 | * @since 1.0.1 26 | */ 27 | @SuppressWarnings("unchecked") public Packet(String tostring) { 28 | Map map = (Map)Stringify.parse(tostring); 29 | this.id = ((Integer)map.get("id")).intValue(); 30 | this.flags = ((Integer)map.get("flags")).intValue(); 31 | this.timestamp = ((Number)map.get("timestamp")).longValue(); 32 | NetworkInterface nic = null; 33 | if (map.get("nic") instanceof String) { 34 | try { 35 | nic = NetworkInterface.getByName((String)map.get("nic")); 36 | } catch (Exception e) { } 37 | } 38 | this.nic = nic; 39 | if (map.get("questions") instanceof List) { 40 | List l = new ArrayList(); 41 | for (Object o : (List)map.get("questions")) { 42 | l.add(Record.parse((Map)o)); 43 | } 44 | this.questions = Collections.unmodifiableList(l); 45 | } else { 46 | this.questions = Collections.emptyList(); 47 | } 48 | if (map.get("answers") instanceof List) { 49 | List l = new ArrayList(); 50 | for (Object o : (List)map.get("answers")) { 51 | l.add(Record.parse((Map)o)); 52 | } 53 | this.answers = Collections.unmodifiableList(l); 54 | } else { 55 | this.answers = Collections.emptyList(); 56 | } 57 | if (map.get("additionals") instanceof List) { 58 | List l = new ArrayList(); 59 | for (Object o : (List)map.get("additionals")) { 60 | l.add(Record.parse((Map)o)); 61 | } 62 | this.additionals = Collections.unmodifiableList(l); 63 | } else { 64 | this.additionals = Collections.emptyList(); 65 | } 66 | if (map.get("authorities") instanceof List) { 67 | List l = new ArrayList(); 68 | for (Object o : (List)map.get("authorities")) { 69 | l.add(Record.parse((Map)o)); 70 | } 71 | this.authorities = Collections.unmodifiableList(l); 72 | } else { 73 | this.authorities = Collections.emptyList(); 74 | } 75 | } 76 | 77 | private Packet(int id, int flags, NetworkInterface nic, List questions, List answers, List authorities, List additionals) { 78 | this.timestamp = System.currentTimeMillis(); 79 | this.id = id; 80 | this.flags = flags; 81 | this.nic = nic; 82 | this.questions = Collections.unmodifiableList(questions); 83 | this.answers = Collections.unmodifiableList(answers); 84 | this.authorities = Collections.unmodifiableList(authorities); 85 | this.additionals = Collections.unmodifiableList(additionals); 86 | } 87 | 88 | /** 89 | * Create a question packet. 90 | * If the supplied question is for A or AAAA, we automatically add the other one 91 | * @param question the question record 92 | */ 93 | Packet(Record question) { 94 | this.timestamp = System.currentTimeMillis(); 95 | this.id = 0; 96 | this.flags = 0; 97 | if (question.getType() == Record.TYPE_A) { 98 | Record aaaa = Record.newQuestion(Record.TYPE_AAAA, question.getName()); 99 | this.questions = Collections.unmodifiableList(Arrays.asList(question, aaaa)); 100 | } else if (question.getType() == Record.TYPE_AAAA) { 101 | Record a = Record.newQuestion(Record.TYPE_A, question.getName()); 102 | this.questions = Collections.unmodifiableList(Arrays.asList(a, question)); 103 | } else { 104 | this.questions = Collections.singletonList(question); 105 | } 106 | this.answers = this.additionals = this.authorities = Collections.emptyList(); 107 | this.nic = null; 108 | } 109 | 110 | /** 111 | * Create a response packet 112 | * @param question the packet we're responding to 113 | * @param answers the answer records 114 | * @param additionals the additionals records 115 | */ 116 | Packet(Packet question, List answers, List additionals) { 117 | this.timestamp = System.currentTimeMillis(); 118 | this.id = question.id; 119 | this.nic = question.nic; 120 | if (additionals == null) { 121 | additionals = Collections.emptyList(); 122 | } 123 | this.answers = answers; 124 | this.additionals = additionals; 125 | this.questions = this.authorities = Collections.emptyList(); 126 | this.flags = (1< answers = new ArrayList(); 138 | List additionals = new ArrayList(); 139 | answers.add(Record.newPtr(service.getTTL_PTR(), domain, fqdn)); 140 | answers.add(Record.newSrv(service.getTTL_SRV(), fqdn, service.getHost(), service.getPort(), 0, 0)); 141 | answers.add(Record.newTxt(service.getTTL_TXT(), fqdn, service.getText())); // Seems "txt" is always required 142 | for (InetAddress address : service.getAddresses()) { 143 | additionals.add(Record.newAddress(service.getTTL_A(), service.getHost(), address)); 144 | } 145 | 146 | this.timestamp = System.currentTimeMillis(); 147 | this.id = 0; 148 | this.flags = (1<unmodifiableList(answers); 150 | this.additionals = Collections.unmodifiableList(additionals); 151 | this.questions = this.authorities = Collections.emptyList(); 152 | this.nic = null; 153 | } 154 | 155 | /** 156 | * Create a packet from an incoming datagram 157 | * @param in the incoming packet 158 | * @param address the address we read from 159 | */ 160 | Packet(ByteBuffer in, NetworkInterface nic) { 161 | try { 162 | this.timestamp = System.currentTimeMillis(); 163 | this.nic = nic; 164 | this.id = in.getShort() & 0xFFFF; 165 | this.flags = in.getShort() & 0xFFFF; 166 | int numQuestions = in.getShort() & 0xFFFF; 167 | int numAnswers = in.getShort() & 0xFFFF; 168 | int numAuthorities = in.getShort() & 0xFFFF; 169 | int numAdditionals = in.getShort() & 0xFFFF; 170 | if (numQuestions > 0) { 171 | List questions = new ArrayList(numQuestions); 172 | for (int i=0;iunmodifiableList(questions); 176 | } else { 177 | this.questions = Collections.emptyList(); 178 | } 179 | if (numAnswers > 0) { 180 | List answers = new ArrayList(numAnswers); 181 | for (int i=0;iunmodifiableList(answers); 187 | } else { 188 | this.answers = Collections.emptyList(); 189 | } 190 | if (numAuthorities > 0) { 191 | List authorities = new ArrayList(numAuthorities); 192 | for (int i=0;iunmodifiableList(authorities); 198 | } else { 199 | this.authorities = Collections.emptyList(); 200 | } 201 | if (numAdditionals > 0) { 202 | List additionals = new ArrayList(numAdditionals); 203 | for (int i=0;iunmodifiableList(additionals); 209 | } else { 210 | this.additionals = Collections.emptyList(); 211 | } 212 | } catch (Exception e) { 213 | in.position(0); 214 | throw (RuntimeException)new RuntimeException("Can't read packet from " + dump(in)).initCause(e); 215 | } 216 | } 217 | 218 | /** 219 | * The address we read from 220 | */ 221 | NetworkInterface getNetworkInterface() { 222 | return nic; 223 | } 224 | 225 | /** 226 | * The ID of the packet 227 | */ 228 | int getID() { 229 | return id; 230 | } 231 | 232 | /** 233 | * Return true if it's a reponse, false if it's a query 234 | */ 235 | boolean isResponse() { 236 | return (flags & (1<>3] |= (byte)(1<<(7-(i&7))); 251 | } 252 | for (int i=0;i nics) { 275 | InetAddress address = r.getAddress(); 276 | if (address == null) { 277 | return true; 278 | } 279 | if (appliesTo(address, nic)) { 280 | return true; 281 | } 282 | for (NetworkInterface onic : nics) { 283 | if (onic != nic && appliesTo(address, onic)) { 284 | return false; 285 | } 286 | } 287 | return true; 288 | } 289 | 290 | /** 291 | * Return a clone of this Packet but excluding any A or AAAA records in the list of addresses. 292 | * @param excludedAddresses the addresses to exclude 293 | * @return a new Packet, or null if all records were excluded 294 | */ 295 | Packet appliedTo(NetworkInterface nic, Collection nics) { 296 | List questions = this.questions.isEmpty() ? this.questions : new ArrayList(this.questions); 297 | List answers = this.answers.isEmpty() ? this.answers : new ArrayList(this.answers); 298 | List additionals = this.additionals.isEmpty() ? this.additionals : new ArrayList(this.additionals); 299 | List authorities = this.authorities.isEmpty() ? this.authorities : new ArrayList(this.authorities); 300 | for (int i=0;i 0) { 390 | sb.append(",\"questions\":["); 391 | for (int i=0;i 0) { 393 | sb.append(","); 394 | } 395 | sb.append(questions.get(i)); 396 | } 397 | sb.append(']'); 398 | } 399 | if (answers.size() > 0) { 400 | sb.append(",\"answers\":["); 401 | for (int i=0;i 0) { 403 | sb.append(","); 404 | } 405 | sb.append(answers.get(i)); 406 | } 407 | sb.append(']'); 408 | } 409 | if (additionals.size() > 0) { 410 | sb.append(",\"additionals\":["); 411 | for (int i=0;i 0) { 413 | sb.append(","); 414 | } 415 | sb.append(additionals.get(i)); 416 | } 417 | sb.append(']'); 418 | } 419 | if (authorities.size() > 0) { 420 | sb.append(",\"authorities\":["); 421 | for (int i=0;i 0) { 423 | sb.append(","); 424 | } 425 | sb.append(authorities.get(i)); 426 | } 427 | sb.append(']'); 428 | } 429 | sb.append("}"); 430 | return sb.toString(); 431 | } 432 | 433 | List getQuestions() { 434 | return questions; 435 | } 436 | 437 | List getAnswers() { 438 | return answers; 439 | } 440 | 441 | List getAdditionals() { 442 | return additionals; 443 | } 444 | 445 | //------------------------------------------------- 446 | 447 | /* 448 | public static void main(String[] args) throws Exception { 449 | for (String s : args) { 450 | byte[] b = new byte[s.length() / 2]; 451 | for (int i=0;i getText() { 102 | return data instanceof Map ? (Map)data : null; 103 | } 104 | 105 | //---------------------------------------------------- 106 | // Static creation methods 107 | //---------------------------------------------------- 108 | 109 | /** 110 | * Parse the output of Stringify.parse(record.toString()) back into a Record. 111 | */ 112 | static Record parse(Map m) { 113 | int type; 114 | if (m.get("type") instanceof String) { 115 | switch ((String)m.get("type")) { 116 | case "ptr": type = TYPE_PTR; break; 117 | case "txt": type = TYPE_TXT; break; 118 | case "cname": type = TYPE_CNAME; break; 119 | case "nsec": type = TYPE_NSEC; break; 120 | case "a": type = TYPE_A; break; 121 | case "aaaa": type = TYPE_AAAA; break; 122 | case "srv": type = TYPE_SRV; break; 123 | case "any": type = TYPE_ANY; break; 124 | default: throw new IllegalArgumentException("Invalid type \"" + m.get("type") + "\""); 125 | } 126 | } else { 127 | type = ((Integer)m.get("type")).intValue(); 128 | } 129 | int clazz = ((Integer)m.get("class")).intValue(); 130 | int ttl = ((Integer)m.get("ttl")).intValue(); 131 | String name = (String)m.get("name"); 132 | Object data = null; 133 | if (type == TYPE_PTR) { 134 | data = m.get("value"); 135 | } else if (type == TYPE_A || type == TYPE_AAAA) { 136 | try { 137 | data = InetAddress.getByName((String)m.get("address")); 138 | } catch (UnknownHostException e) { 139 | throw new IllegalArgumentException("Invalid address \"" + m.get("address") + "\"", e); 140 | } 141 | } else if (type == TYPE_TXT) { 142 | data = m.get("data"); 143 | } else if (type == TYPE_SRV) { 144 | if (m.get("host") != null) { 145 | String host = (String)m.get("host"); 146 | int port = (Integer)m.get("port"); 147 | int priority = (Integer)m.get("priority"); 148 | int weight = (Integer)m.get("weight"); 149 | data = new SrvData(priority, weight, port, host); 150 | } 151 | } else if (m.get("bytes") instanceof String) { 152 | data = Stringify.parseHex((String)m.get("bytes")); 153 | } 154 | return new Record(type, clazz, ttl, name, data); 155 | } 156 | 157 | /** 158 | * Create a new Question 159 | * @param type the type 160 | * @param name the name 161 | */ 162 | static Record newQuestion(int type, String name) { 163 | return new Record(type, CLAZZ, 0, name, null); 164 | } 165 | 166 | /** 167 | * Create a new A or AAAA record 168 | * @param name the name 169 | * @param address the address 170 | */ 171 | static Record newAddress(int ttl, String name, InetAddress address) { 172 | if (name == null) { 173 | throw new IllegalArgumentException("name is null"); 174 | } else if (address instanceof Inet4Address) { 175 | return new Record(TYPE_A, CLAZZ, ttl, name, address); 176 | } else if (address instanceof Inet6Address) { 177 | return new Record(TYPE_AAAA, CLAZZ, ttl, name, address); 178 | } else { 179 | throw new IllegalArgumentException("address invalid"); 180 | } 181 | } 182 | 183 | static Record newPtr(int ttl, String name, String value) { 184 | if (name == null || value == null) { 185 | throw new IllegalArgumentException("name or value is null"); 186 | } 187 | return new Record(TYPE_PTR, CLAZZ, ttl, name, value); 188 | } 189 | 190 | static Record newSrv(int ttl, String name, String host, int port, int weight, int priority) { 191 | if (name == null || host == null || port < 1 || port > 65535) { 192 | throw new IllegalArgumentException("name, host or port is invalid"); 193 | } 194 | return new Record(TYPE_SRV, CLAZZ, ttl, name, new SrvData(priority, weight, port, host)); 195 | } 196 | 197 | static Record newTxt(int ttl, String name, Map map) { 198 | if (name == null || map == null) { 199 | throw new IllegalArgumentException("name or map is invalid"); 200 | } 201 | return new Record(TYPE_TXT, CLAZZ, ttl, name, map); 202 | } 203 | 204 | //---------------------------------------------------- 205 | // Static methods for reading/writing 206 | //---------------------------------------------------- 207 | 208 | static Record readAnswer(ByteBuffer in) { 209 | // System.out.println("RECORD: " + Packet.dump(in)); 210 | int tell = in.position(); 211 | try { 212 | String name = readName(in); 213 | int type = in.getShort() & 0xFFFF; 214 | int clazz = in.getShort() & 0xFFFF; 215 | int ttl = in.getInt(); 216 | int len = in.getShort() & 0xFFFF; 217 | Object data; 218 | if (type == TYPE_PTR) { 219 | data = readName(in); 220 | } else if (type == TYPE_SRV) { 221 | int priority = in.getShort() & 0xffff; 222 | int weight = in.getShort() & 0xffff; 223 | int port = in.getShort() & 0xffff; 224 | String host = readName(in); 225 | data = new SrvData(priority, weight, port, host); 226 | } else if (type == TYPE_A || type == TYPE_AAAA) { 227 | byte[] buf = new byte[len]; 228 | in.get(buf); 229 | data = InetAddress.getByAddress(buf); 230 | } else if (type == TYPE_TXT) { 231 | Map map = new LinkedHashMap(); 232 | int end = in.position() + len; 233 | while (in.position() < end) { 234 | String value = readString(in); 235 | if (value.length() > 0) { 236 | int ix = value.indexOf("="); 237 | if (ix > 0) { 238 | map.put(value.substring(0, ix), value.substring(ix + 1)); 239 | } else { 240 | map.put(value, null); // ??? 241 | } 242 | } 243 | } 244 | data = Collections.unmodifiableMap(map); 245 | } else { 246 | // System.out.println("UNKNOWN TYPE " + type+" len="+len); 247 | byte[] buf = new byte[len]; 248 | in.get(buf); 249 | data = buf; 250 | } 251 | Record r = new Record(type, clazz, ttl, name, data); 252 | return r; 253 | } catch (Exception e) { 254 | ((Buffer)in).position(tell); 255 | throw (RuntimeException)new RuntimeException("Failed reading record " + Packet.dump(in)).initCause(e); 256 | } 257 | } 258 | 259 | static Record readQuestion(ByteBuffer in) { 260 | // System.out.println("RECORD: " + Packet.dump(in)); 261 | int tell = in.position(); 262 | try { 263 | String name = readName(in); 264 | int type = in.getShort() & 0xFFFF; 265 | int clazz = in.getShort() & 0xFFFF; 266 | return new Record(type, clazz, 0, name, null); 267 | } catch (Exception e) { 268 | ((Buffer)in).position(tell); 269 | throw (RuntimeException)new RuntimeException("Failed reading record " + Packet.dump(in)).initCause(e); 270 | } 271 | } 272 | 273 | void write(ByteBuffer out) { 274 | final int pos1 = out.position(); 275 | out.put(writeName(getName())); 276 | out.putShort((short)type); 277 | out.putShort((short)clazz); 278 | if (data != null) { 279 | out.putInt(ttl); 280 | int pos = out.position(); 281 | out.putShort((short)0); 282 | if (type == TYPE_PTR) { 283 | out.put(writeName(getPtrValue())); 284 | } else if (type == TYPE_SRV) { 285 | out.putShort((short)getSrvPriority()); 286 | out.putShort((short)getSrvWeight()); 287 | out.putShort((short)getSrvPort()); 288 | out.put(writeName(getSrvHost())); 289 | } else if (type == TYPE_A) { 290 | out.put(((Inet4Address)getAddress()).getAddress()); 291 | } else if (type == TYPE_AAAA) { 292 | out.put(((Inet6Address)getAddress()).getAddress()); 293 | } else if (type == TYPE_TXT) { 294 | if (getText().isEmpty()) { 295 | out.put((byte)0); 296 | } else { 297 | for (Map.Entry e : getText().entrySet()) { 298 | String value = e.getKey()+"="+e.getValue(); 299 | byte[] b = value.getBytes(StandardCharsets.UTF_8); 300 | out.put((byte)b.length); 301 | out.put(b); 302 | } 303 | } 304 | } else if (data instanceof byte[]) { 305 | out.put((byte[])data); 306 | } 307 | if (out.position() > pos + 2) { 308 | int len = out.position() - pos - 2; 309 | out.putShort(pos, (short)len); 310 | } else { 311 | ((Buffer)out).position(pos); 312 | } 313 | 314 | /* 315 | String s1 = toString(); 316 | int pos2 = out.position(); 317 | int oldlimit = out.limit(); 318 | out.limit(pos2); 319 | out.position(pos1); 320 | System.out.println(this); 321 | System.out.println(Packet.dump(out)); 322 | Record r = readAnswer(out); 323 | String s2 = r.toString(); 324 | if (!s1.equals(s2) || out.position() != pos2) { 325 | throw new Error("Should be " + s1 + "@"+pos2+" got " + s2+"@"+out.position()); 326 | } 327 | out.limit(oldlimit); 328 | */ 329 | } 330 | } 331 | 332 | private static byte[] writeName(String name) { 333 | ByteBuffer buf = ByteBuffer.allocate(name.length() * 2); 334 | int len = name.length(); 335 | int start = 0; 336 | for (int i=0;i<=len;i++) { 337 | char c = i == len ? '.' : name.charAt(i); 338 | if (c == '.') { 339 | byte[] b = name.substring(start, i).getBytes(StandardCharsets.UTF_8); 340 | if (b.length >= 0x40) { 341 | throw new UnsupportedOperationException("Not implemented yet"); 342 | } 343 | buf.put((byte)b.length); 344 | buf.put(b); 345 | start = i + 1; 346 | } 347 | } 348 | buf.put((byte)0); 349 | byte[] out = new byte[buf.position()]; 350 | System.arraycopy(buf.array(), 0, out, 0, out.length); 351 | String s = readName(ByteBuffer.wrap(out, 0, out.length)); 352 | if (!s.equals(name)) { 353 | throw new IllegalStateException("Wrong name: " + Stringify.toString(name) + " != " + Stringify.toString(s)); 354 | } 355 | return out; 356 | } 357 | 358 | private static byte[] writeString(String s) { 359 | byte[] b = s.getBytes(StandardCharsets.UTF_8); 360 | if (b.length > 255) { 361 | throw new UnsupportedOperationException("String too long (" + b.length +" bytes)"); 362 | } 363 | byte[] out = new byte[b.length + 1]; 364 | out[0] = (byte)b.length; 365 | System.arraycopy(b, 0, out, 1, b.length); 366 | return out; 367 | } 368 | 369 | private static String readString(ByteBuffer in) { 370 | // System.out.println("STRING: " + Packet.dump(in)); 371 | int len = in.get() & 0xFF; 372 | if (len == 0) { 373 | return ""; 374 | } else { 375 | String s = new String(in.array(), in.position(), len, StandardCharsets.UTF_8); 376 | ((Buffer)in).position(in.position() + len); 377 | return s; 378 | } 379 | } 380 | 381 | private static String readName(ByteBuffer in) { 382 | // System.out.println("STRINGLIST: " + Packet.dump(in)); 383 | StringBuilder sb = new StringBuilder(); 384 | int len; 385 | int end = -1; 386 | while ((len = (in.get()&0xFF)) > 0) { 387 | if (len < 0x40) { 388 | if (sb.length() > 0) { 389 | sb.append('.'); 390 | } 391 | sb.append(new String(in.array(), in.position(), len, StandardCharsets.UTF_8)); 392 | ((Buffer)in).position(in.position() + len); 393 | } else { 394 | int off = ((len & 0x3F) << 8) | (in.get() & 0xFF); // Offset from start of packet 395 | if (end < 0) { 396 | end = in.position(); 397 | } 398 | ((Buffer)in).position(off); 399 | } 400 | } 401 | if (end >= 0) { 402 | ((Buffer)in).position(end); 403 | } 404 | // System.out.println("STRINGLIST OUT: " + Stringify.toString(sb.toString())); 405 | return sb.toString(); 406 | } 407 | 408 | public String toString() { 409 | StringBuilder sb = new StringBuilder(); 410 | sb.append("{"); 411 | sb.append("\"type\":"); 412 | switch (type) { 413 | case TYPE_A: sb.append("\"a\""); break; 414 | case TYPE_AAAA: sb.append("\"aaaa\""); break; 415 | case TYPE_PTR: sb.append("\"ptr\""); break; 416 | case TYPE_SRV: sb.append("\"srv\""); break; 417 | case TYPE_TXT: sb.append("\"txt\""); break; 418 | case TYPE_ANY: sb.append("\"any\""); break; 419 | case TYPE_CNAME: sb.append("\"cname\""); break; 420 | case TYPE_NSEC: sb.append("\"nsec\""); break; 421 | default: sb.append(Integer.toString(type)); 422 | } 423 | sb.append(",\"name\":"); 424 | sb.append(Stringify.toString(getName())); 425 | sb.append(",\"class\":"); 426 | sb.append(clazz); 427 | sb.append(",\"ttl\":"); 428 | sb.append(ttl); 429 | if (data != null) { 430 | int len = sb.length(); 431 | if (type == TYPE_A || type == TYPE_AAAA) { 432 | sb.append(",\"address\":"); 433 | try { 434 | sb.append(Stringify.toString(InetAddress.getByAddress(getAddress().getAddress()).getHostAddress())); // Remove extra data from tostring 435 | } catch (Exception e) { 436 | sb.append(Stringify.toString(getAddress().getHostAddress())); 437 | } 438 | len = 0; 439 | } else if (type == TYPE_PTR) { 440 | sb.append(",\"value\":"); 441 | sb.append(Stringify.toString(getPtrValue())); 442 | len = 0; 443 | } else if (type == TYPE_SRV) { 444 | sb.append(",\"host\":"); 445 | sb.append(Stringify.toString(getSrvHost())); 446 | sb.append(",\"port\":" + getSrvPort() + ",\"priority\":" + getSrvPriority() + ",\"weight\":" + getSrvWeight()); 447 | len = 0; 448 | } else if (type == TYPE_TXT) { 449 | sb.append(",\"data\":{"); 450 | boolean first = true; 451 | for (Map.Entry e : getText().entrySet()) { 452 | if (first) { 453 | first = false; 454 | } else { 455 | sb.append(','); 456 | } 457 | sb.append(Stringify.toString(e.getKey())); 458 | sb.append(':'); 459 | sb.append(Stringify.toString(e.getValue())); 460 | } 461 | sb.append("}"); 462 | len = 0; 463 | } else { 464 | byte[] d = (byte[])data; 465 | sb.append(",\"bytes\":\""); 466 | for (int i=0;i> LOCAL = Collections.>unmodifiableMap(new HashMap>()); 14 | 15 | private final Zeroconf zeroconf; 16 | private final String fqdn, name, type, domain; // Store FQDN because it may not be escaped properly. Store it exactly as we hear it 17 | private String host; 18 | private int port; 19 | private int ttl_srv = Record.TTL_SRV; 20 | private int ttl_txt = Record.TTL_TXT; 21 | private int ttl_ptr = Record.TTL_PTR; 22 | private int ttl_a = Record.TTL_A; 23 | private Map> addresses; 24 | private Map text; 25 | private long lastAddressRequest; 26 | boolean cancelled; // This flag required becuase changes may happen after it is cancelled, which makes it look like a remote service. 27 | 28 | Service(Zeroconf zeroconf, String fqdn, String name, String type, String domain) { 29 | this.zeroconf = zeroconf; 30 | this.fqdn = fqdn; 31 | this.name = name; 32 | this.type = type; 33 | this.domain = domain; 34 | this.addresses = new LinkedHashMap>(); 35 | } 36 | 37 | Service(Zeroconf zeroconf, String fqdn) { 38 | List l = splitFQDN(fqdn); 39 | if (l == null) { 40 | throw new IllegalArgumentException("Can't split " + Stringify.toString(fqdn)); 41 | } 42 | this.zeroconf = zeroconf; 43 | this.fqdn = fqdn; 44 | this.name = l.get(0); 45 | this.type = l.get(1); 46 | this.domain = l.get(2); 47 | this.addresses = new LinkedHashMap>(); 48 | } 49 | 50 | /** 51 | * Return the Zeroconf object this Service is assigned to 52 | * @return the zeroconf 53 | */ 54 | public Zeroconf getZeroconf() { 55 | return zeroconf; 56 | } 57 | 58 | /** 59 | * Given an FQDN, split into three parts: the instance name, the type+protocol, and the domain, eg "Foo bar", "_http._tcp", ".local" 60 | * return null if it fails 61 | */ 62 | static List splitFQDN(String name) { 63 | List l = new ArrayList(); 64 | StringBuilder sb = new StringBuilder(); 65 | for (int i=0;i1;i--) { 80 | String s = l.get(i); 81 | if (s.equals("_tcp") || s.equals("_udp") || (s.charAt(0) == '_' && l.get(i - 1).charAt(0) == '_')) { 82 | String type = l.get(i - 1) + "." + s; 83 | sb.setLength(0); 84 | for (int j=i+1;j 0) { 92 | sb.append('.'); 93 | } 94 | sb.append(l.get(j)); 95 | } 96 | String instance = sb.toString(); 97 | return Arrays.asList(instance, type, domain); 98 | } 99 | } 100 | return null; 101 | } 102 | 103 | boolean setHost(String host, int port) { 104 | boolean modified = false; 105 | if (port != this.port) { 106 | this.port = port; 107 | modified = true; 108 | } 109 | if (host == null ? this.host != null : !host.equals(this.host)) { 110 | this.host = host; 111 | modified = true; 112 | } 113 | return modified; 114 | } 115 | 116 | boolean setText(Map text) { 117 | if (text == null ? this.text != null : !text.equals(this.text)) { 118 | this.text = text; 119 | return true; 120 | } 121 | return false; 122 | } 123 | 124 | boolean addAddress(InetAddress address, NetworkInterface nic) { 125 | if (addresses == LOCAL) { 126 | if (cancelled) { 127 | return false; 128 | } 129 | throw new IllegalStateException("Local addresses"); 130 | } 131 | Collection nics = addresses.get(address); 132 | boolean created = nics == null; 133 | if (nics == null) { 134 | addresses.put(address, nics = new ArrayList()); 135 | } 136 | if (nic != null && !nics.contains(nic)) { 137 | nics.add(nic); 138 | } 139 | return created; 140 | } 141 | 142 | boolean removeAddress(InetAddress address) { 143 | if (addresses == LOCAL) { 144 | if (cancelled) { 145 | return false; 146 | } 147 | throw new IllegalStateException("Local addresses"); 148 | } 149 | return addresses.remove(address) != null; 150 | } 151 | 152 | /** 153 | * Return the fully-qualified domain name for this Service, 154 | * which also serves as a unique key. 155 | * @return the FQDN 156 | */ 157 | public String getFQDN() { 158 | return fqdn; 159 | /* 160 | StringBuilder sb = new StringBuilder(); 161 | for (int i=0;i getText() { 239 | return text == null ? Collections.emptyMap() : text; 240 | } 241 | 242 | /** 243 | * Return an unmodifiable list containing the addresses 244 | * @return the list of addresses 245 | */ 246 | public Collection getAddresses() { 247 | if (addresses == LOCAL) { 248 | // Done this way on the theory that the local addresses may change, 249 | // and if it does we want this to update automatically. 250 | return zeroconf.getLocalAddresses(); 251 | } else { 252 | if (addresses.isEmpty()) { 253 | if (System.currentTimeMillis() - lastAddressRequest > 1000) { 254 | // We should have these, but maybe they weren't announced? 255 | // Ask once a second, no more. Requesting A also requests AAAA 256 | lastAddressRequest = System.currentTimeMillis(); 257 | zeroconf.query(type, name, Record.TYPE_A); 258 | } 259 | } 260 | return Collections.unmodifiableCollection(addresses.keySet()); 261 | } 262 | } 263 | 264 | /** 265 | * Return a read-only collection of NetworkInterfaces this service was announced on. 266 | * If the service is one being announced locally, the collection has the same values as 267 | * {@link Zeroconf#getNetworkInterfaces} 268 | * @since 1.0.1 269 | * @return the read-only collection of NetworkInterface objects 270 | */ 271 | public Collection getNetworkInterfaces() { 272 | List nics = new ArrayList(); 273 | if (host == null) { 274 | nics.addAll(zeroconf.getNetworkInterfaces()); 275 | } else { 276 | for (Collection l : addresses.values()) { 277 | for (NetworkInterface nic : l) { 278 | if (!nics.contains(nic)) { 279 | nics.add(nic); 280 | } 281 | } 282 | } 283 | } 284 | return Collections.unmodifiableCollection(nics); 285 | } 286 | 287 | /** 288 | * Announce this Service on the network. 289 | * @return true if the service was announced, false if it already exists on the network. 290 | */ 291 | public boolean announce() { 292 | if (zeroconf.announce(this)) { 293 | cancelled = false; 294 | return true; 295 | } 296 | return false; 297 | } 298 | 299 | /** 300 | * Cancel the announcement of this Service on the Network 301 | * @return true if the service was announced and is now cancelled, false if it was not announced or announced by someone else. 302 | */ 303 | public boolean cancel() { 304 | if (zeroconf.unannounce(this)) { 305 | cancelled = true; 306 | return true; 307 | } 308 | return false; 309 | } 310 | 311 | public int hashCode() { 312 | return getFQDN().hashCode(); 313 | } 314 | 315 | /** 316 | * Two services are equal if they have the same {@link #getFQDN FQDN} and belong to the same {@link Zeroconf} object 317 | * @param o the object 318 | * @return true if the services are equal 319 | */ 320 | public boolean equals(Object o) { 321 | if (o instanceof Service) { 322 | Service s = (Service)o; 323 | return s.zeroconf == zeroconf && s.getFQDN().equals(getFQDN()); 324 | } 325 | return false; 326 | } 327 | 328 | public String toString() { 329 | StringBuilder sb = new StringBuilder(); 330 | sb.append("{\"name\":"); 331 | sb.append(Stringify.toString(name)); 332 | sb.append(",\"type\":"); 333 | sb.append(Stringify.toString(type)); 334 | sb.append(",\"domain\":"); 335 | sb.append(Stringify.toString(domain)); 336 | if (host != null) { 337 | sb.append(",\"host\":"); 338 | sb.append(Stringify.toString(host)); 339 | sb.append(",\"port\":"); 340 | sb.append(Integer.toString(port)); 341 | } 342 | if (text != null) { 343 | sb.append(",\"text\":{"); 344 | boolean first = true; 345 | for (Map.Entry e : text.entrySet()) { 346 | if (first) { 347 | first = false; 348 | } else { 349 | sb.append(','); 350 | } 351 | sb.append(Stringify.toString(e.getKey())); 352 | sb.append(':'); 353 | sb.append(Stringify.toString(e.getValue())); 354 | } 355 | sb.append('}'); 356 | } 357 | if (addresses != LOCAL) { 358 | sb.append(",\"addresses\":["); 359 | boolean first = true; 360 | for (Map.Entry> e : addresses.entrySet()) { 361 | InetAddress address = e.getKey(); 362 | Collection nics = e.getValue(); 363 | if (!first) { 364 | sb.append(','); 365 | } 366 | StringBuilder sb2 = new StringBuilder(); 367 | sb2.append(address.toString()); 368 | if (!nics.isEmpty()) { 369 | sb2.append("("); 370 | boolean first2 = true; 371 | for (NetworkInterface nic : nics) { 372 | if (!first2) { 373 | sb2.append(','); 374 | } 375 | sb2.append(nic.getName()); 376 | } 377 | sb2.append(")"); 378 | } 379 | first = false; 380 | sb.append(Stringify.toString(sb2.toString())); 381 | } 382 | sb.append("]"); 383 | } 384 | sb.append("}"); 385 | return sb.toString(); 386 | } 387 | 388 | /** 389 | * A Builder class to create a new {@link Service} for announcement 390 | */ 391 | public static class Builder { 392 | private static final int MINTTL = 5; // 5s seems reasonable? 393 | private static final int MAXTTL = 86400; // 1day seems reasonable? 394 | 395 | private String name, type, domain, host; 396 | private int port = -1; 397 | private int ttl_a = Record.TTL_A; 398 | private int ttl_srv = Record.TTL_SRV; 399 | private int ttl_ptr = Record.TTL_PTR; 400 | private int ttl_txt = Record.TTL_TXT; 401 | private Map props = new LinkedHashMap(); 402 | private List addresses = new ArrayList(); 403 | 404 | /** 405 | * (Required) Set the instance name 406 | * @param name the name 407 | * @return this 408 | */ 409 | public Builder setName(String name) { 410 | if (name == null || name.length() == 0) { 411 | throw new IllegalArgumentException("Empty name"); 412 | } 413 | for (int i=0;i= 0x7F) { 416 | throw new IllegalArgumentException("Invalid name character U+" + Integer.toHexString(c)+" in " + Stringify.toString(name)); 417 | } 418 | } 419 | this.name = name; 420 | return this; 421 | } 422 | 423 | /** 424 | * Set the fully qualifier host name - it should contain a domain. 425 | * If not set, will be generated from {@link Zeroconf#getLocalHostName} and the domain, 426 | * from {@link #setDomain} or {@link Zeroconf#getDomain} 427 | * @param host the host. 428 | * @return this 429 | */ 430 | public Builder setHost(String host) { 431 | if (host != null && host.length() == 0) { 432 | throw new IllegalArgumentException("Invalid host"); 433 | } 434 | this.host = host; 435 | return this; 436 | } 437 | /** 438 | * (Required) Set the service type - a combination of the service name and protocol, eg "_http._tcp" 439 | * Both name and protocol must begin with an underscore and be separated by a single dot. 440 | * @param type the type 441 | * @return this 442 | */ 443 | public Builder setType(String type) { 444 | int ix; 445 | if (type == null || (ix=type.indexOf(".")) < 0 || type.length() < 2 || ix + 1 >= type.length() || type.charAt(0) != '_' || type.charAt(ix + 1) != '_') { 446 | throw new IllegalArgumentException("Invalid type: must contain service+protocol, both starting with underscore eg \"_http._tcp\""); 447 | } 448 | this.type = type; 449 | return this; 450 | } 451 | /** 452 | * Set the domain, eg ".local". If not set this defaults to {@link Zeroconf#getDomain} 453 | * @param domain the domain 454 | * @return this 455 | */ 456 | public Builder setDomain(String domain) { 457 | if (domain != null && (domain.length() < 2 || domain.charAt(0) != '.')) { 458 | throw new IllegalArgumentException("Invalid domain: must start with dot, eg \".local\""); 459 | } 460 | this.domain = domain; 461 | return this; 462 | } 463 | /** 464 | * Set the fully-qualified domain name of this service, eg "My Service._http._tcp.local". 465 | * Can be called as an alternative to calling {@link #setName}, {@link #setType} and {@link #setDomain} 466 | * @param fqdn the fully-qualified domain name 467 | * @return this 468 | */ 469 | public Builder setFQDN(String fqdn) { 470 | List l = splitFQDN(fqdn); 471 | if (l != null) { 472 | setName(l.get(0)); 473 | setType(l.get(1)); 474 | setDomain(l.get(2)); 475 | } else { 476 | throw new IllegalArgumentException("Invalid FQDN: " + Stringify.toString(fqdn) + " can't split"); 477 | } 478 | return this; 479 | } 480 | /** 481 | * (Required) Set the port to announce. 482 | * @param port the port, between 0 and 65535. A value of zero means no port. 483 | * @return this 484 | */ 485 | public Builder setPort(int port) { 486 | if (port < 0 || port > 65535) { 487 | throw new IllegalArgumentException("Invalid port"); 488 | } 489 | this.port = port; 490 | return this; 491 | } 492 | /** 493 | * Get the time-to-live in seconds for any "ptr" records announced for this service. 494 | * @return the time-to-live 495 | */ 496 | public int getTTL_PTR() { 497 | return ttl_ptr; 498 | } 499 | /** 500 | * Set the time-to-live in seconds for any "ptr" records announced for this service. 501 | * @param ttl the time-to-live in seconds 502 | * @return this 503 | */ 504 | public Builder setTTL_PTR(int ttl) { 505 | if (ttl < MINTTL || ttl > MAXTTL) { 506 | throw new IllegalArgumentException("TTL outside range " + MINTTL + ".." + MAXTTL); 507 | } 508 | ttl_ptr = ttl; 509 | return this; 510 | } 511 | /** 512 | * Get the time-to-live in seconds for any "srv" records announced for this service. 513 | * @return the time-to-live 514 | */ 515 | public int getTTL_SRV() { 516 | return ttl_srv; 517 | } 518 | /** 519 | * Set the time-to-live in seconds for any "srv" records announced for this service. 520 | * @param ttl the time-to-live in seconds 521 | * @return this 522 | */ 523 | public Builder setTTL_SRV(int ttl) { 524 | if (ttl < MINTTL || ttl > MAXTTL) { 525 | throw new IllegalArgumentException("TTL outside range " + MINTTL + ".." + MAXTTL); 526 | } 527 | ttl_srv = ttl; 528 | return this; 529 | } 530 | /** 531 | * Get the time-to-live in seconds for any "txt" records announced for this service. 532 | * @return the time-to-live 533 | */ 534 | public int getTTL_TXT() { 535 | return ttl_txt; 536 | } 537 | /** 538 | * Set the time-to-live in seconds for any "txt" records announced for this service. 539 | * @param ttl the time-to-live in seconds 540 | * @return this 541 | */ 542 | public Builder setTTL_TXT(int ttl) { 543 | if (ttl < MINTTL || ttl > MAXTTL) { 544 | throw new IllegalArgumentException("TTL outside range " + MINTTL + ".." + MAXTTL); 545 | } 546 | ttl_txt = ttl; 547 | return this; 548 | } 549 | /** 550 | * Get the time-to-live in seconds for any "a" records announced for this service. 551 | * @return the time-to-live 552 | */ 553 | public int getTTL_A() { 554 | return ttl_a; 555 | } 556 | /** 557 | * Set the time-to-live in seconds for any "a" or "aaaa" records announced for this service. 558 | * @param ttl the time-to-live in seconds 559 | * @return this 560 | */ 561 | public Builder setTTL_A(int ttl) { 562 | if (ttl < MINTTL || ttl > MAXTTL) { 563 | throw new IllegalArgumentException("TTL outside range " + MINTTL + ".." + MAXTTL); 564 | } 565 | ttl_a = ttl; 566 | return this; 567 | } 568 | /** 569 | * Add a text value to the Service 570 | * @param key the text key 571 | * @param value the text value. If this is null, the key will be added without any "=" 572 | * @return this 573 | */ 574 | public Builder put(String key, String value) { 575 | if (value == null) { 576 | props.remove(key); 577 | } else { 578 | props.put(key, value); 579 | } 580 | return this; 581 | } 582 | /** 583 | * Add text values from the supplied Map to the Service 584 | * @param map the map 585 | * @return this 586 | */ 587 | public Builder putAll(Map map) { 588 | props.putAll(map); 589 | return this; 590 | } 591 | /** 592 | * Add an internet address to the Service. If not specified, the addresses 593 | * from {@link Zeroconf#getLocalAddresses} will be used 594 | * @param address the address 595 | * @return this 596 | */ 597 | public Builder addAddress(InetAddress address) { 598 | if (address != null) { 599 | addresses.add(address); 600 | } 601 | return this; 602 | } 603 | /** 604 | * Build a new Service which can be announced with {@link Service#announce} 605 | * @param zeroconf the Zeroconf instance to bind this Service to 606 | * @return the new Service 607 | */ 608 | public Service build(Zeroconf zeroconf) { 609 | if (name == null) { 610 | throw new IllegalStateException("Name is required"); 611 | } 612 | if (type == null) { 613 | throw new IllegalStateException("Type is required"); 614 | } 615 | if (port < 0) { 616 | throw new IllegalStateException("Port is required"); 617 | } 618 | if (domain == null) { 619 | domain = zeroconf.getDomain(); 620 | } 621 | if (host == null && zeroconf.getLocalHostName() == null) { 622 | throw new IllegalStateException("Host is required (cannot be determined automatically)"); 623 | } 624 | if (!props.isEmpty()) { 625 | try { 626 | Record r = Record.newTxt(Record.TTL_TXT, "text", props); 627 | r.write(java.nio.ByteBuffer.allocate(8192)); 628 | } catch (Exception e) { 629 | throw (RuntimeException)new IllegalStateException("TXT record is too large").initCause(e); 630 | } 631 | } 632 | StringBuilder sb = new StringBuilder(); 633 | for (int i=0;i>(); 647 | for (InetAddress address : addresses) { 648 | service.addAddress(address, null); 649 | } 650 | } else { 651 | service.addresses = LOCAL; 652 | } 653 | if (!props.isEmpty()) { 654 | service.setText(Collections.unmodifiableMap(props)); 655 | } 656 | service.ttl_a = ttl_a; 657 | service.ttl_srv = ttl_srv; 658 | service.ttl_ptr = ttl_ptr; 659 | service.ttl_txt = ttl_txt; 660 | return service; 661 | } 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /src/main/com/bfo/zeroconf/Stringify.java: -------------------------------------------------------------------------------- 1 | package com.bfo.zeroconf; 2 | 3 | import java.nio.*; 4 | import java.util.*; 5 | 6 | class Stringify { 7 | 8 | static Object parse(String in) { 9 | return parse(CharBuffer.wrap(in)); 10 | } 11 | 12 | /** 13 | * A quick single-method JSON parser, intended to parse input which is expected to be valid. 14 | * Does not exacly match the JSON parsing rules for numbers. 15 | */ 16 | static Object parse(CharBuffer in) { 17 | int tell = in.position(); 18 | try { 19 | char c; 20 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t') { 21 | tell++; 22 | } 23 | Object out; 24 | if (c == '{') { 25 | Map m = new LinkedHashMap(); 26 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t'); 27 | if (c != '}') { 28 | in.position(in.position() - 1); 29 | do { 30 | String key = (String)parse(in); 31 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t'); 32 | if (c == ':') { 33 | m.put((String)key, parse(in)); 34 | tell = in.position(); 35 | } else { 36 | throw new UnsupportedOperationException("expecting colon"); 37 | } 38 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t'); 39 | if (c != ',' && c != '}') { 40 | throw new UnsupportedOperationException("expecting comma or end-map"); 41 | } 42 | } while (c != '}'); 43 | } 44 | out = m; 45 | } else if (c == '[') { 46 | List l = new ArrayList(); 47 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t'); 48 | if (c != ']') { 49 | in.position(in.position() - 1); 50 | do { 51 | l.add(parse(in)); 52 | tell = in.position(); 53 | while ((c=in.get()) == ' ' || c == '\n' || c == '\r' || c == '\t'); 54 | if (c != ',' && c != ']') { 55 | throw new UnsupportedOperationException("expecting comma or end-list"); 56 | } 57 | } while (c != ']'); 58 | } 59 | out = l; 60 | } else if (c == '"') { 61 | StringBuilder sb = new StringBuilder(); 62 | while ((c=in.get()) != '"') { 63 | if (c == '\\') { 64 | c = in.get(); 65 | switch (c) { 66 | case 'n': c = '\n'; break; 67 | case 'r': c = '\r'; break; 68 | case 't': c = '\t'; break; 69 | case 'b': c = '\b'; break; 70 | case 'f': c = '\f'; break; 71 | case 'u': c = (char)Integer.parseInt(in.subSequence(0, 4).toString(), 16); in.position(in.position() + 4); break; 72 | } 73 | } 74 | sb.append(c); 75 | } 76 | out = sb.toString(); 77 | } else if (c == 't' && in.get() == 'r' && in.get() == 'u' && in.get() == 'e') { 78 | out = Boolean.TRUE; 79 | } else if (c == 'f' && in.get() == 'a' && in.get() == 'l' && in.get() == 's' && in.get() == 'e') { 80 | out = Boolean.FALSE; 81 | } else if (c == 'n' && in.get() == 'u' && in.get() == 'l' && in.get() == 'l') { 82 | out = null; 83 | } else if (c == '-' || (c >= '0' && c <= '9')) { 84 | StringBuilder sb = new StringBuilder(); 85 | sb.append(c); 86 | while (in.hasRemaining()) { 87 | if ((c=in.get()) == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9')) { 88 | sb.append(c); 89 | } else { 90 | in.position(in.position() - 1); 91 | break; 92 | } 93 | } 94 | String s = sb.toString(); 95 | try { 96 | Long l = Long.parseLong(s); 97 | if (l.longValue() == l.intValue()) { // This can't be done with a ternary due to unboxing confusion 98 | out = Integer.valueOf(l.intValue()); 99 | } else { 100 | out = l; 101 | } 102 | } catch (Exception e) { 103 | try { 104 | out = Double.parseDouble(s); 105 | } catch (Exception e2) { 106 | throw new UnsupportedOperationException("invalid number: " + s); 107 | } 108 | } 109 | } else { 110 | throw new UnsupportedOperationException("invalid " + (c >= ' ' && c < 0x80 ? "'" + ((char)c) + "'" : "U+" + Integer.toHexString(c))); 111 | } 112 | return out; 113 | } catch (BufferUnderflowException e) { 114 | throw (IllegalArgumentException)new IllegalArgumentException("Parse failed: unexpected EOF").initCause(e); 115 | } catch (ClassCastException e) { 116 | in.position(tell); 117 | throw new IllegalArgumentException("Parse failed at " + in.position() + ": expected string"); 118 | } catch (UnsupportedOperationException e) { 119 | in.position(tell); 120 | throw new IllegalArgumentException("Parse failed at " + in.position() + ": " + e.getMessage()); 121 | } 122 | } 123 | 124 | static byte[] parseHex(String s) { 125 | byte[] b = new byte[s.length() / 2]; 126 | for (int i=0;i 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 | --------------------------------------------------------------------------------