├── .github ├── dependabot.yml └── workflows │ └── maven.yml ├── .gitignore ├── .maven.xml ├── LICENSE ├── README.md ├── pom.xml └── src └── main └── java └── xyz └── gianlu └── zeroconf ├── ABLock.java ├── DiscoveredService.java ├── Main.java ├── Packet.java ├── PacketListener.java ├── Record.java ├── RecordA.java ├── RecordAAAA.java ├── RecordANY.java ├── RecordPTR.java ├── RecordSRV.java ├── RecordTXT.java ├── Service.java └── Zeroconf.java /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Maven build / deploy 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up JDK 1.8 17 | uses: actions/setup-java@v2 18 | with: 19 | java-version: '8' 20 | distribution: 'adopt' 21 | - name: Import GPG secrets 22 | if: ${{ !startsWith(github.ref, 'refs/pull') }} 23 | run: | 24 | echo $GPG_SECRET_KEYS | base64 --decode > gpg-private-key.txt 25 | gpg --pinentry-mode loopback --import --batch gpg-private-key.txt 26 | echo $GPG_OWNERTRUST | base64 --decode > gpg-ownertrust.txt 27 | gpg --pinentry-mode loopback --import-ownertrust --batch gpg-ownertrust.txt 28 | env: 29 | GPG_SECRET_KEYS: ${{ secrets.GPG_SECRET_KEYS }} 30 | GPG_OWNERTRUST: ${{ secrets.GPG_OWNERTRUST }} 31 | - name: Cache local Maven repository 32 | uses: actions/cache@v2 33 | with: 34 | path: ~/.m2/repository 35 | key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 36 | restore-keys: | 37 | ${{ runner.os }}-maven- 38 | - name: Build with Maven 39 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 40 | run: mvn clean test -Pdebug -B -U -Dgpg.skip -Dmaven.javadoc.skip=true 41 | - name: Deploy with Maven to OSSRH 42 | if: ${{ !startsWith(github.ref, 'refs/pull') }} 43 | run: mvn deploy --settings .maven.xml -B -U -Possrh 44 | env: 45 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 46 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 47 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 48 | - name: Deploy with Maven to GitHub packages 49 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 50 | run: mvn deploy --settings .maven.xml -B -U -Pgithub 51 | env: 52 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Create release 55 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 56 | uses: softprops/action-gh-release@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | name: ${{ github.ref }} 61 | draft: true 62 | prerelease: false 63 | files: target/zeroconf-java.jar 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | /target -------------------------------------------------------------------------------- /.maven.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | ossrh 7 | ${env.SONATYPE_USERNAME} 8 | ${env.SONATYPE_PASSWORD} 9 | 10 | 11 | 12 | github 13 | devgianlu 14 | ${env.GITHUB_TOKEN} 15 | 16 | 17 | 18 | 19 | 20 | ossrh 21 | 22 | gpg 23 | ${env.GPG_PASSPHRASE} 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 devgianlu 4 | Copyright (c) 2016 faceless2 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zeroconf-java 2 | 3 | [![Maven build / deploy](https://github.com/devgianlu/zeroconf-java/actions/workflows/maven.yml/badge.svg?branch=master)](https://github.com/devgianlu/zeroconf-java/actions/workflows/maven.yml) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/b406bddbc072496fb4508d6013cdc967)](https://www.codacy.com/manual/devgianlu/zeroconf-java?utm_source=github.com&utm_medium=referral&utm_content=devgianlu/zeroconf-java&utm_campaign=Badge_Grade) 5 | [![time tracker](https://wakatime.com/badge/github/devgianlu/zeroconf-java.svg)](https://wakatime.com/badge/github/devgianlu/zeroconf-java) 6 | [![Donate Bitcoin](https://img.shields.io/badge/donate-bitcoin-orange.svg)](https://gianlu.xyz/donate/) 7 | 8 | A pure Java implementation of the Zeroconf technology. This library reuses a lot of code from [faceless2/cu-zeroconf](https://github.com/faceless2/cu-zeroconf). 9 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | 5 | xyz.gianlu.zeroconf 6 | zeroconf 7 | 1.3.3-SNAPSHOT 8 | 9 | zeroconf-java 10 | A pure Java implementation of the Zeroconf technology 11 | 12 | https://github.com/devgianlu/zeroconf-java 13 | 14 | 15 | 16 | MIT License 17 | https://opensource.org/licenses/MIT 18 | 19 | 20 | 21 | 22 | 23 | Gianluca Altomani 24 | altomanigianluca@gmail.com 25 | https://gianlu.xyz 26 | 27 | 28 | Mike Bremford 29 | https://github.com/faceless2/ 30 | 31 | 32 | 33 | 34 | scm:git:git://github.com/devgianlu/zeroconf-java.git 35 | scm:git:ssh://github.com:devgianlu/zeroconf-java.git 36 | https://github.com/devgianlu/zeroconf-java/tree/master 37 | 38 | 39 | 40 | UTF-8 41 | 1.8 42 | 1.8 43 | 44 | 45 | 46 | zeroconf-java 47 | 48 | 49 | 50 | 51 | 52 | org.jetbrains 53 | annotations 54 | 21.0.1 55 | 56 | 57 | 58 | 59 | org.slf4j 60 | slf4j-api 61 | 1.7.30 62 | 63 | 64 | 65 | 66 | 67 | ossrh 68 | https://oss.sonatype.org/content/repositories/snapshots 69 | 70 | 71 | ossrh 72 | https://oss.sonatype.org/service/local/staging/deploy/maven2 73 | 74 | 75 | 76 | 77 | 78 | debug 79 | 80 | true 81 | 82 | debug 83 | 84 | 85 | 86 | 87 | ossrh 88 | 89 | 90 | ossrh 91 | 92 | 93 | 94 | 95 | 96 | 97 | org.apache.maven.plugins 98 | maven-source-plugin 99 | 3.2.1 100 | 101 | 102 | attach-sources 103 | 104 | jar-no-fork 105 | 106 | 107 | 108 | 109 | 110 | org.apache.maven.plugins 111 | maven-javadoc-plugin 112 | 3.3.0 113 | 114 | 115 | attach-javadocs 116 | 117 | jar 118 | 119 | 120 | 121 | 122 | true 123 | false 124 | 125 | 126 | 127 | 128 | 129 | org.sonatype.plugins 130 | nexus-staging-maven-plugin 131 | 1.6.8 132 | true 133 | 134 | ossrh 135 | https://oss.sonatype.org/ 136 | 137 | 138 | 139 | 140 | 141 | org.apache.maven.plugins 142 | maven-gpg-plugin 143 | 3.0.1 144 | 145 | 146 | sign-artifacts 147 | verify 148 | 149 | sign 150 | 151 | 152 | 153 | 154 | --pinentry-mode 155 | loopback 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | github 166 | 167 | 168 | github 169 | 170 | 171 | 172 | 173 | 174 | github 175 | https://maven.pkg.github.com/devgianlu/zeroconf-java 176 | 177 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/ABLock.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2021 erhannis. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | package xyz.gianlu.zeroconf; 25 | 26 | /** 27 | * Multistage lock customized for NIO Selectors.
28 | * Extended rationale:
29 | * See, NIO Selectors take locks on their things when they select(), and don't 30 | * release them while waiting. If a select happens before any channels are 31 | * registered, the select never completes. If a register first calls wakeup 32 | * to abort the hung select, there's a small chance the select could loop back 33 | * around again before the register actually got called. Adding a plain 34 | * synchronization around the two calls doesn't fix it, because if the wakeup 35 | * is inside the sync it won't get called and the select won't return, and if 36 | * it's outside then the loop-around could still happen and nothing's been 37 | * fixed.
38 | *
39 | * So, here's my solution. Consider the following code.
40 | *
 41 |  * private final ABLock selectorLock = new ABLock();
 42 |  *
 43 |  * ...
 44 |  *
 45 |  * // Channel registration
 46 |  * selectorLock.lockA1();
 47 |  * try {
 48 |  *   getSelector().wakeup();
 49 |  *   selectorLock.lockA2();
 50 |  *   try {
 51 |  *     channels.put(nic, channel.register(getSelector(), SelectionKey.OP_READ));
 52 |  *   } finally {
 53 |  *     selectorLock.unlockA2();
 54 |  *   }
 55 |  * } finally {
 56 |  *   selectorLock.unlockA1();
 57 |  * }
 58 |  *
 59 |  * ...
 60 |  *
 61 |  * // Select
 62 |  * Selector selector = getSelector();
 63 |  * selectorLock.lockB();
 64 |  * try {
 65 |  *   selector.select();
 66 |  * } finally {
 67 |  *   selectorLock.unlockB();
 68 |  * }
 69 |  * 
70 | *
71 | * The goal is to prevent reentering `select` between a `wakeup` and `register`. 72 | * To that end:
73 | * B blocks A2.
74 | * A1 blocks B.
75 | * A2 blocks B.
76 | * Nothing else blocks anything else.
77 | * By e.g. "A2 blocks B", I mean "if a lock exists on A2, attempts to acquire a 78 | * lock on B will block until there are no more locks on A2." So, in the 79 | * registration thread, you acquire A1 to prevent any new B locks, call wakeup 80 | * to get the Selector to drop its B lock, then lock on A2 to wait for it to do 81 | * so, then make your registration, then drop the locks (in reverse order, as is 82 | * custom). On the select thread, you acquire B, then select, then release B.
83 | * Unless I've made a goof:
84 | * You can have multiple registrations occur at once, and multiple selections 85 | * occur at once, but never both registrations and selections, and a 86 | * (lock for a) registration will not wait forever for a selection to conclude.
87 | * HOWEVER.
88 | * I've only tested this briefly. I'd want to test it a lot more before I was 89 | * very comfortable with it, and I don't really have time right now. I suspect 90 | * it's better than nothing, at least, though. 91 | * 92 | * @author erhannis 93 | */ 94 | public final class ABLock { 95 | private final Object sync = new Object(); 96 | private long a1 = 0; 97 | private long a2 = 0; 98 | private long b = 0; 99 | 100 | /** 101 | * For every call to lockA1, there must be exactly one subsequent call to unlockA1.
102 | * Attempted locks on A1 are not blocked by anything. 103 | * An existing lock on A1 blocks new locks on B. 104 | * An existing lock on A1 DOES NOT block new locks on A1 or A2. 105 | */ 106 | public void lockA1() { 107 | synchronized (sync) { 108 | a1++; 109 | sync.notifyAll(); 110 | } 111 | } 112 | 113 | public void unlockA1() { 114 | synchronized (sync) { 115 | a1--; 116 | sync.notifyAll(); 117 | } 118 | } 119 | 120 | /** 121 | * For every call to lockA2, there must be a subsequent call to unlockA2.
122 | * Attempted locks on A2 are blocked by existing locks on B, and nothing else. 123 | * An existing lock on A2 blocks new locks on B. 124 | * An existing lock on A2 DOES NOT block new locks on A1 or A2. 125 | */ 126 | public void lockA2() throws InterruptedException { 127 | synchronized (sync) { 128 | while (b > 0) 129 | sync.wait(); 130 | 131 | a2++; 132 | sync.notifyAll(); 133 | } 134 | } 135 | 136 | public void unlockA2() { 137 | synchronized (sync) { 138 | a2--; 139 | sync.notifyAll(); 140 | } 141 | } 142 | 143 | /** 144 | * For every call to lockB, there must be a subsequent call to unlockB.
145 | * Attempted locks on B are blocked by existing locks on A1 and A2, and nothing else. 146 | * An existing lock on B blocks new locks on A2. 147 | * An existing lock on B DOES NOT block new locks on A1 or B. 148 | */ 149 | public void lockB() throws InterruptedException { 150 | synchronized (sync) { 151 | while (a1 > 0 || a2 > 0) 152 | sync.wait(); 153 | 154 | b++; 155 | sync.notifyAll(); 156 | } 157 | } 158 | 159 | public void unlockB() { 160 | synchronized (sync) { 161 | b--; 162 | sync.notifyAll(); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/DiscoveredService.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.net.InetSocketAddress; 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | /** 10 | * @author devgianlu 11 | */ 12 | public final class DiscoveredService { 13 | public final String target; 14 | public final int port; 15 | public final String name; 16 | public final String service; 17 | public final String protocol; 18 | public final String domain; 19 | public final String serviceName; 20 | private final long expiration; 21 | private final List relatedRecords = new ArrayList<>(5); 22 | 23 | DiscoveredService(@NotNull RecordSRV record) { 24 | expiration = System.currentTimeMillis() + record.ttl * 1000L; 25 | 26 | target = record.getTarget(); 27 | port = record.getPort(); 28 | serviceName = record.getName(); 29 | 30 | String[] split = serviceName.split("\\."); 31 | if (split.length != 4) throw new IllegalArgumentException("Invalid service name: " + record.getName()); 32 | 33 | name = split[0]; 34 | service = split[1]; 35 | protocol = split[2]; 36 | domain = "." + split[3]; 37 | } 38 | 39 | public boolean isExpired() { 40 | return System.currentTimeMillis() > expiration; 41 | } 42 | 43 | void addRelatedRecord(@NotNull Record record) { 44 | relatedRecords.removeIf(Record::isExpired); 45 | relatedRecords.add(record); 46 | } 47 | 48 | @NotNull 49 | public List getRelatedRecords() { 50 | return new ArrayList<>(relatedRecords); 51 | } 52 | 53 | @NotNull 54 | public List getAddresses() { 55 | List list = new ArrayList<>(3); 56 | for (Record record : relatedRecords) { 57 | if (record instanceof RecordSRV) 58 | list.add(new InetSocketAddress(((RecordSRV) record).getTarget(), ((RecordSRV) record).getPort())); 59 | } 60 | return list; 61 | } 62 | 63 | @Override 64 | public boolean equals(Object o) { 65 | if (this == o) return true; 66 | if (o == null || getClass() != o.getClass()) return false; 67 | DiscoveredService that = (DiscoveredService) o; 68 | if (port != that.port) return false; 69 | if (!target.equals(that.target)) return false; 70 | if (!name.equals(that.name)) return false; 71 | if (!service.equals(that.service)) return false; 72 | if (!protocol.equals(that.protocol)) return false; 73 | return domain.equals(that.domain); 74 | } 75 | 76 | @Override 77 | public int hashCode() { 78 | int result = target.hashCode(); 79 | result = 31 * result + port; 80 | result = 31 * result + name.hashCode(); 81 | result = 31 * result + service.hashCode(); 82 | result = 31 * result + protocol.hashCode(); 83 | result = 31 * result + domain.hashCode(); 84 | return result; 85 | } 86 | 87 | @Override 88 | public String toString() { 89 | return "DiscoveredService{" + 90 | "target='" + target + '\'' + 91 | ", port=" + port + 92 | ", name='" + name + '\'' + 93 | ", service='" + service + '\'' + 94 | ", protocol='" + protocol + '\'' + 95 | ", domain='" + domain + '\'' + 96 | '}'; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/Main.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import java.io.IOException; 4 | 5 | /** 6 | * @author Gianlu 7 | */ 8 | public class Main { 9 | public static void main(String[] args) throws IOException { 10 | Zeroconf zeroconf = new Zeroconf(); 11 | zeroconf.setUseIpv4(true) 12 | .setUseIpv6(false) 13 | .addAllNetworkInterfaces(); 14 | 15 | Runtime.getRuntime().addShutdownHook(new Thread(zeroconf::close)); 16 | 17 | // Announce service 18 | Service service = new Service(args[0], args[1], Integer.parseInt(args[2])); 19 | zeroconf.announce(service); 20 | 21 | // Start discovering 22 | Zeroconf.DiscoveredServices services = zeroconf.discover(args[1], "tcp", ".local"); 23 | while (true) { 24 | System.out.println(services.getServices()); 25 | 26 | try { 27 | Thread.sleep(1000); 28 | } catch (InterruptedException ex) { 29 | break; 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/Packet.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import java.net.InetSocketAddress; 4 | import java.nio.ByteBuffer; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | 8 | /** 9 | * A Service Discovery Packet. This class is only of interest to developers. 10 | */ 11 | public final class Packet { 12 | private static final int FLAG_RESPONSE = 15; 13 | private static final int FLAG_AA = 10; 14 | private final List questions; 15 | private final List answers; 16 | private final List authorities; 17 | private final List additionals; 18 | private int id; 19 | private int flags; 20 | private InetSocketAddress address; 21 | 22 | Packet() { 23 | this(0); 24 | } 25 | 26 | Packet(int id) { 27 | this.id = id; 28 | questions = new ArrayList<>(); 29 | answers = new ArrayList<>(); 30 | authorities = new ArrayList<>(); 31 | additionals = new ArrayList<>(); 32 | setResponse(true); 33 | } 34 | 35 | InetSocketAddress getAddress() { 36 | return address; 37 | } 38 | 39 | void setAddress(InetSocketAddress address) { 40 | this.address = address; 41 | } 42 | 43 | int getID() { 44 | return id; 45 | } 46 | 47 | /** 48 | * Return true if it's a response, false if it's a query 49 | */ 50 | boolean isResponse() { 51 | return isFlag(FLAG_RESPONSE); 52 | } 53 | 54 | void setResponse(boolean on) { 55 | setFlag(FLAG_RESPONSE, on); 56 | } 57 | 58 | boolean isAuthoritative() { 59 | return isFlag(FLAG_AA); 60 | } 61 | 62 | void setAuthoritative(boolean on) { 63 | setFlag(FLAG_AA, on); 64 | } 65 | 66 | private boolean isFlag(int flag) { 67 | return (flags & (1 << flag)) != 0; 68 | } 69 | 70 | private void setFlag(int flag, boolean on) { 71 | if (on) flags |= (1 << flag); 72 | else flags &= ~(1 << flag); 73 | } 74 | 75 | void read(ByteBuffer in, InetSocketAddress address) { 76 | byte[] q = new byte[in.remaining()]; 77 | in.get(q); 78 | in.position(0); 79 | 80 | this.address = address; 81 | id = in.getShort() & 0xFFFF; 82 | flags = in.getShort() & 0xFFFF; 83 | int numquestions = in.getShort() & 0xFFFF; 84 | int numanswers = in.getShort() & 0xFFFF; 85 | int numauthorities = in.getShort() & 0xFFFF; 86 | int numadditionals = in.getShort() & 0xFFFF; 87 | 88 | for (int i = 0; i < numquestions; i++) { 89 | questions.add(Record.readQuestion(in)); 90 | } 91 | 92 | for (int i = 0; i < numanswers; i++) { 93 | if (in.hasRemaining()) answers.add(Record.readAnswer(in)); 94 | } 95 | 96 | for (int i = 0; i < numauthorities; i++) { 97 | if (in.hasRemaining()) authorities.add(Record.readAnswer(in)); 98 | } 99 | 100 | for (int i = 0; i < numadditionals; i++) { 101 | if (in.hasRemaining()) additionals.add(Record.readAnswer(in)); 102 | } 103 | } 104 | 105 | void write(ByteBuffer out) { 106 | out.putShort((short) id); 107 | out.putShort((short) flags); 108 | out.putShort((short) questions.size()); 109 | out.putShort((short) answers.size()); 110 | out.putShort((short) authorities.size()); 111 | out.putShort((short) additionals.size()); 112 | for (Record r : questions) r.write(out, this); 113 | for (Record r : answers) r.write(out, this); 114 | for (Record r : authorities) r.write(out, this); 115 | for (Record r : additionals) r.write(out, this); 116 | } 117 | 118 | @Override 119 | public String toString() { 120 | return "Packet{" + 121 | "id=" + id + 122 | ", flags=" + flags + 123 | ", questions=" + questions + 124 | ", answers=" + answers + 125 | ", authorities=" + authorities + 126 | ", additionals=" + additionals + 127 | ", address=" + address + 128 | '}'; 129 | } 130 | 131 | List getQuestions() { 132 | return questions; 133 | } 134 | 135 | List getAnswers() { 136 | return answers; 137 | } 138 | 139 | List getAdditionals() { 140 | return additionals; 141 | } 142 | 143 | void addAnswer(Record record) { 144 | answers.add(record); 145 | } 146 | 147 | void addQuestion(Record record) { 148 | questions.add(record); 149 | } 150 | 151 | void addAdditional(Record record) { 152 | additionals.add(record); 153 | } 154 | 155 | void addAuthority(Record record) { 156 | authorities.add(record); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/PacketListener.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | /** 6 | * An interface that will be notified of a packet transmission 7 | * 8 | * @see Zeroconf#addReceiveListener 9 | * @see Zeroconf#addSendListener 10 | */ 11 | public interface PacketListener { 12 | void packetEvent(@NotNull Packet packet); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/Record.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import java.nio.ByteBuffer; 4 | import java.util.Arrays; 5 | 6 | /** 7 | * Base class for a DNS record. Written entirely without the benefit of specifications. 8 | */ 9 | public class Record { 10 | static final int TYPE_A = 0x01; 11 | static final int TYPE_PTR = 0x0C; 12 | static final int TYPE_CNAME = 0x05; 13 | static final int TYPE_TXT = 0x10; 14 | static final int TYPE_AAAA = 0x1C; 15 | static final int TYPE_SRV = 0x21; 16 | static final int TYPE_NSEC = 0x2F; 17 | static final int TYPE_ANY = 0xFF; 18 | private final long timestamp; 19 | private final int type; 20 | protected int ttl; 21 | private String name; 22 | private int clazz; 23 | private byte[] data; 24 | 25 | Record(int type) { 26 | this.timestamp = System.currentTimeMillis(); 27 | this.type = type & 0xFFFF; 28 | setTTL(4500); 29 | this.clazz = 1; 30 | } 31 | 32 | protected static int writeName(String name, ByteBuffer out, Packet packet) { 33 | int len = name.length(); 34 | int start = 0; 35 | for (int i = 0; i <= len; i++) { 36 | char c = i == len ? '.' : name.charAt(i); 37 | if (c == '.') { 38 | out.put((byte) (i - start)); 39 | for (int j = start; j < i; j++) 40 | out.put((byte) name.charAt(j)); 41 | 42 | start = i + 1; 43 | } 44 | } 45 | 46 | out.put((byte) 0); 47 | return name.length() + 2; 48 | } 49 | 50 | protected static String readName(ByteBuffer in) { 51 | StringBuilder sb = new StringBuilder(); 52 | int len; 53 | while ((len = (in.get() & 0xFF)) > 0) { 54 | if (len >= 0x40) { 55 | int off = ((len & 0x3F) << 8) | (in.get() & 0xFF); // Offset from start of packet 56 | int oldPos = in.position(); 57 | in.position(off); 58 | if (sb.length() > 0) sb.append('.'); 59 | sb.append(readName(in)); 60 | in.position(oldPos); 61 | break; 62 | } else { 63 | if (sb.length() > 0) sb.append('.'); 64 | while (len-- > 0) sb.append((char) (in.get() & 0xFF)); 65 | } 66 | } 67 | return sb.toString(); 68 | } 69 | 70 | static Record readAnswer(ByteBuffer in) { 71 | String name = readName(in); 72 | int type = in.getShort() & 0xFFFF; 73 | Record record = getInstance(type); 74 | record.setName(name); 75 | record.clazz = in.getShort() & 0xFFFF; 76 | record.ttl = in.getInt(); 77 | int len = in.getShort() & 0xFFFF; 78 | record.readData(len, in); 79 | return record; 80 | } 81 | 82 | static Record readQuestion(ByteBuffer in) { 83 | String name = readName(in); 84 | int type = in.getShort() & 0xFFFF; 85 | Record record = getInstance(type); 86 | record.setName(name); 87 | record.clazz = in.getShort() & 0xFFFF; 88 | return record; 89 | } 90 | 91 | private static Record getInstance(int type) { 92 | switch (type) { 93 | case TYPE_A: 94 | return new RecordA(); 95 | case TYPE_AAAA: 96 | return new RecordAAAA(); 97 | case TYPE_SRV: 98 | return new RecordSRV(); 99 | case TYPE_PTR: 100 | return new RecordPTR(); 101 | case TYPE_TXT: 102 | return new RecordTXT(); 103 | case TYPE_ANY: 104 | return new RecordANY(); 105 | default: 106 | return new Record(type); 107 | } 108 | } 109 | 110 | public String getName() { 111 | return name; 112 | } 113 | 114 | Record setName(String name) { 115 | this.name = name; 116 | return this; 117 | } 118 | 119 | public int getType() { 120 | return type; 121 | } 122 | 123 | Record setTTL(int ttl) { 124 | this.ttl = ttl; 125 | return this; 126 | } 127 | 128 | protected void readData(int len, ByteBuffer in) { 129 | data = new byte[len]; 130 | in.get(data); 131 | } 132 | 133 | protected int writeData(ByteBuffer out, Packet packet) { 134 | if (data != null) { 135 | out.put(data); 136 | return data.length; 137 | } else { 138 | return -1; 139 | } 140 | } 141 | 142 | public boolean isUnicastQuery() { 143 | return (clazz & 0x80) != 0; 144 | } 145 | 146 | public boolean isExpired() { 147 | return System.currentTimeMillis() > timestamp + ttl * 1000L; 148 | } 149 | 150 | @Override 151 | public String toString() { 152 | return "Record{" + 153 | "type=" + type + 154 | ", ttl=" + ttl + 155 | ", name='" + name + '\'' + 156 | ", clazz=" + clazz + 157 | ", data=" + Arrays.toString(data) + 158 | '}'; 159 | } 160 | 161 | void write(ByteBuffer out, Packet packet) { 162 | writeName(name, out, packet); 163 | out.putShort((short) type); 164 | out.putShort((short) clazz); 165 | out.putInt(ttl); 166 | int pos = out.position(); 167 | out.putShort((short) 0); 168 | int len = writeData(out, packet); 169 | if (len > 0) out.putShort(pos, (short) len); 170 | else out.position(pos); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordA.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.io.IOException; 7 | import java.net.Inet4Address; 8 | import java.net.InetAddress; 9 | import java.nio.ByteBuffer; 10 | 11 | public final class RecordA extends Record { 12 | private byte[] address; 13 | 14 | RecordA() { 15 | super(TYPE_A); 16 | } 17 | 18 | RecordA(String name, Inet4Address address) { 19 | this(); 20 | setName(name); 21 | setTTL(120); 22 | this.address = address.getAddress(); 23 | } 24 | 25 | @Override 26 | protected void readData(int len, ByteBuffer in) { 27 | address = new byte[len]; 28 | in.get(address); 29 | } 30 | 31 | @Override 32 | protected int writeData(ByteBuffer out, Packet packet) { 33 | if (address != null) { 34 | out.put(address); 35 | return address.length; 36 | } else { 37 | return -1; 38 | } 39 | } 40 | 41 | @Nullable 42 | public Inet4Address getAddress() { 43 | try { 44 | return address == null ? null : (Inet4Address) InetAddress.getByAddress(address); 45 | } catch (IOException ex) { 46 | throw new IllegalStateException(ex); 47 | } 48 | } 49 | 50 | @Override 51 | @NotNull 52 | public String toString() { 53 | return "{type:a, name:\"" + getName() + "\", address:\"" + getAddress() + "\"}"; 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordAAAA.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.io.IOException; 7 | import java.net.Inet6Address; 8 | import java.net.InetAddress; 9 | import java.nio.ByteBuffer; 10 | 11 | public final class RecordAAAA extends Record { 12 | private byte[] address; 13 | 14 | RecordAAAA() { 15 | super(TYPE_AAAA); 16 | } 17 | 18 | RecordAAAA(String name, Inet6Address value) { 19 | this(); 20 | setName(name); 21 | setTTL(120); 22 | this.address = value.getAddress(); 23 | } 24 | 25 | @Override 26 | protected void readData(int len, ByteBuffer in) { 27 | address = new byte[len]; 28 | in.get(address); 29 | } 30 | 31 | @Override 32 | protected int writeData(ByteBuffer out, Packet packet) { 33 | if (address != null) { 34 | out.put(address); 35 | return address.length; 36 | } else { 37 | return -1; 38 | } 39 | } 40 | 41 | @Nullable 42 | public Inet6Address getAddress() { 43 | try { 44 | return address == null ? null : (Inet6Address) InetAddress.getByAddress(address); 45 | } catch (IOException ex) { 46 | throw new IllegalStateException(ex); 47 | } 48 | } 49 | 50 | @Override 51 | @NotNull 52 | public String toString() { 53 | return "{type:aaaa, name:\"" + getName() + "\", address:\"" + getAddress() + "\"}"; 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordANY.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.ByteBuffer; 6 | 7 | public final class RecordANY extends Record { 8 | 9 | RecordANY() { 10 | super(TYPE_ANY); 11 | } 12 | 13 | RecordANY(String name) { 14 | this(); 15 | setName(name); 16 | } 17 | 18 | @Override 19 | protected void readData(int len, ByteBuffer in) { 20 | throw new IllegalStateException(); 21 | } 22 | 23 | @Override 24 | protected int writeData(ByteBuffer out, Packet packet) { 25 | return -1; 26 | } 27 | 28 | @Override 29 | @NotNull 30 | public String toString() { 31 | return "{type:any, name:\"" + getName() + "\"}"; 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordPTR.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.ByteBuffer; 6 | 7 | public final class RecordPTR extends Record { 8 | private String value; 9 | 10 | RecordPTR() { 11 | super(TYPE_PTR); 12 | } 13 | 14 | RecordPTR(String name, String value) { 15 | this(); 16 | setName(name); 17 | this.value = value; 18 | } 19 | 20 | /** 21 | * For queries 22 | */ 23 | RecordPTR(String name) { 24 | this(); 25 | setName(name); 26 | } 27 | 28 | @Override 29 | protected void readData(int len, ByteBuffer in) { 30 | value = readName(in); 31 | } 32 | 33 | @Override 34 | protected int writeData(ByteBuffer out, Packet packet) { 35 | return value != null ? writeName(value, out, packet) : -1; 36 | } 37 | 38 | public String getValue() { 39 | return value; 40 | } 41 | 42 | @Override 43 | @NotNull 44 | public String toString() { 45 | StringBuilder sb = new StringBuilder(); 46 | sb.append("{type:ptr, name:\""); 47 | sb.append(getName()); 48 | sb.append('\"'); 49 | if (value != null) { 50 | sb.append(", value:\""); 51 | sb.append(getValue()); 52 | sb.append('\"'); 53 | } 54 | sb.append('}'); 55 | return sb.toString(); 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordSRV.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.ByteBuffer; 6 | 7 | public final class RecordSRV extends Record { 8 | private int priority; 9 | private int weight; 10 | private int port; 11 | private String target; 12 | 13 | RecordSRV() { 14 | super(TYPE_SRV); 15 | } 16 | 17 | RecordSRV(String name, String target, int port) { 18 | this(); 19 | setName(name); 20 | this.target = target; 21 | this.port = port; 22 | } 23 | 24 | @Override 25 | protected void readData(int len, ByteBuffer in) { 26 | priority = in.getShort() & 0xFFFF; 27 | weight = in.getShort() & 0xFFFF; 28 | port = in.getShort() & 0xFFFF; 29 | target = readName(in); 30 | } 31 | 32 | @Override 33 | protected int writeData(ByteBuffer out, Packet packet) { 34 | if (target != null) { 35 | out.putShort((short) priority); 36 | out.putShort((short) weight); 37 | out.putShort((short) port); 38 | return 6 + writeName(target, out, packet); 39 | } else { 40 | return -1; 41 | } 42 | } 43 | 44 | public int getPriority() { 45 | return priority; 46 | } 47 | 48 | public int getWeight() { 49 | return weight; 50 | } 51 | 52 | public int getPort() { 53 | return port; 54 | } 55 | 56 | public String getTarget() { 57 | return target; 58 | } 59 | 60 | @Override 61 | @NotNull 62 | public String toString() { 63 | StringBuilder sb = new StringBuilder(); 64 | sb.append("{type:srv, name:\""); 65 | sb.append(getName()); 66 | sb.append('\"'); 67 | if (target != null) { 68 | sb.append(", priority:"); 69 | sb.append(getPriority()); 70 | sb.append(", weight:"); 71 | sb.append(getWeight()); 72 | sb.append(", port:"); 73 | sb.append(getPort()); 74 | sb.append(", target:\""); 75 | sb.append(getTarget()); 76 | sb.append('\"'); 77 | } 78 | sb.append('}'); 79 | return sb.toString(); 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/RecordTXT.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.nio.ByteBuffer; 6 | import java.nio.charset.StandardCharsets; 7 | import java.util.LinkedHashMap; 8 | import java.util.Map; 9 | 10 | public final class RecordTXT extends Record { 11 | private Map values; 12 | 13 | RecordTXT() { 14 | super(TYPE_TXT); 15 | } 16 | 17 | RecordTXT(String name, Map values) { 18 | this(); 19 | setName(name); 20 | this.values = values; 21 | } 22 | 23 | RecordTXT(String name, String map) { 24 | this(); 25 | setName(name); 26 | values = new LinkedHashMap<>(); 27 | String[] q = map.split(", *"); 28 | for (String s : q) { 29 | String[] kv = s.split("="); 30 | if (kv.length == 2) values.put(kv[0], kv[1]); 31 | } 32 | } 33 | 34 | @Override 35 | protected void readData(int len, ByteBuffer in) { 36 | int end = in.position() + len; 37 | values = new LinkedHashMap<>(); 38 | while (in.position() < end) { 39 | int slen = in.get() & 0xFF; 40 | StringBuilder sb = new StringBuilder(slen); 41 | for (int i = 0; i < slen; i++) sb.append((char) in.get()); 42 | 43 | String value = sb.toString(); 44 | int ix = value.indexOf("="); 45 | if (ix > 0) values.put(value.substring(0, ix), value.substring(ix + 1)); 46 | } 47 | } 48 | 49 | @Override 50 | protected int writeData(ByteBuffer out, Packet packet) { 51 | if (values != null) { 52 | int len = 0; 53 | for (Map.Entry e : values.entrySet()) { 54 | String value = e.getKey() + "=" + e.getValue(); 55 | out.put((byte) value.length()); 56 | out.put(value.getBytes(StandardCharsets.UTF_8)); 57 | len += value.length() + 1; 58 | } 59 | return len; 60 | } else { 61 | return -1; 62 | } 63 | } 64 | 65 | public Map getValues() { 66 | return values; 67 | } 68 | 69 | @Override 70 | @NotNull 71 | public String toString() { 72 | StringBuilder sb = new StringBuilder(); 73 | sb.append("{type:text, name:\""); 74 | sb.append(getName()); 75 | sb.append("\""); 76 | if (values != null) { 77 | sb.append(", values:"); 78 | sb.append(getValues()); 79 | } 80 | sb.append('}'); 81 | return sb.toString(); 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/Service.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.net.Inet4Address; 6 | import java.net.Inet6Address; 7 | import java.net.InetAddress; 8 | import java.util.*; 9 | 10 | /** 11 | * Service represents a Service to be announced by the Zeroconf class. 12 | */ 13 | @SuppressWarnings({"unused", "WeakerAccess"}) 14 | public final class Service { 15 | private final String alias; 16 | private final String service; 17 | private final int port; 18 | private final Map text; 19 | private final List addresses = new ArrayList<>(); 20 | private String domain; 21 | private String protocol; 22 | private String host; 23 | 24 | /** 25 | * Create a new {@link Service} to be announced by this object. 26 | * 27 | * A JmDNS `type` field of "_foobar._tcp.local." would be specified here as a `service` param of "foobar". 28 | * 29 | * @param alias the service alias, eg "My Web Server" 30 | * @param service the service type, eg "http" 31 | * @param port the service port 32 | */ 33 | public Service(@NotNull String alias, @NotNull String service, int port) { 34 | this.alias = alias; 35 | for (int i = 0; i < alias.length(); i++) { 36 | char c = alias.charAt(i); 37 | if (c < 0x20 || c == 0x7F) 38 | throw new IllegalArgumentException(alias); 39 | } 40 | 41 | this.service = service; 42 | this.port = port; 43 | this.protocol = "tcp"; 44 | this.text = new LinkedHashMap<>(); 45 | } 46 | 47 | private static void esc(String in, StringBuilder out) { 48 | for (int i = 0; i < in.length(); i++) { 49 | char c = in.charAt(i); 50 | if (c == '.' || c == '\\') out.append('\\'); 51 | out.append(c); 52 | } 53 | } 54 | 55 | @Override 56 | public String toString() { 57 | return "Service{" + 58 | "alias='" + alias + '\'' + 59 | ", service='" + service + '\'' + 60 | ", port=" + port + 61 | ", text=" + text + 62 | ", addresses=" + addresses + 63 | ", domain='" + domain + '\'' + 64 | ", protocol='" + protocol + '\'' + 65 | ", host='" + host + '\'' + 66 | '}'; 67 | } 68 | 69 | /** 70 | * Set the protocol, which can be one of "tcp" (the default) or "udp" 71 | * 72 | * @param protocol the protocol 73 | * @return this 74 | */ 75 | @NotNull 76 | public Service setProtocol(String protocol) { 77 | if ("tcp".equals(protocol) || "udp".equals(protocol)) this.protocol = protocol; 78 | else throw new IllegalArgumentException(protocol); 79 | return this; 80 | } 81 | 82 | /** 83 | * @return the domain 84 | */ 85 | public String getDomain() { 86 | return domain; 87 | } 88 | 89 | /** 90 | * Set the domain, which defaults to {@link Zeroconf#getDomain} and must begin with "." 91 | * 92 | * @param domain the domain 93 | * @return this 94 | */ 95 | @NotNull 96 | public Service setDomain(String domain) { 97 | if (domain == null || domain.length() < 2 || domain.charAt(0) != '.') 98 | throw new IllegalArgumentException(domain); 99 | 100 | this.domain = domain; 101 | return this; 102 | } 103 | 104 | /** 105 | * @return the host 106 | */ 107 | public String getHost() { 108 | return host; 109 | } 110 | 111 | /** 112 | * Set the host which is hosting this service, which defaults to {@link Zeroconf#getLocalHostName}. 113 | * It is possible to announce a service on a non-local host 114 | * 115 | * @param host the host 116 | * @return this 117 | */ 118 | @NotNull 119 | public Service setHost(String host) { 120 | this.host = host; 121 | return this; 122 | } 123 | 124 | /** 125 | * Set the Text record to go with this Service, which is of the form "key1=value1, key2=value2" 126 | * Any existing Text records are replaced 127 | * 128 | * @param text the text 129 | * @return this 130 | */ 131 | @NotNull 132 | public Service setText(String text) { 133 | this.text.clear(); 134 | String[] q = text.split(", *"); 135 | for (String s : q) { 136 | String[] r = s.split("="); 137 | if (r.length == 2) this.text.put(r[0], r[1]); 138 | else throw new IllegalArgumentException(text); 139 | } 140 | 141 | return this; 142 | } 143 | 144 | /** 145 | * Set the Text record to go with this Service, which is specified as a Map of keys and values 146 | * Any existing Text records are replaced 147 | * 148 | * @param text the text 149 | * @return this 150 | */ 151 | @NotNull 152 | public Service setText(Map text) { 153 | this.text.clear(); 154 | this.text.putAll(text); 155 | return this; 156 | } 157 | 158 | /** 159 | * Add a Text record entry to go with this Service to the existing list of Text record entries. 160 | * 161 | * @param key the text key 162 | * @param value the corresponding value. 163 | * @return this 164 | */ 165 | @NotNull 166 | public Service putText(String key, String value) { 167 | this.text.put(key, value); 168 | return this; 169 | } 170 | 171 | /** 172 | * Add an InetAddress to the list of addresses for this service. By default they are taken 173 | * from {@link Zeroconf#getLocalAddresses}, as the hostname is taken from {@link Zeroconf#getLocalHostName}. 174 | * If advertising a Service on a non-local host, the addresses must be set manually using this 175 | * method. 176 | * 177 | * @param address the InetAddress this Service resides on 178 | * @return this 179 | */ 180 | @NotNull 181 | public Service addAddress(@NotNull InetAddress address) { 182 | addresses.add(address); 183 | return this; 184 | } 185 | 186 | @NotNull 187 | public Service addAddresses(Collection addresses) { 188 | this.addresses.addAll(addresses); 189 | return this; 190 | } 191 | 192 | /** 193 | * @return whether the service has addresses to announce 194 | */ 195 | public boolean hasAddresses() { 196 | return !addresses.isEmpty(); 197 | } 198 | 199 | /** 200 | * @return the alias 201 | */ 202 | @NotNull 203 | public String getAlias() { 204 | return alias; 205 | } 206 | 207 | /** 208 | * Return the instance-name for this service. This is the "fully qualified domain name" of 209 | * the service and looks something like "My Service._http._tcp.local" 210 | * 211 | * @return the instance name 212 | */ 213 | @NotNull 214 | public String getInstanceName() { 215 | StringBuilder sb = new StringBuilder(); 216 | esc(alias, sb); 217 | sb.append("._"); 218 | esc(service, sb); 219 | sb.append("._"); 220 | sb.append(protocol); 221 | sb.append(domain); 222 | return sb.toString(); 223 | } 224 | 225 | /** 226 | * Return the service-name for this service. This is the "domain name" of 227 | * the service and looks something like "._http._tcp.local" - i.e. the InstanceName 228 | * without the alias. Note the rather ambiguous term "service name" comes from the spec. 229 | * 230 | * @return the service name 231 | */ 232 | @NotNull 233 | public String getServiceName() { 234 | StringBuilder sb = new StringBuilder(); 235 | sb.append('_'); 236 | esc(service, sb); 237 | sb.append("._"); 238 | sb.append(protocol); 239 | sb.append(domain); 240 | return sb.toString(); 241 | } 242 | 243 | @NotNull 244 | Packet getPacket() { 245 | Packet packet = new Packet(); 246 | packet.setAuthoritative(true); 247 | 248 | String fqdn = getInstanceName(); 249 | String ptrname = getServiceName(); 250 | 251 | packet.addAnswer(new RecordPTR(ptrname, fqdn).setTTL(28800)); 252 | packet.addAnswer(new RecordSRV(fqdn, host, port).setTTL(120)); 253 | if (!text.isEmpty()) packet.addAnswer(new RecordTXT(fqdn, text).setTTL(120)); 254 | 255 | for (InetAddress address : addresses) { 256 | if (address instanceof Inet4Address) 257 | packet.addAnswer(new RecordA(host, (Inet4Address) address)); 258 | else if (address instanceof Inet6Address) 259 | packet.addAnswer(new RecordAAAA(host, (Inet6Address) address)); 260 | } 261 | 262 | return packet; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/main/java/xyz/gianlu/zeroconf/Zeroconf.java: -------------------------------------------------------------------------------- 1 | package xyz.gianlu.zeroconf; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | 8 | import java.io.Closeable; 9 | import java.io.IOException; 10 | import java.math.BigInteger; 11 | import java.net.*; 12 | import java.nio.ByteBuffer; 13 | import java.nio.ByteOrder; 14 | import java.nio.channels.DatagramChannel; 15 | import java.nio.channels.SelectionKey; 16 | import java.nio.channels.Selector; 17 | import java.util.*; 18 | import java.util.concurrent.CopyOnWriteArrayList; 19 | import java.util.concurrent.ThreadLocalRandom; 20 | import java.util.concurrent.TimeUnit; 21 | import java.util.concurrent.atomic.AtomicBoolean; 22 | 23 | /** 24 | *

25 | * This is the root class for the Service Discovery object. 26 | *

27 | * This class does not have any fancy hooks to clean up. The {@link #close} method should be called when the 28 | * class is to be discarded, but failing to do so won't break anything. Announced services will expire in 29 | * their own time, which is typically two minutes - although during this time, conforming implementations 30 | * should refuse to republish any duplicate services. 31 | *

32 | */ 33 | @SuppressWarnings({"unused", "WeakerAccess"}) 34 | public final class Zeroconf implements Closeable { 35 | private static final String DISCOVERY = "_services._dns-sd._udp.local"; 36 | private static final InetSocketAddress BROADCAST4; 37 | private static final InetSocketAddress BROADCAST6; 38 | private static final Logger LOGGER = LoggerFactory.getLogger(Zeroconf.class); 39 | 40 | static { 41 | try { 42 | BROADCAST4 = new InetSocketAddress(InetAddress.getByName("224.0.0.251"), 5353); 43 | BROADCAST6 = new InetSocketAddress(InetAddress.getByName("FF02::FB"), 5353); 44 | } catch (IOException ex) { 45 | throw new IllegalStateException(ex); 46 | } 47 | } 48 | 49 | private final ListenerThread thread; 50 | private final List registry; 51 | private final Collection services; 52 | private final CopyOnWriteArrayList discoverers; 53 | private final CopyOnWriteArrayList receiveListeners; 54 | private final CopyOnWriteArrayList sendListeners; 55 | private boolean useIpv4 = true; 56 | private boolean useIpv6 = true; 57 | private String hostname; 58 | private String domain; 59 | 60 | /** 61 | * Create a new Zeroconf object 62 | */ 63 | public Zeroconf() { 64 | setDomain(".local"); 65 | 66 | try { 67 | setLocalHostName(getOrCreateLocalHostName()); 68 | } catch (IOException ignored) { 69 | } 70 | 71 | receiveListeners = new CopyOnWriteArrayList<>(); 72 | sendListeners = new CopyOnWriteArrayList<>(); 73 | discoverers = new CopyOnWriteArrayList<>(); 74 | thread = new ListenerThread(); 75 | registry = new ArrayList<>(); 76 | services = new HashSet<>(); 77 | } 78 | 79 | @NotNull 80 | public static String getOrCreateLocalHostName() throws UnknownHostException { 81 | String host = InetAddress.getLocalHost().getHostName(); 82 | if (Objects.equals(host, "localhost")) { 83 | host = Base64.getEncoder().encodeToString(BigInteger.valueOf(ThreadLocalRandom.current().nextLong()).toByteArray()) + ".local"; 84 | LOGGER.warn("Hostname cannot be `localhost`, temporary hostname is {}.", host); 85 | return host; 86 | } 87 | 88 | return host; 89 | } 90 | 91 | @NotNull 92 | public Zeroconf setUseIpv4(boolean ipv4) { 93 | this.useIpv4 = ipv4; 94 | return this; 95 | } 96 | 97 | @NotNull 98 | public Zeroconf setUseIpv6(boolean ipv6) { 99 | this.useIpv6 = ipv6; 100 | return this; 101 | } 102 | 103 | /** 104 | * Close down this Zeroconf object and cancel any services it has advertised. 105 | */ 106 | @Override 107 | public void close() { 108 | for (Service service : new ArrayList<>(services)) 109 | unannounce(service); 110 | 111 | services.clear(); 112 | 113 | for (DiscoveredServices discoverer : new ArrayList<>(discoverers)) 114 | discoverer.stop(); 115 | 116 | discoverers.clear(); 117 | 118 | try { 119 | thread.close(); 120 | } catch (InterruptedException ignored) { 121 | } 122 | } 123 | 124 | /** 125 | * Add a {@link PacketListener} to the list of listeners notified when a Service Discovery 126 | * Packet is received 127 | * 128 | * @param listener the listener 129 | * @return this Zeroconf 130 | */ 131 | @NotNull 132 | public Zeroconf addReceiveListener(@NotNull PacketListener listener) { 133 | receiveListeners.addIfAbsent(listener); 134 | return this; 135 | } 136 | 137 | /** 138 | * Remove a previously added {@link PacketListener} from the list of listeners notified when 139 | * a Service Discovery Packet is received 140 | * 141 | * @param listener the listener 142 | * @return this Zeroconf 143 | */ 144 | @NotNull 145 | public Zeroconf removeReceiveListener(@NotNull PacketListener listener) { 146 | receiveListeners.remove(listener); 147 | return this; 148 | } 149 | 150 | /** 151 | * Add a {@link PacketListener} to the list of listeners notified when a Service 152 | * Discovery Packet is sent 153 | * 154 | * @param listener the listener 155 | * @return this Zeroconf 156 | */ 157 | @NotNull 158 | public Zeroconf addSendListener(@NotNull PacketListener listener) { 159 | sendListeners.addIfAbsent(listener); 160 | return this; 161 | } 162 | 163 | /** 164 | * Remove a previously added {@link PacketListener} from the list of listeners notified 165 | * when a Service Discovery Packet is sent 166 | * 167 | * @param listener the listener 168 | * @return this Zeroconf 169 | */ 170 | @NotNull 171 | public Zeroconf removeSendListener(@NotNull PacketListener listener) { 172 | sendListeners.remove(listener); 173 | return this; 174 | } 175 | 176 | /** 177 | *

178 | * Add a {@link NetworkInterface} to the list of interfaces that send and received Service 179 | * Discovery Packets. The interface should be up, should 180 | * {@link NetworkInterface#supportsMulticast} support Multicast and not be a 181 | * {@link NetworkInterface#isLoopback Loopback interface}. However, adding a 182 | * NetworkInterface that does not match this requirement will not throw an Exception - it 183 | * will just be ignored, as will any attempt to add a NetworkInterface that has already 184 | * been added. 185 | *

186 | * All the interface's IP addresses will be added to the list of 187 | * {@link #getLocalAddresses local addresses}. 188 | * If the interface's addresses change, or the interface is otherwise modified in a 189 | * significant way, then it should be removed and re-added to this object. This is 190 | * not done automatically. 191 | *

192 | * 193 | * @param nic a NetworkInterface 194 | * @return this 195 | * @throws IOException if something goes wrong in an I/O way 196 | */ 197 | @NotNull 198 | public Zeroconf addNetworkInterface(@NotNull NetworkInterface nic) throws IOException { 199 | thread.addNetworkInterface(nic); 200 | return this; 201 | } 202 | 203 | @NotNull 204 | public Zeroconf addNetworkInterfaces(@NotNull Collection nics) throws IOException { 205 | for (NetworkInterface nic : nics) thread.addNetworkInterface(nic); 206 | return this; 207 | } 208 | 209 | /** 210 | * Remove a {@link #addNetworkInterface previously added} NetworkInterface from this 211 | * object's list. The addresses that were part of the interface at the time it was added 212 | * will be removed from the list of {@link #getLocalAddresses local addresses}. 213 | * 214 | * @param nic a NetworkInterface 215 | * @return this 216 | * @throws IOException if something goes wrong in an I/O way 217 | */ 218 | @NotNull 219 | public Zeroconf removeNetworkInterface(@NotNull NetworkInterface nic) throws IOException { 220 | thread.removeNetworkInterface(nic); 221 | return this; 222 | } 223 | 224 | /** 225 | * A convenience method to add all local NetworkInterfaces - it simply runs 226 | *
227 |      * for (Enumeration<NetworkInterface> e = NetworkInterface.getNetworkInterfaces();e.hasMoreElements();) {
228 |      *     addNetworkInterface(e.nextElement());
229 |      * }
230 |      * 
231 | * 232 | * @return this 233 | * @throws IOException if something goes wrong in an I/O way 234 | */ 235 | @NotNull 236 | public Zeroconf addAllNetworkInterfaces() throws IOException { 237 | for (Enumeration e = NetworkInterface.getNetworkInterfaces(); e.hasMoreElements(); ) 238 | addNetworkInterface(e.nextElement()); 239 | 240 | return this; 241 | } 242 | 243 | /** 244 | * Get the Service Discovery Domain, which is set by {@link #setDomain}. It defaults to ".local", 245 | * but can be set by {@link #setDomain} 246 | * 247 | * @return the domain 248 | */ 249 | public String getDomain() { 250 | return domain; 251 | } 252 | 253 | /** 254 | * Set the Service Discovery Domain 255 | * 256 | * @param domain the domain 257 | * @return this 258 | */ 259 | @NotNull 260 | public Zeroconf setDomain(@NotNull String domain) { 261 | this.domain = domain; 262 | return this; 263 | } 264 | 265 | /** 266 | * Get the local hostname, which defaults to InetAddress.getLocalHost().getHostName(). 267 | * 268 | * @return the local host name 269 | */ 270 | public String getLocalHostName() { 271 | if (hostname == null) throw new IllegalStateException("Hostname cannot be determined"); 272 | return hostname; 273 | } 274 | 275 | /** 276 | * Set the local hostname, as returned by {@link #getOrCreateLocalHostName} 277 | * 278 | * @param name the hostname, which should be undotted 279 | * @return this 280 | */ 281 | @NotNull 282 | public Zeroconf setLocalHostName(@NotNull String name) { 283 | this.hostname = name; 284 | return this; 285 | } 286 | 287 | /** 288 | * Return a list of InetAddresses which the Zeroconf object considers to be "local". These 289 | * are the all the addresses of all the {@link NetworkInterface} objects added to this 290 | * object. The returned list is a copy, it can be modified and will not be updated 291 | * by this object. 292 | * 293 | * @return a List of local {@link InetAddress} objects 294 | */ 295 | public List getLocalAddresses() { 296 | return thread.getLocalAddresses(); 297 | } 298 | 299 | /** 300 | * Send a packet 301 | */ 302 | public void send(@NotNull Packet packet) { 303 | thread.push(packet); 304 | } 305 | 306 | /** 307 | * Return the registry of records. This is the list of DNS records that we will 308 | * automatically match any queries against. The returned list is live. 309 | */ 310 | public List getRegistry() { 311 | return registry; 312 | } 313 | 314 | /** 315 | * Return the list of all Services that have been {@link Zeroconf#announce} announced 316 | * by this object. The returned Collection is read-only and live, so will be updated 317 | * by this object. 318 | * 319 | * @return the Collection of announced Services 320 | */ 321 | public Collection getAnnouncedServices() { 322 | return Collections.unmodifiableCollection(services); 323 | } 324 | 325 | /** 326 | * Given a query packet, trawl through our registry and try to find any records that 327 | * match the queries. If there are any, send our own response packet. 328 | *

329 | * This is largely derived from other implementations, but broadly the logic here is 330 | * that questions are matched against records based on the "name" and "type" fields, 331 | * where {@link #DISCOVERY} and {@link Record#TYPE_ANY} are wildcards for those 332 | * fields. Currently we match against all packet types - should these be just "PTR" 333 | * records? 334 | *

335 | * Once we have this list of matched records, we search this list for any PTR records 336 | * and add any matching SRV or TXT records (RFC 6763 12.1). After that, we scan our 337 | * updated list and add any A or AAAA records that match any SRV records (12.2). 338 | *

339 | * At the end of all this, if we have at least one record, send it as a response 340 | */ 341 | private void handlePacket(@NotNull Packet packet) { 342 | Packet response = null; 343 | Set targets = null; 344 | for (Record question : packet.getQuestions()) { 345 | for (Record record : getRegistry()) { 346 | if ((question.getName().equals(DISCOVERY) || question.getName().equals(record.getName())) && (question.getType() == record.getType() || question.getType() == Record.TYPE_ANY && record.getType() != Record.TYPE_NSEC)) { 347 | if (response == null) { 348 | response = new Packet(packet.getID()); 349 | response.setAuthoritative(true); 350 | } 351 | 352 | response.addAnswer(record); 353 | if (record instanceof RecordSRV) { 354 | if (targets == null) targets = new HashSet<>(); 355 | targets.add(((RecordSRV) record).getTarget()); 356 | } 357 | } 358 | } 359 | 360 | if (response != null && question.getType() != Record.TYPE_ANY) { 361 | // When including a DNS-SD Service Instance Enumeration or Selective 362 | // Instance Enumeration (subtype) PTR record in a response packet, the 363 | // server/responder SHOULD include the following additional records: 364 | // o The SRV record(s) named in the PTR rdata. 365 | // o The TXT record(s) named in the PTR rdata. 366 | // o All address records (type "A" and "AAAA") named in the SRV rdata. 367 | for (Record answer : response.getAnswers()) { 368 | if (answer.getType() != Record.TYPE_PTR) 369 | continue; 370 | 371 | for (Record record : getRegistry()) { 372 | if (record.getName().equals(((RecordPTR) answer).getValue()) 373 | && (record.getType() == Record.TYPE_SRV || record.getType() == Record.TYPE_TXT)) { 374 | response.addAdditional(record); 375 | if (record instanceof RecordSRV) { 376 | if (targets == null) targets = new HashSet<>(); 377 | targets.add(((RecordSRV) record).getTarget()); 378 | } 379 | } 380 | } 381 | } 382 | } 383 | } 384 | 385 | if (response != null) { 386 | // When including an SRV record in a response packet, the 387 | // server/responder SHOULD include the following additional records: 388 | // o All address records (type "A" and "AAAA") named in the SRV rdata. 389 | if (targets != null) { 390 | for (String target : targets) { 391 | for (Record record : getRegistry()) { 392 | if (record.getName().equals(target) && (record.getType() == Record.TYPE_A || record.getType() == Record.TYPE_AAAA)) { 393 | response.addAdditional(record); 394 | } 395 | } 396 | } 397 | } 398 | 399 | send(response); 400 | } 401 | } 402 | 403 | /** 404 | * Create a background thread that continuously searches for the given service. 405 | * 406 | * @param service the service name, eg "_http" 407 | * @param protocol the protocol, eg "_tcp" 408 | * @param domain the domain, eg ".local" 409 | * @return a list of discovered services 410 | */ 411 | @NotNull 412 | public DiscoveredServices discover(@NotNull String service, @NotNull String protocol, @NotNull String domain) { 413 | DiscoveredServices discoverer = new DiscoveredServices("_" + service + "._" + protocol + domain); 414 | new Thread(discoverer, "zeroconf-discover-" + service + "-" + protocol + "-" + domain).start(); 415 | discoverers.add(discoverer); 416 | return discoverer; 417 | } 418 | 419 | /** 420 | * Probe for a ZeroConf service with the specified name and return true if a matching 421 | * service is found. 422 | *

423 | * The approach is borrowed from https://www.npmjs.com/package/bonjour - we send three 424 | * broadcasts trying to match the service name, 250ms apart. If we receive no response, 425 | * assume there is no service that matches 426 | *

427 | * Note the approach here is the only example of where we send a query packet. It could 428 | * be used as the basis for us acting as a service discovery client 429 | * 430 | * @param fqdn the fully qualified service name, eg "My Web Service._http._tcp.local". 431 | */ 432 | private boolean probe(final String fqdn) { 433 | Packet probe = new Packet(); 434 | probe.setResponse(false); 435 | probe.addQuestion(new RecordANY(fqdn)); 436 | AtomicBoolean match = new AtomicBoolean(false); 437 | PacketListener probeListener = packet -> { 438 | if (packet.isResponse()) { 439 | for (Record r : packet.getAnswers()) { 440 | if (r.getName().equalsIgnoreCase(fqdn)) { 441 | synchronized (match) { 442 | match.set(true); 443 | match.notifyAll(); 444 | } 445 | } 446 | } 447 | 448 | for (Record r : packet.getAdditionals()) { 449 | if (r.getName().equalsIgnoreCase(fqdn)) { 450 | synchronized (match) { 451 | match.set(true); 452 | match.notifyAll(); 453 | } 454 | } 455 | } 456 | } 457 | }; 458 | 459 | addReceiveListener(probeListener); 460 | for (int i = 0; i < 3 && !match.get(); i++) { 461 | send(probe); 462 | synchronized (match) { 463 | try { 464 | match.wait(250); 465 | } catch (InterruptedException ex) { 466 | // ignore 467 | } 468 | } 469 | } 470 | 471 | removeReceiveListener(probeListener); 472 | return match.get(); 473 | } 474 | 475 | /** 476 | * Announce the service - probe to see if it already exists and fail if it does, otherwise 477 | * announce it 478 | */ 479 | public void announce(@NotNull Service service) { 480 | if (service.getDomain() == null) service.setDomain(getDomain()); 481 | if (service.getHost() == null) service.setHost(getLocalHostName()); 482 | if (!service.hasAddresses()) service.addAddresses(getLocalAddresses()); 483 | 484 | Packet packet = service.getPacket(); 485 | if (probe(service.getInstanceName())) 486 | throw new IllegalArgumentException("Service " + service.getInstanceName() + " already on network"); 487 | 488 | getRegistry().addAll(packet.getAnswers()); 489 | services.add(service); 490 | 491 | for (int i = 0; i < 3; i++) { 492 | send(packet); 493 | 494 | try { 495 | Thread.sleep(225); 496 | } catch (InterruptedException ignored) { 497 | } 498 | } 499 | 500 | LOGGER.info("Announced {}.", service); 501 | } 502 | 503 | /** 504 | * Unannounce the service. Do this by re-announcing all our records but with a TTL of 0 to 505 | * ensure they expire. Then remove from the registry. 506 | */ 507 | public void unannounce(@NotNull Service service) { 508 | Packet packet = service.getPacket(); 509 | getRegistry().removeAll(packet.getAnswers()); 510 | for (Record r : packet.getAnswers()) { 511 | getRegistry().remove(r); 512 | r.setTTL(0); 513 | } 514 | 515 | services.remove(service); 516 | 517 | for (int i = 0; i < 3; i++) { 518 | send(packet); 519 | 520 | try { 521 | Thread.sleep(125); 522 | } catch (InterruptedException ignored) { 523 | } 524 | } 525 | 526 | LOGGER.info("Unannounced {}.", service); 527 | } 528 | 529 | public class DiscoveredServices implements Runnable { 530 | private final String serviceName; 531 | private final PacketListener listener; 532 | private final Set services = Collections.synchronizedSet(new HashSet<>()); 533 | private volatile boolean shouldStop = false; 534 | private int nextInterval = 1000; 535 | 536 | DiscoveredServices(@NotNull String serviceName) { 537 | this.serviceName = serviceName; 538 | 539 | addReceiveListener(listener = packet -> { 540 | if (!packet.isResponse()) 541 | return; 542 | 543 | for (Record r : packet.getAnswers()) { 544 | if (r instanceof RecordSRV) addService((RecordSRV) r); 545 | else addRelated(r); 546 | } 547 | 548 | for (Record r : packet.getAdditionals()) { 549 | if (r instanceof RecordSRV) addService((RecordSRV) r); 550 | else addRelated(r); 551 | } 552 | }); 553 | } 554 | 555 | private void addRelated(@NotNull Record record) { 556 | if (!record.getName().endsWith(serviceName)) 557 | return; 558 | 559 | synchronized (services) { 560 | services.stream().filter(s -> s.serviceName.equals(record.getName())).forEach(s -> s.addRelatedRecord(record)); 561 | } 562 | } 563 | 564 | private void addService(@NotNull RecordSRV record) { 565 | if (!record.getName().endsWith(serviceName)) 566 | return; 567 | 568 | services.removeIf(s -> s.isExpired() || s.serviceName.equals(record.getName())); 569 | services.add(new DiscoveredService(record)); 570 | } 571 | 572 | public void stop() { 573 | shouldStop = true; 574 | } 575 | 576 | @NotNull 577 | public List getServices() { 578 | return new ArrayList<>(services); 579 | } 580 | 581 | @Nullable 582 | public DiscoveredService getService(@NotNull String name) { 583 | synchronized (services) { 584 | for (DiscoveredService service : services) 585 | if (service.name.equals(name)) 586 | return service; 587 | 588 | return null; 589 | } 590 | } 591 | 592 | @Override 593 | public void run() { 594 | while (!shouldStop) { 595 | Packet probe = new Packet(); 596 | probe.setResponse(false); 597 | probe.addQuestion(new RecordPTR(serviceName)); 598 | send(probe); 599 | 600 | try { 601 | //noinspection BusyWait 602 | Thread.sleep(nextInterval); 603 | nextInterval *= 2; 604 | if (nextInterval >= TimeUnit.MINUTES.toMillis(60)) 605 | nextInterval = (int) (TimeUnit.MINUTES.toMillis(60) + 20 + Math.random() * 100); 606 | } catch (InterruptedException ex) { 607 | break; 608 | } 609 | } 610 | 611 | removeReceiveListener(listener); 612 | } 613 | } 614 | 615 | /** 616 | * The thread that listens to one or more Multicast DatagramChannels using a Selector, 617 | * waiting for incoming packets. This wait can be also interrupted and a packet sent. 618 | */ 619 | private class ListenerThread extends Thread { 620 | private final Deque sendq; 621 | private final Map channels; 622 | private final Map> localAddresses; 623 | private final ABLock selectorLock = new ABLock(); 624 | private volatile boolean cancelled; 625 | private Selector selector; 626 | 627 | ListenerThread() { 628 | super("zeroconf-io-thread"); 629 | 630 | setDaemon(false); 631 | sendq = new ArrayDeque<>(); 632 | channels = new HashMap<>(); 633 | localAddresses = new HashMap<>(); 634 | } 635 | 636 | private synchronized Selector getSelector() throws IOException { 637 | if (selector == null) 638 | selector = Selector.open(); 639 | return selector; 640 | } 641 | 642 | /** 643 | * Stop the thread and rejoin 644 | */ 645 | void close() throws InterruptedException { 646 | this.cancelled = true; 647 | if (selector != null) { 648 | selector.wakeup(); 649 | if (isAlive()) join(); 650 | } 651 | } 652 | 653 | /** 654 | * Add a packet to the send queue 655 | */ 656 | void push(Packet packet) { 657 | sendq.addLast(packet); 658 | if (selector != null) { 659 | // Only send if we have a Nic 660 | selector.wakeup(); 661 | } 662 | } 663 | 664 | /** 665 | * Pop a packet from the send queue or return null if none available 666 | */ 667 | private Packet pop() { 668 | return sendq.pollFirst(); 669 | } 670 | 671 | /** 672 | * Add a NetworkInterface. Try to identify whether it's IPV4 or IPV6, or both. IPV4 tested, 673 | * IPV6 is not but at least it doesn't crash. 674 | */ 675 | public void addNetworkInterface(@NotNull NetworkInterface nic) throws IOException { 676 | if (!channels.containsKey(nic) && nic.supportsMulticast() && nic.isUp() && !nic.isLoopback()) { 677 | boolean ipv4 = false; 678 | boolean ipv6 = false; 679 | List locallist = new ArrayList<>(); 680 | for (Enumeration e = nic.getInetAddresses(); e.hasMoreElements(); ) { 681 | InetAddress a = e.nextElement(); 682 | if ((a instanceof Inet4Address && !useIpv4) || (a instanceof Inet6Address && !useIpv6)) 683 | continue; 684 | 685 | ipv4 |= a instanceof Inet4Address; 686 | ipv6 |= a instanceof Inet6Address; 687 | if (!a.isLoopbackAddress() && !a.isMulticastAddress()) 688 | locallist.add(a); 689 | } 690 | 691 | DatagramChannel channel = DatagramChannel.open(StandardProtocolFamily.INET); 692 | channel.configureBlocking(false); 693 | channel.setOption(StandardSocketOptions.SO_REUSEADDR, true); 694 | channel.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 255); 695 | if (ipv4) { 696 | channel.bind(new InetSocketAddress("0.0.0.0", BROADCAST4.getPort())); 697 | channel.setOption(StandardSocketOptions.IP_MULTICAST_IF, nic); 698 | channel.join(BROADCAST4.getAddress(), nic); 699 | } else if (ipv6) { 700 | channel.bind(new InetSocketAddress("::", BROADCAST6.getPort())); 701 | channel.join(BROADCAST6.getAddress(), nic); 702 | } 703 | 704 | selectorLock.lockA1(); 705 | try { 706 | getSelector().wakeup(); 707 | 708 | selectorLock.lockA2(); 709 | try { 710 | channels.put(nic, channel.register(getSelector(), SelectionKey.OP_READ)); 711 | } finally { 712 | selectorLock.unlockA2(); 713 | } 714 | } catch (InterruptedException ignored) { 715 | } finally { 716 | selectorLock.unlockA1(); 717 | } 718 | 719 | localAddresses.put(nic, locallist); 720 | if (!isAlive()) start(); 721 | } 722 | } 723 | 724 | void removeNetworkInterface(@NotNull NetworkInterface nic) throws IOException { 725 | SelectionKey key = channels.remove(nic); 726 | if (key != null) { 727 | localAddresses.remove(nic); 728 | key.channel().close(); 729 | getSelector().wakeup(); 730 | } 731 | } 732 | 733 | List getLocalAddresses() { 734 | List list = new ArrayList<>(); 735 | for (List pernic : localAddresses.values()) { 736 | for (InetAddress address : pernic) { 737 | if (!list.contains(address)) 738 | list.add(address); 739 | } 740 | } 741 | 742 | return list; 743 | } 744 | 745 | @Override 746 | public void run() { 747 | ByteBuffer buf = ByteBuffer.allocate(65536); 748 | buf.order(ByteOrder.BIG_ENDIAN); 749 | while (!cancelled) { 750 | buf.clear(); 751 | try { 752 | Packet packet = pop(); 753 | if (packet != null) { 754 | // Packet to Send 755 | buf.clear(); 756 | packet.write(buf); 757 | buf.flip(); 758 | for (PacketListener listener : sendListeners) 759 | listener.packetEvent(packet); 760 | 761 | for (SelectionKey key : channels.values()) { 762 | DatagramChannel channel = (DatagramChannel) key.channel(); 763 | InetSocketAddress address = packet.getAddress(); 764 | if (address != null) { 765 | buf.position(0); 766 | channel.send(buf, address); 767 | } else { 768 | if (useIpv4) { 769 | buf.position(0); 770 | channel.send(buf, BROADCAST4); 771 | } 772 | 773 | if (useIpv6) { 774 | buf.position(0); 775 | channel.send(buf, BROADCAST6); 776 | } 777 | } 778 | } 779 | } 780 | 781 | // We know selector exists 782 | Selector selector = getSelector(); 783 | selectorLock.lockB(); 784 | try { 785 | selector.select(); 786 | } finally { 787 | selectorLock.unlockB(); 788 | } 789 | 790 | Set selected = selector.selectedKeys(); 791 | for (SelectionKey key : selected) { 792 | // We know selected keys are readable 793 | DatagramChannel channel = (DatagramChannel) key.channel(); 794 | InetSocketAddress address = (InetSocketAddress) channel.receive(buf); 795 | if (address != null && buf.position() != 0) { 796 | buf.flip(); 797 | packet = new Packet(); 798 | packet.read(buf, address); 799 | for (PacketListener listener : receiveListeners) 800 | listener.packetEvent(packet); 801 | handlePacket(packet); 802 | } 803 | } 804 | 805 | selected.clear(); 806 | } catch (Exception ex) { 807 | LOGGER.warn("Failed receiving/sending packet!", ex); 808 | } 809 | } 810 | } 811 | } 812 | } 813 | --------------------------------------------------------------------------------