Gumdrop includes a full DNS proxy server that demonstrates the framework's UDP protocol support. The DNS server can resolve queries locally or proxy them to upstream servers, and is designed to be easily subclassed for custom name resolution. It serves both as a practical DNS proxy and as a reference implementation for developers building UDP-based services.
The DNS server is built on Gumdrop's UDPEndpoint abstraction,
which provides non-blocking UDP I/O using the same SelectorLoop architecture as
TCP servers. Each DNS query arrives as a datagram, is parsed, processed, and
the response is sent back—all within the event-driven framework.
DNSMessageresolve() for local handlingDNSService - the application service; handles queries, caching, and proxyingDNSListener, DoTListener, DoQListener - transport listeners (see Listeners)DNSMessage - parses and serializes DNS wire formatDNSQuestion - represents a query questionDNSResourceRecord - represents answer recordsDNSCache - in-memory response cache with TTL supportWhen the server cannot resolve a query locally, it forwards the request to configured upstream DNS servers. Multiple upstreams can be specified; they are tried in order until one responds.
/etc/resolv.confThe server maintains an in-memory cache to reduce upstream queries:
For secure DNS (DNS over DTLS), the server can be configured with TLS credentials. DTLS session state is maintained per remote address, providing encrypted DNS for privacy-conscious deployments.
When a UDP response from an upstream server has the TC (truncated) flag set, the server automatically retries the query over TCP using standard DNS-over-TCP framing (2-byte length prefix per RFC 1035 section 4.2.1). This is transparent to the client and ensures that large responses (such as DNSSEC-signed or multi-record answers) are delivered intact.
Upstream responses are validated to ensure the DNS message ID matches the query that was sent (RFC 5452). Mismatched IDs are logged and discarded, protecting against cache poisoning and spoofed responses.
Gumdrop's DNS implementation goes beyond basic query/response handling with comprehensive protocol compliance across multiple RFCs.
Both the server proxy and the client resolver advertise EDNS0 support by including an OPT pseudo-record in outgoing queries. This signals the maximum UDP payload size (4096 bytes by default), allowing larger responses without TCP fallback. EDNS0 options are also used as the transport for DNS cookies and padding.
Gumdrop supports DNSSEC validation for DNS responses. When enabled, the resolver sets the DO (DNSSEC OK) bit in EDNS0 queries to request DNSSEC records from upstream servers, then validates the chain of trust from the signer zone to a configured trust anchor.
Supported algorithms (per RFC 8624):
Supported DS digest types: SHA-1 (1), SHA-256 (2), SHA-384 (4).
All cryptographic operations use java.security from the
java.base module and are purely CPU-bound — no blocking I/O,
fully safe for the NIO event loop. DNSKEY and DS records needed for
chain-of-trust validation are fetched asynchronously through the existing
resolver callback model.
Configuration:
// Enable DNSSEC on the client resolver
DNSResolver resolver = new DNSResolver();
resolver.setDnssecEnabled(true);
resolver.addServer("8.8.8.8");
resolver.open();
// Enable DNSSEC-aware proxying on the server
DNSService service = new DNSService();
service.setDnssecEnabled(true);
When the server has DNSSEC enabled, it sets DO in upstream queries and strips DNSSEC records from responses to clients that did not request them (no DO bit). The AD (Authenticated Data) flag is preserved from upstream responses.
Outgoing DNS messages use name compression as specified in RFC 1035 section 4.1.4. Repeated domain name suffixes are replaced with pointers to earlier occurrences, reducing message size on the wire. This is especially effective for responses containing multiple records with shared suffixes.
The server and resolver support DNS cookies, a lightweight mechanism for source address verification carried as an EDNS0 option. The client generates a random cookie and includes it in queries; the server replies with a server cookie computed using HMAC-SHA256. On subsequent queries, the client presents the cached server cookie for validation. This provides protection against off-path spoofing without the overhead of DNSSEC or encrypted transports.
The DoT client transport includes several optimizations and security features:
SPKIPinnedCertTrustManagerTCPDNSConnectionPool
maintains persistent TCP connections to upstream servers with configurable
maximum connections, idle timeout, and connection lifetimeDoQ support includes error handling, session resumption, and connection reuse:
DOQ_NO_ERROR through
DOQ_EXCESSIVE_LOAD) via QUIC RESET_STREAM frames for malformed
queries, oversized messages, and server errorsDoQConnectionPool maintains persistent QUIC connections per
upstream server. Subsequent queries open new streams on the existing
connection rather than establishing new QUIC sessions, with automatic eviction
of idle connections
DoHClientTransport provides DNS resolution over HTTPS, sending
queries as HTTP POST requests with application/dns-message
content type. The response body contains the raw DNS wire-format answer.
HTTPClient infrastructure (HTTP/2
with TLS)/dns-query)
The DNS service is designed for extension. By subclassing DNSService
and overriding the resolve() method, you can implement custom name
resolution for any use case.
protected DNSMessage resolve(DNSMessage query)
Return a DNSMessage response to handle the query locally, or return
null to proxy the query to upstream servers. This simple contract
makes it easy to:
public class InternalDNSService extends DNSService {
private Map<String, InetAddress> internalHosts = new HashMap<>();
public InternalDNSService() {
internalHosts.put("app.internal", InetAddress.getByName("10.0.1.10"));
internalHosts.put("db.internal", InetAddress.getByName("10.0.1.20"));
internalHosts.put("cache.internal", InetAddress.getByName("10.0.1.30"));
}
@Override
protected DNSMessage resolve(DNSMessage query) {
DNSQuestion question = query.getQuestions().get(0);
String name = question.getName();
// Handle .internal domain
if (name.endsWith(".internal")) {
InetAddress addr = internalHosts.get(name);
if (addr != null) {
List<DNSResourceRecord> answers = new ArrayList<>();
answers.add(DNSResourceRecord.a(name, 300, addr));
return query.createResponse(answers);
}
// Name not found in internal domain
return query.createErrorResponse(DNSMessage.RCODE_NXDOMAIN);
}
// All other queries go to upstream
return null;
}
}
public class AdBlockingDNSService extends DNSService {
private Set<String> blockedDomains = new HashSet<>();
public void loadBlocklist(Path file) throws IOException {
blockedDomains.addAll(Files.readAllLines(file));
}
@Override
protected DNSMessage resolve(DNSMessage query) {
DNSQuestion question = query.getQuestions().get(0);
String name = question.getName().toLowerCase();
// Check against blocklist
if (blockedDomains.contains(name)) {
// Return NXDOMAIN for blocked domains
return query.createErrorResponse(DNSMessage.RCODE_NXDOMAIN);
}
// Allow through to upstream
return null;
}
}
public class ServiceDiscoveryDNS extends DNSService {
private ServiceRegistry registry;
@Override
protected DNSMessage resolve(DNSMessage query) {
DNSQuestion question = query.getQuestions().get(0);
// Handle SRV queries for service discovery
if (question.getType() == DNSType.SRV) {
String serviceName = question.getName();
List<ServiceInstance> instances = registry.lookup(serviceName);
if (!instances.isEmpty()) {
List<DNSResourceRecord> answers = new ArrayList<>();
for (ServiceInstance inst : instances) {
answers.add(DNSResourceRecord.srv(serviceName, 300,
inst.getPriority(), inst.getWeight(),
inst.getPort(), inst.getHostname()));
}
return query.createResponse(answers);
}
}
return null;
}
}
The DNSResourceRecord class provides factory methods for common
record types:
| Type | Factory Method | Description |
|---|---|---|
| A | DNSResourceRecord.a(name, ttl, address) |
IPv4 address |
| AAAA | DNSResourceRecord.aaaa(name, ttl, address) |
IPv6 address |
| CNAME | DNSResourceRecord.cname(name, ttl, canonical) |
Canonical name (alias) |
| MX | DNSResourceRecord.mx(name, ttl, priority, exchange) |
Mail exchange |
| NS | DNSResourceRecord.ns(name, ttl, nameserver) |
Authoritative name server |
| PTR | DNSResourceRecord.ptr(name, ttl, hostname) |
Pointer (reverse DNS) |
| SOA | DNSResourceRecord.soa(...) |
Start of authority |
| TXT | DNSResourceRecord.txt(name, ttl, text) |
Text record |
| SRV | DNSResourceRecord.srv(name, ttl, priority, weight, port, target) |
Service location |
DNSSEC record types (RFC 4034, RFC 5155) are parsed from wire format and provide RDATA accessor methods:
| Type | Value | Description |
|---|---|---|
| DS | 43 | Delegation signer — links child DNSKEY to parent zone |
| RRSIG | 46 | DNSSEC signature — covers an RRset |
| NSEC | 47 | Next secure record — denial of existence |
| DNSKEY | 48 | DNS public key — zone signing key or key signing key |
| NSEC3 | 50 | Hashed denial of existence (RFC 5155) |
| NSEC3PARAM | 51 | NSEC3 parameters (RFC 5155) |
When a TelemetryConfig is set on the service, DNSServerMetrics
records the following OpenTelemetry metrics. Each metric includes a
dns.transport attribute (udp, dot, or
doq) identifying the listener that handled the query.
| Metric | Type | Description |
|---|---|---|
dns.server.queries | Counter | Queries received (by dns.question.type, dns.transport) |
dns.server.responses | Counter | Responses sent (by dns.response.code, dns.transport) |
dns.server.query.duration | Histogram | Query processing time (ms) |
dns.server.cache.hits | Counter | Cache hits |
dns.server.cache.misses | Counter | Cache misses |
dns.server.upstream.queries | Counter | Queries forwarded upstream |
dns.server.upstream.duration | Histogram | Upstream query duration (ms) |
dns.server.upstream.failures | Counter | Upstream query failures |
A DNSService can have multiple listeners. Each listener exposes the same resolution logic over a different transport. You can combine any of the following:
| Listener | Class | Transport | Description |
|---|---|---|---|
| DNSListener | org.bluezoo.gumdrop.dns.DNSListener | UDP | Standard DNS over UDP, default port 53 |
| DoTListener | org.bluezoo.gumdrop.dns.DoTListener | TCP + TLS | DNS over TLS (DoT), default port 853 |
| DoQListener | org.bluezoo.gumdrop.dns.DoQListener | QUIC | DNS over QUIC (DoQ), default port 853 |
Configure one or more <listener> elements per service. For example,
you can serve plain DNS on 5353 and DoT on 853 from the same service.
Property names use kebab-case in configuration; the dependency injection
framework maps them to camelCase setters (e.g. upstream-servers
→ setUpstreamServers). Same convention as HTTP and other services.
upstream-servers - space-separated list of upstream DNS serversuse-system-resolvers - use /etc/resolv.conf nameservers
(default: true)cache-enabled - enable response caching (default: true)addresses - bind addresses (default: all interfaces)port - listening port (optional). If omitted, defaults are:
DNSListener 53, DoTListener 853, DoQListener 853.For UDP with DTLS and for DoT (TCP+TLS), use the same keystore properties as other TLS listeners (e.g. HTTP, SMTP):
secure - enable DTLS/TLS (default: false; DoTListener is always secure)keystore-file - path to keystore file (e.g. PKCS12)keystore-pass - keystore passwordneed-client-auth - require client certificates (default: false)
DoQ uses QUIC (TLS 1.3 with PEM certs), like the HTTP/3 listener. Use
cert-file and key-file, not keystore properties:
cert-file - path to certificate chain (PEM)key-file - path to private key (PEM)
<!-- DNS proxy on non-privileged port -->
<service id="dns" class="org.bluezoo.gumdrop.dns.DNSService">
<property name="upstream-servers">8.8.8.8 1.1.1.1</property>
<property name="cache-enabled">true</property>
<listener class="org.bluezoo.gumdrop.dns.DNSListener"
port="5353"/>
</service>
<!-- DNS with DoT listener (keystore-file / keystore-pass like HTTP/SMTP) -->
<service id="dns-secure" class="org.bluezoo.gumdrop.dns.DNSService">
<property name="upstream-servers">8.8.8.8</property>
<listener class="org.bluezoo.gumdrop.dns.DNSListener"
port="53"/>
<listener class="org.bluezoo.gumdrop.dns.DoTListener"
port="853" secure="true"
keystore-file="keystore.p12"
keystore-pass="secret"/>
</service>
<!-- DNS with UDP, DoT, and DoQ listeners (DoQ uses cert-file / key-file like HTTP/3) -->
<service id="dns-full" class="org.bluezoo.gumdrop.dns.DNSService">
<property name="upstream-servers">8.8.8.8 1.1.1.1</property>
<property name="cache-enabled">true</property>
<listener class="org.bluezoo.gumdrop.dns.DNSListener"
port="5353"/>
<listener class="org.bluezoo.gumdrop.dns.DoTListener"
port="853" secure="true"
keystore-file="keystore.p12"
keystore-pass="secret"/>
<listener class="org.bluezoo.gumdrop.dns.DoQListener"
port="853" secure="true"
cert-file="cert.pem"
key-file="key.pem"/>
</service>
Upstream servers can be specified with optional ports:
8.8.8.8 - Google DNS (default port 53)1.1.1.1 - Cloudflare DNS192.168.1.1:53 - local router with explicit port[2001:4860:4860::8888]:53 - IPv6 with port
DNSResolver is an asynchronous DNS client for outbound lookups.
It uses non-blocking I/O and delivers results via the
DNSQueryCallback interface. Use it when your Gumdrop service needs to resolve names (e.g. MX
lookups for SMTP, TXT for SPF/DKIM/DMARC, or A/AAAA for connecting to backends).
You can configure a DNSResolver as a component and inject it into
services that need DNS. The resolver runs on the event loop. When you set the
resolver's SelectorLoop to match the handler's endpoint (see
SelectorLoop Affinity below), callbacks are invoked on the same thread as
the handler, so you can safely update handler state from the callback without
extra locking.
Configure upstream servers (e.g. upstream-servers or
use-system-resolvers), then call queryA,
queryTXT, queryMX, etc. with a
DNSQueryCallback. Results and errors are delivered asynchronously.
When a DNSResolver is used from a Gumdrop service (e.g. an HTTP
or SMTP handler), set the resolver's SelectorLoop to the same loop as the
connection's endpoint. That way all DNS callbacks run on the same thread as the
handler, avoiding cross-thread coordination.
Call setSelectorLoop(SelectorLoop) with the endpoint's loop
before calling open(). If you do not set a SelectorLoop,
the resolver uses an arbitrary Gumdrop worker loop; callbacks will still be
correct but may run on a different thread than the handler that started the
query.
// In a handler that has access to the endpoint's SelectorLoop:
SelectorLoop loop = endpoint.getSelectorLoop();
resolver.setSelectorLoop(loop);
resolver.open();
resolver.queryTXT("_dmarc.example.com", new DNSQueryCallback() {
public void onResponse(DNSMessage response) {
// Runs on the same SelectorLoop thread as the handler
for (DNSResourceRecord rr : response.getAnswers()) {
if (rr.getType() == DNSType.TXT) {
String txt = rr.getTxtData();
// Use result...
}
}
}
public void onError(String error) {
// Handle timeout or error
}
});
In configuration, if your service obtains the resolver by reference (e.g. from
the registry), the service's init() or the code that wires the
resolver to the handler should set the SelectorLoop from the handler's
endpoint when the first request arrives, or use a shared loop if your design
assigns one loop per service.
The resolver uses a pluggable DNSClientTransport interface.
The default is plain UDP, but you can select any of the following:
| Transport | Class | RFC | Default Port |
|---|---|---|---|
| UDP | UDPDNSClientTransport |
RFC 1035 | 53 |
| TCP | TCPDNSClientTransport |
RFC 1035 | 53 |
| DoT | TCPDNSClientTransport (secure) |
RFC 7858 | 853 |
| DoQ | DoQClientTransport |
RFC 9250 | 853 |
| DoH | DoHClientTransport |
RFC 8484 | 443 |
To use DoT, create a TCPDNSClientTransport with
setSecure(true) and optionally configure SPKI fingerprints
for the Strict privacy profile. For DoQ, use DoQClientTransport
or DoQConnectionPool for persistent connections. For DoH, use
DoHClientTransport and optionally set a custom URI path.
When a UDP response has the TC (truncated) flag set, DNSResolver
automatically retries over TCP using standard DNS-over-TCP framing (2-byte
length prefix, RFC 1035 section 4.2.2). The retry is transparent to the caller.
DNSResolver provides a convenience method for SRV lookups
(RFC 2782) commonly used for service discovery:
resolver.querySRV("_xmpp-server._tcp.example.com",
new DNSQueryCallback() {
public void onResponse(DNSMessage response) {
for (DNSResourceRecord rr : response.getAnswers()) {
System.out.println(rr.getSRVTarget() + ":"
+ rr.getSRVPort()
+ " (priority=" + rr.getSRVPriority()
+ ", weight=" + rr.getSRVWeight() + ")");
}
}
public void onError(String error) {
// Handle error
}
});
The DNS server showcases Gumdrop's UDP protocol support through the
UDPEndpoint abstraction, created via UDPTransportFactory.
This demonstrates how to build UDP-based services within the event-driven framework.
UDPEndpoint is the UDP equivalent of TCPEndpoint.
Unlike TCP, there are no per-client endpoint objects—the server receives
datagrams directly and sends responses to the source address. A
UDPEndpoint is created by adding a UDPTransportFactory
to a UDPListener.
public abstract class UDPEndpoint extends Endpoint {
// Override to handle incoming datagrams
protected abstract void receive(ByteBuffer data, InetSocketAddress source);
// Send a response datagram
public void sendTo(ByteBuffer data, InetSocketAddress destination);
}
| Aspect | TCP (TCPListener) | UDP (UDPEndpoint) |
|---|---|---|
| Endpoint model | Per-client TCPEndpoint instances | Single endpoint, multiple sources |
| Message boundaries | Stream-based, must frame | Preserved per datagram |
| Reliability | Guaranteed delivery | Best-effort, may lose/reorder |
| TLS support | SSL/TLS | DTLS |
The DNS server serves as a template for building other UDP-based services.
Create a UDPListener with a UDPTransportFactory
and provide an ProtocolHandler to process datagrams:
UDPListener server = new UDPListener();
server.setPort(9000);
server.setDescription("my-service");
server.setTransportFactory(new UDPTransportFactory());
server.setHandlerFactory(new ProtocolHandlerFactory() {
public ProtocolHandler createHandler() {
return new MyUDPHandler();
}
});
public class MyUDPHandler implements ProtocolHandler {
@Override
public void receive(ByteBuffer data, Endpoint endpoint) {
// Parse the datagram
MyProtocolMessage request = MyProtocolMessage.parse(data);
// Process and generate response
MyProtocolMessage response = process(request);
// Send response back
endpoint.write(response.serialize());
}
}
The DNS server demonstrates patterns applicable to other UDP protocols:
← Back to Main Page | HTTP Server & Client | SMTP Server & Client | Telemetry
Gumdrop DNS Server