This is an automated email from the ASF dual-hosted git repository.
vladimirsitnikov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/jmeter.git
The following commit(s) were added to refs/heads/master by this push:
new c6c6c07e5c fix(deps): update dependency dnsjava:dnsjava to v3, support
ip:host for custom resolvers
c6c6c07e5c is described below
commit c6c6c07e5c8def261959c9abda8f64ca839ed392
Author: Mend Renovate <[email protected]>
AuthorDate: Mon Oct 27 17:27:23 2025 +0000
fix(deps): update dependency dnsjava:dnsjava to v3, support ip:host for
custom resolvers
---
gradle/verification-keyring.keys | 44 ++++
gradle/verification-metadata.xml | 6 +
src/bom-thirdparty/build.gradle.kts | 2 +-
src/dist/src/dist/expected_release_jars.csv | 2 +-
src/licenses/build.gradle.kts | 2 +-
.../protocol/http/control/DNSCacheManager.java | 94 +++++++-
.../protocol/http/control/DnsManagerTest.java | 62 -----
.../protocol/http/control/DNSCacheManagerTest.kt | 112 ++++++++-
.../jmeter/protocol/http/util/MockDnsServer.kt | 250 +++++++++++++++++++++
9 files changed, 501 insertions(+), 73 deletions(-)
diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys
index 72f64f77dd..6aac783a60 100644
--- a/gradle/verification-keyring.keys
+++ b/gradle/verification-keyring.keys
@@ -4120,6 +4120,50 @@
x/uXMcOo9YWnMc38gnN+Em4HltkFgKPpM3lPzjN9DqJg5VlBkGUL9Xnm4os=
=M/EH
-----END PGP PUBLIC KEY BLOCK-----
+pub 0CA7139CBC7026F9
+uid dnsjava <[email protected]>
+
+sub 6DA2B39D3A4085B5
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+PeLQQS4CUQM4f21Ij/FbUFuptPs=
+=1Vdr
+-----END PGP PUBLIC KEY BLOCK-----
+
pub 0D3B328562A119A7
uid Aleksey Shipilev <[email protected]>
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 727aae1b23..06a1536bad 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -259,6 +259,7 @@
<trusting group="com.github.tomakehurst"/>
<trusting group="org.wiremock" name="wiremock" version="3.0.1"/>
</trusted-key>
+ <trusted-key id="E06A36F67D8C1BD13F1F278D0CA7139CBC7026F9"
group="dnsjava" name="dnsjava" version="3.6.3"/>
<trusted-key id="E0D98C5FD55A8AF232290E58DEE12B9896F97E34"
group="org.pcollections"/>
<trusted-key id="E2ACB037933CDEAAB7BF77D49A2C7A98E457C53D"
group="org.springframework"/>
<trusted-key id="E3A9F95079E84CE201F7CF60BEDE11EAF1164480"
group="org.hamcrest"/>
@@ -615,6 +616,11 @@
<sha256
value="958d4f0ee63caee175085f9396d298c2fd88278918bbf3337a32ec74ce488b61"
origin="Generated by Gradle" reason="Artifact is not signed"/>
</artifact>
</component>
+ <component group="org.ow2" name="ow2" version="1.5">
+ <artifact name="ow2-1.5.pom">
+ <sha256
value="0f8a1b116e760b8fe6389c51b84e4b07a70fc11082d4f936e453b583dd50b43b"
origin="Generated by Gradle" reason="Artifact is not signed"/>
+ </artifact>
+ </component>
<component group="org.ow2" name="ow2" version="1.5.1">
<artifact name="ow2-1.5.1.pom">
<sha256
value="321ddbb7ee6fe4f53dea6b4cd6db74154d6bfa42391c1f763b361b9f485acf05"
origin="Generated by Gradle"/>
diff --git a/src/bom-thirdparty/build.gradle.kts
b/src/bom-thirdparty/build.gradle.kts
index 6e79280cc7..0ba7064d26 100644
--- a/src/bom-thirdparty/build.gradle.kts
+++ b/src/bom-thirdparty/build.gradle.kts
@@ -68,7 +68,7 @@ dependencies {
api("commons-lang:commons-lang:2.6")
api("commons-logging:commons-logging:1.3.5")
api("commons-net:commons-net:3.12.0")
- api("dnsjava:dnsjava:2.1.9")
+ api("dnsjava:dnsjava:3.6.3")
api("io.burt:jmespath-core:0.6.0")
api("io.burt:jmespath-jackson:0.6.0")
api("javax.activation:javax.activation-api:1.2.0")
diff --git a/src/dist/src/dist/expected_release_jars.csv
b/src/dist/src/dist/expected_release_jars.csv
index 137ad6251c..3321bc7501 100644
--- a/src/dist/src/dist/expected_release_jars.csv
+++ b/src/dist/src/dist/expected_release_jars.csv
@@ -49,7 +49,7 @@
217795,darklaf-windows-2.7.3.jar
362793,datamodel-jvm-4.7.3.jar
98115,dec-0.1.2.jar
-320748,dnsjava-2.1.9.jar
+588267,dnsjava-3.6.3.jar
16829,error_prone_annotations-2.24.0.jar
1736381,freemarker-2.3.32.jar
32359,geronimo-jms_1.1_spec-1.1.1.jar
diff --git a/src/licenses/build.gradle.kts b/src/licenses/build.gradle.kts
index e229246786..6a0ef80a10 100644
--- a/src/licenses/build.gradle.kts
+++ b/src/licenses/build.gradle.kts
@@ -104,7 +104,7 @@ val gatherBinaryLicenses by
tasks.registering(GatherLicenseTask::class) {
// That enables to have "version-independent" MIT license in
licenses/slf4j-api, and
// it would be copied provided the detected license for slf4j-api is MIT.
- overrideLicense("dnsjava:dnsjava:2.1.9") {
+ overrideLicense("dnsjava:dnsjava:3.6.3") {
expectedLicense = SpdxLicense.BSD_2_Clause
}
diff --git
a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java
b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java
index 8b67fa1f76..7af898b28d 100644
---
a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java
+++
b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java
@@ -19,7 +19,9 @@ package org.apache.jmeter.protocol.http.control;
import java.io.Serializable;
import java.net.InetAddress;
+import java.net.InetSocketAddress;
import java.net.UnknownHostException;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
@@ -46,6 +48,7 @@ import org.xbill.DNS.ExtendedResolver;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.Resolver;
+import org.xbill.DNS.SimpleResolver;
import org.xbill.DNS.TextParseException;
import org.xbill.DNS.Type;
@@ -121,13 +124,22 @@ public class DNSCacheManager extends ConfigTestElement
implements TestIterationL
private Resolver createResolver() {
CollectionProperty dnsServers = getServers();
try {
- String[] serverNames = new String[dnsServers.size()];
- int index = 0;
+ List<Resolver> resolvers = new ArrayList<>();
for (JMeterProperty jMeterProperty : dnsServers) {
- serverNames[index] = jMeterProperty.getStringValue();
- index++;
+ // it can be either ipv4 or ipv6
+ String hostPort = jMeterProperty.getStringValue();
+ InetSocketAddress address = parseHostPort(hostPort);
+ // dnsjava needs resolved address
+ InetSocketAddress resolvedDnsServer = new
InetSocketAddress(address.getHostString(), address.getPort());
+ // Check if the address is unresolved (hostname couldn't be
resolved or invalid IP format)
+ if (resolvedDnsServer.isUnresolved()) {
+ throw new UnknownHostException("Cannot resolve DNS server
address: " + hostPort);
+ }
+ SimpleResolver resolver = new
SimpleResolver(resolvedDnsServer);
+ resolver.setTimeout(ExtendedResolver.DEFAULT_TIMEOUT); // it
was previously in new ExtendedResolver(String[])
+ resolvers.add(resolver);
}
- ExtendedResolver result = new ExtendedResolver(serverNames);
+ ExtendedResolver result = new ExtendedResolver(resolvers);
if (log.isDebugEnabled()) {
log.debug("Using DNS Resolvers: {}",
Arrays.asList(result.getResolvers()));
}
@@ -141,6 +153,73 @@ public class DNSCacheManager extends ConfigTestElement
implements TestIterationL
}
}
+ /**
+ * Parses a hostPort string into an InetSocketAddress.
+ * Supports formats:
+ * - hostname (e.g., "one.one.one.one")
+ * - IPv4 (e.g., "1.1.1.1")
+ * - IPv6 (e.g., "::1", "2001:db8::1", "ff06:0:0:0:0:0:0:c3")
+ * - hostname:port (e.g., "one.one.one.one:53")
+ * - IPv4:port (e.g., "1.1.1.1:53")
+ * - [IPv6]:port (e.g., "[::1]:53", "[ff06:0:0:0:0:0:0:c3]:53")
+ *
+ * @param hostPort the host and optional port string
+ * @return InetSocketAddress with default port 53 if not specified
+ * @throws UnknownHostException if the format is invalid
+ */
+ @VisibleForTesting
+ static InetSocketAddress parseHostPort(String hostPort) throws
UnknownHostException {
+ String host;
+ int port = 53; // Default DNS port
+
+ if (hostPort.startsWith("[")) {
+ // IPv6 with optional port: [::1] or [::1]:53
+ int closeBracket = hostPort.lastIndexOf(']');
+ if (closeBracket == -1) {
+ throw new UnknownHostException("Invalid IPv6 address format: "
+ hostPort);
+ }
+ host = hostPort.substring(1, closeBracket);
+ if (closeBracket + 1 < hostPort.length()) {
+ if (hostPort.charAt(closeBracket + 1) == ':') {
+ try {
+ port =
Integer.parseInt(hostPort.substring(closeBracket + 2));
+ } catch (NumberFormatException e) {
+ throw new UnknownHostException("Invalid port in: " +
hostPort);
+ }
+ } else {
+ throw new UnknownHostException("Invalid format after IPv6
address: " + hostPort);
+ }
+ }
+ } else {
+ // Could be:
+ // * single colon: hostname:port, IPv4:port
+ // * 0 or 2+ colons: hostname, IPv4, or bare IPv6
+ // Single colon means
+ int firstColon = hostPort.indexOf(':');
+ int secondColon = firstColon == -1 ? -1 : hostPort.indexOf(':',
firstColon + 1);
+
+ if (firstColon == -1 || secondColon != -1) {
+ // Zero or 2+ colons indicate bare IPv4, bare hostname, or
bare IPv6 address
+ // Examples: ::1, 2001:db8::1, ff06:0:0:0:0:0:0:c3
+ host = hostPort;
+ } else {
+ // Single colon: check if it's a port separator
+ int colonPos = hostPort.indexOf(':');
+ String possiblePort = hostPort.substring(colonPos + 1);
+ try {
+ port = Integer.parseInt(possiblePort);
+ // It's a valid port, so everything before is the host
+ host = hostPort.substring(0, colonPos);
+ } catch (NumberFormatException e) {
+ // Not a port, treat entire string as host
+ host = hostPort;
+ }
+ }
+ }
+
+ return InetSocketAddress.createUnresolved(host, port);
+ }
+
/**
* Resolves address using system or custom DNS resolver
*/
@@ -270,7 +349,7 @@ public class DNSCacheManager extends ConfigTestElement
implements TestIterationL
Lookup lookup = new Lookup(host, Type.A);
lookup.setCache(lookupCache);
if (timeoutMs > 0) {
- resolver.setTimeout(timeoutMs / 1000, timeoutMs % 1000);
+ resolver.setTimeout(Duration.ofMillis(timeoutMs));
}
lookup.setResolver(resolver);
Record[] records = lookup.run();
@@ -281,6 +360,9 @@ public class DNSCacheManager extends ConfigTestElement
implements TestIterationL
for (int i = 0; i < records.length; i++) {
addresses[i] = ((ARecord) records[i]).getAddress();
}
+ } catch (java.nio.channels.UnresolvedAddressException uae) {
+ // Thrown when DNS server address itself couldn't be resolved
+ throw new UnknownHostException("DNS server address is unresolved:
" + uae.getMessage());
} catch (TextParseException tpe) { // NOSONAR Exception handled
log.debug("Failed to create Lookup object for host:{}, error
message:{}", host, tpe.toString());
}
diff --git
a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/DnsManagerTest.java
b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/DnsManagerTest.java
deleted file mode 100644
index 1f52c9c9d7..0000000000
---
a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/DnsManagerTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to you under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.jmeter.protocol.http.control;
-
-import org.apache.jmeter.protocol.http.sampler.HTTPSampler;
-import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
-import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory;
-import org.apache.jmeter.protocol.http.sampler.ResultAsString;
-import org.apache.jmeter.samplers.SampleResult;
-import org.apache.jmeter.wiremock.WireMockExtension;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Assumptions;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.MethodSource;
-
-import com.github.tomakehurst.wiremock.WireMockServer;
-
-@ExtendWith(WireMockExtension.class)
-public class DnsManagerTest {
- @ParameterizedTest
-
@MethodSource("org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory#getImplementations")
- void badDnsInCustomResolverShouldFailHttpSampler(String
httpImplementation, WireMockServer server) {
-
Assumptions.assumeTrue(!HTTPSamplerFactory.IMPL_JAVA.equals(httpImplementation),
- "Java implementation does not support custom DNS resolver
yet");
- DNSCacheManager dns = new DNSCacheManager();
- dns.setCustomResolver(true);
- dns.addServer("20.0.118.11");
- // By default it uses 3 retries (see
org.xbill.DNS.ExtendedResolver#setRetries)
- dns.setTimeoutMs(2000);
- HTTPSamplerBase http =
HTTPSamplerFactory.newInstance(httpImplementation);
- http.setDNSResolver(dns);
- http.setMethod(HTTPSampler.GET);
- http.setPort(server.port());
- http.setDomain("localhost");
- http.setPath("/index.html");
-
- http.setRunningVersion(true);
-
- SampleResult result = http.sample();
- Assertions.assertEquals(
- "Non HTTP response message: Failed to resolve host name:
localhost",
- result.getResponseMessage(), () ->
- "HTTP is using a custom DNS resolver, so it must fail
resolving localhost \n" +
- ResultAsString.toString(result));
- }
-}
diff --git
a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/DNSCacheManagerTest.kt
b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/DNSCacheManagerTest.kt
index 1c23d84a4a..d56144eb3a 100644
---
a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/DNSCacheManagerTest.kt
+++
b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/control/DNSCacheManagerTest.kt
@@ -17,20 +17,37 @@
package org.apache.jmeter.protocol.http.control
+import com.github.tomakehurst.wiremock.WireMockServer
+import com.github.tomakehurst.wiremock.client.WireMock.aResponse
+import com.github.tomakehurst.wiremock.client.WireMock.get
+import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
+import org.apache.jmeter.protocol.http.sampler.HTTPSampler
+import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory
+import org.apache.jmeter.protocol.http.sampler.ResultAsString
+import org.apache.jmeter.protocol.http.util.MockDnsServer
+import org.apache.jmeter.wiremock.WireMockExtension
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledIfSystemProperty
+import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.fail
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.CsvSource
+import org.junit.jupiter.params.provider.MethodSource
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.ResolverConfig
+import java.net.Inet4Address
+import java.net.Inet6Address
import java.net.InetAddress
import java.net.UnknownHostException
+@ExtendWith(WireMockExtension::class)
class DNSCacheManagerTest {
companion object {
@@ -120,7 +137,7 @@ class DNSCacheManagerTest {
fun `Valid DNS resolves and caches with custom resolve true`() {
assumeLocalDnsResolverOK()
for (dns in VALID_DNS_SERVERS) {
- sut.addServer(dns)
+ sut.addServer(dns.hostString)
}
sut.isCustomResolver = true
sut.timeoutMs = 5000
@@ -134,7 +151,7 @@ class DNSCacheManagerTest {
fun `Cache should be used where entries exist`() {
assumeLocalDnsResolverOK()
for (dns in VALID_DNS_SERVERS) {
- sut.addServer(dns)
+ sut.addServer(dns.hostString)
}
sut.isCustomResolver = true
sut.timeoutMs = 5000
@@ -190,4 +207,95 @@ class DNSCacheManagerTest {
}
assertNull(sut.resolver, ".resolver")
}
+
+ @ParameterizedTest
+
@MethodSource("org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory#getImplementations")
+ fun `custom resolver should use mock DNS server to resolve host`(
+ httpImplementation: String,
+ server: WireMockServer
+ ) {
+ assumeTrue(
+ httpImplementation != HTTPSamplerFactory.IMPL_JAVA,
+ "Java implementation does not support custom DNS resolver yet"
+ )
+
+ // Set up WireMock to respond to requests
+ server.stubFor(
+ get(urlEqualTo("/index.html"))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withBody("OK")
+ )
+ )
+
+ val testDomainName = "non.existing.domain.for.tests"
+ // Resolve
+ val mockDnsServer = MockDnsServer(
+ answers = mapOf(
+ // This should map to WireMock listen address, and it is not
clear
+ // how to tell if WireMock listens on IPv4 or IPv6
+ testDomainName to listOf("127.0.0.1")
+ )
+ )
+
+ mockDnsServer.start()
+ try {
+ // Use a custom DNS resolver with a mock DNS server
+ val dns = DNSCacheManager().apply {
+ isCustomResolver = true
+ addServer(
+ when (mockDnsServer.localAddress) {
+ is Inet4Address -> "127.0.0.1"
+ is Inet6Address -> "[::1]"
+ else -> TODO("Unexpected address type of
mockDnsServer.localAddress: ${mockDnsServer.localAddress::class.simpleName}")
+ } + ":" + mockDnsServer.boundPort
+ )
+ }
+
+ val http =
HTTPSamplerFactory.newInstance(httpImplementation).apply {
+ dnsResolver = dns
+ method = HTTPSampler.GET
+ port = server.port()
+ domain = testDomainName
+ path = "/index.html"
+ isRunningVersion = true
+ }
+
+ val result = http.sample()
+
+ assertTrue(result.isSuccessful) {
+ "HTTP request should succeed using custom DNS resolver with
mock DNS server. " +
+ "Response: ${result.responseMessage}\n" +
+ ResultAsString.toString(result)
+ }
+
+ assertEquals("200", result.responseCode) {
+ "Expected 200 response
code\n${ResultAsString.toString(result)}"
+ }
+ } finally {
+ mockDnsServer.close()
+ }
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ "one.one.one.one, one.one.one.one, 53",
+ "1.1.1.1, 1.1.1.1, 53",
+ "::1, ::1, 53",
+ "one.one.one.one:8053, one.one.one.one, 8053",
+ "1.1.1.1:53, 1.1.1.1, 53",
+ "[::1]:53, ::1, 53",
+ "127.0.0.1:53, 127.0.0.1, 53",
+ "ff06:0:0:0:0:0:0:c3, ff06:0:0:0:0:0:0:c3, 53",
+ "2001:db8:85a3:0:0:8a2e:370:7334, 2001:db8:85a3:0:0:8a2e:370:7334, 53",
+ "[ff06:0:0:0:0:0:0:c3]:8053, ff06:0:0:0:0:0:0:c3, 8053"
+ )
+ fun parseHostPort(input: String, expectedHost: String, expectedPort: Int) {
+ val addr = DNSCacheManager.parseHostPort(input)
+ assertAll(
+ { assertEquals(expectedHost, addr.hostString) { "host from $input"
} },
+ { assertEquals(expectedPort, addr.port) { "port from $input" } }
+ )
+ }
}
diff --git
a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/util/MockDnsServer.kt
b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/util/MockDnsServer.kt
new file mode 100644
index 0000000000..bc4cacb46d
--- /dev/null
+++
b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/util/MockDnsServer.kt
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jmeter.protocol.http.util
+
+import org.slf4j.LoggerFactory
+import org.xbill.DNS.AAAARecord
+import org.xbill.DNS.ARecord
+import org.xbill.DNS.Message
+import org.xbill.DNS.Name
+import org.xbill.DNS.Rcode
+import org.xbill.DNS.Record
+import org.xbill.DNS.Section
+import org.xbill.DNS.Type
+import java.io.Closeable
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.time.Duration
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * A lightweight mock DNS server for testing purposes.
+ *
+ * Supports A and AAAA record queries with configurable responses. Useful for
testing
+ * DNS resolution behavior without requiring external DNS infrastructure.
+ *
+ * Example usage:
+ * ```
+ * val server = MockDnsServer(
+ * answers = mapOf("example.com" to listOf("192.0.2.1", "2001:db8::1"))
+ * )
+ * server.start()
+ * // ... perform tests using server.boundPort() ...
+ * server.close()
+ * ```
+ *
+ * @param port The port to bind to (0 = auto-assign)
+ * @param answers Static hostname-to-IP mappings (hostname without trailing
dot)
+ * @param ttl Time-to-live for DNS records
+ * @param soTimeout Socket timeout for graceful shutdown checks
+ * @param answerProvider Custom record provider for dynamic responses
(overrides [answers])
+ */
+class MockDnsServer(
+ private val port: Int = 0,
+ private val answers: Map<String, List<String>> = emptyMap(),
+ private val ttl: Duration = Duration.ofSeconds(60),
+ private val soTimeout: Duration = Duration.ofMillis(250),
+ private val answerProvider: ((Name, Int, Int) -> List<Record>)? = null
+) : Closeable {
+ companion object {
+ private val logger = LoggerFactory.getLogger(MockDnsServer::class.java)
+ private const val UDP_SIZE = 512
+ }
+
+ private val running = AtomicBoolean(false)
+ private val requests = AtomicInteger(0)
+ private val receivedQueries = ConcurrentLinkedQueue<QueryInfo>()
+
+ /**
+ * Returns the number of DNS queries received by this server.
+ */
+ val requestCount: Int get() = requests.get()
+
+ /**
+ * Returns a list of all DNS queries received by this server.
+ * Useful for test assertions.
+ */
+ fun getReceivedQueries(): List<QueryInfo> = receivedQueries.toList()
+
+ /**
+ * Clears the history of received queries.
+ */
+ fun clearQueryHistory() = receivedQueries.clear()
+
+ /**
+ * Information about a received DNS query.
+ */
+ data class QueryInfo(
+ val name: String,
+ val type: Int,
+ val typeName: String,
+ val dclass: Int
+ )
+
+ // single-thread executor for serving; daemon thread to not hang JVM
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor
{ r ->
+ Thread(r, "mock-dns-server-$port").apply { isDaemon = true }
+ }
+
+ @Volatile private var socket: DatagramSocket? = null
+
+ var boundPort: Int = -1
+ private set
+
+ val localAddress: InetAddress get() = socket!!.localAddress
+
+ /**
+ * Starts the DNS server on the configured port.
+ *
+ * @return The port the server is bound to
+ * @throws IllegalStateException if port is invalid
+ * @throws java.net.BindException if port is already in use
+ */
+ fun start(): Int {
+ if (!running.compareAndSet(false, true)) return boundPort // idempotent
+
+ require(port in 0..65535) { "Port must be in range 0-65535, got:
$port" }
+
+ try {
+ val ds = DatagramSocket(port)
+ ds.soTimeout = soTimeout.toMillis().toInt()
+ socket = ds
+ boundPort = (ds.localSocketAddress as InetSocketAddress).port
+ logger.debug("MockDnsServer started on port $boundPort")
+ executor.execute { serve(ds) }
+ return boundPort
+ } catch (e: Exception) {
+ running.set(false)
+ throw e
+ }
+ }
+
+ private fun serve(ds: DatagramSocket) {
+ val buf = ByteArray(UDP_SIZE)
+ while (running.get()) {
+ try {
+ val packet = DatagramPacket(buf, buf.size)
+ ds.receive(packet) // times out periodically due to soTimeout
+ requests.incrementAndGet()
+ val request = Message(packet.data)
+ val response = buildResponse(request)
+ val wire = response.toWire()
+ val out = DatagramPacket(wire, wire.size, packet.address,
packet.port)
+ ds.send(out)
+ } catch (e: java.net.SocketTimeoutException) {
+ // loop again, check running flag
+ } catch (e: Exception) {
+ if (running.get()) {
+ logger.debug("MockDnsServer error while serving", e)
+ } else {
+ break
+ }
+ }
+ }
+ }
+
+ private fun buildResponse(req: Message): Message {
+ val q = req.getQuestion()
+
+ // Record query for test inspection
+ receivedQueries.offer(
+ QueryInfo(
+ name = q.name.toString(true).trimEnd('.'),
+ type = q.type,
+ typeName = Type.string(q.type),
+ dclass = q.dClass
+ )
+ )
+
+ val resp = Message(req.header.id)
+ resp.addRecord(q, Section.QUESTION)
+ resp.header.rcode = Rcode.NOERROR
+ val records = provideRecords(q.name, q.type, q.dClass)
+ if (records.isEmpty()) {
+ resp.header.rcode = Rcode.NXDOMAIN
+ } else {
+ records.forEach { resp.addRecord(it, Section.ANSWER) }
+ }
+ return resp
+ }
+
+ private fun provideRecords(name: Name, type: Int, dclass: Int):
List<Record> {
+ answerProvider?.let { return it(name, type, dclass) }
+ val key = name.toString(true).trimEnd('.') // canonical hostname key
+ val ips = answers[key] ?: return emptyList()
+ val ttl = this.ttl.toSeconds()
+ return ips.mapNotNull { ip ->
+ try {
+ val addr = InetAddress.getByName(ip)
+ val recordType = if (addr.address.size == 4) Type.A else
Type.AAAA
+
+ // Return record only if query type matches or is ANY
+ when {
+ type == Type.ANY -> when (recordType) {
+ Type.A -> ARecord(name, dclass, ttl, addr)
+ Type.AAAA -> AAAARecord(name, dclass, ttl, addr)
+ else -> null
+ }
+ type == recordType -> when (recordType) {
+ Type.A -> ARecord(name, dclass, ttl, addr)
+ Type.AAAA -> AAAARecord(name, dclass, ttl, addr)
+ else -> null
+ }
+ else -> null // query type doesn't match this address type
+ }
+ } catch (e: Exception) {
+ logger.debug("Failed to parse IP address: $ip", e)
+ null
+ }
+ }
+ }
+
+ override fun close() {
+ stop()
+ }
+
+ /**
+ * Stops the DNS server gracefully.
+ * This method is idempotent and can be called multiple times safely.
+ */
+ fun stop() {
+ if (!running.compareAndSet(true, false)) return // idempotent
+ logger.debug("Stopping MockDnsServer on port $boundPort")
+ try {
+ socket?.close()
+ } catch (e: Exception) {
+ logger.debug("Error closing socket", e)
+ }
+ socket = null
+ executor.shutdownNow()
+ try {
+ if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
+ logger.warn("MockDnsServer executor did not terminate in time")
+ }
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+}