Skip to content

Commit 9d64e9b

Browse files
committed
fix: adding missing dns class
1 parent 6bde80f commit 9d64e9b

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package io.github.hectorvent.floci.core.common.dns;
2+
3+
import io.github.hectorvent.floci.config.EmulatorConfig;
4+
import io.github.hectorvent.floci.core.common.docker.ContainerDetector;
5+
import io.vertx.core.Vertx;
6+
import io.vertx.core.buffer.Buffer;
7+
import io.vertx.core.datagram.DatagramSocket;
8+
import io.vertx.core.datagram.DatagramSocketOptions;
9+
import jakarta.enterprise.context.ApplicationScoped;
10+
import jakarta.inject.Inject;
11+
import org.jboss.logging.Logger;
12+
13+
import java.net.DatagramPacket;
14+
import java.net.InetAddress;
15+
import java.nio.ByteBuffer;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.util.ArrayList;
19+
import java.util.Arrays;
20+
import java.util.List;
21+
import java.util.Optional;
22+
23+
/**
24+
* Embedded UDP/53 DNS server that runs inside the Floci container and is injected
25+
* into every spawned container (Lambda, RDS, ElastiCache) as their DNS resolver.
26+
*
27+
* Resolves *.{floci.hostname} (and any configured extra-suffixes) to Floci's own
28+
* Docker network IP so virtual-hosted S3 URLs (my-bucket.floci:4566) work from
29+
* inside Lambda containers without requiring wildcard Docker aliases.
30+
*
31+
* All other queries are forwarded transparently to the upstream resolver read from
32+
* /etc/resolv.conf (Docker's embedded DNS at 127.0.0.11).
33+
*
34+
* Only starts when Floci detects it is running inside Docker. No-op on the host.
35+
*/
36+
@ApplicationScoped
37+
public class EmbeddedDnsServer {
38+
39+
private static final Logger LOG = Logger.getLogger(EmbeddedDnsServer.class);
40+
private static final int DNS_PORT = 53;
41+
private static final int TTL = 60;
42+
private static final String FALLBACK_UPSTREAM = "127.0.0.11";
43+
44+
private volatile String serverIp;
45+
private final List<String> suffixes = new ArrayList<>();
46+
private String upstreamDns;
47+
48+
EmbeddedDnsServer(List<String> suffixes) {
49+
this.suffixes.addAll(suffixes);
50+
}
51+
52+
@Inject
53+
public EmbeddedDnsServer(EmulatorConfig config, ContainerDetector containerDetector, Vertx vertx) {
54+
if (!containerDetector.isRunningInContainer()) {
55+
return;
56+
}
57+
try {
58+
String myIp = InetAddress.getLocalHost().getHostAddress();
59+
upstreamDns = readUpstreamDns();
60+
61+
config.hostname().ifPresent(suffixes::add);
62+
config.dns().extraSuffixes().ifPresent(suffixes::addAll);
63+
64+
DatagramSocket socket = vertx.createDatagramSocket(new DatagramSocketOptions().setIpV6(false));
65+
socket.listen(DNS_PORT, "0.0.0.0", ar -> {
66+
if (ar.succeeded()) {
67+
serverIp = myIp;
68+
LOG.infov("Embedded DNS server started on {0}:53, resolving {1} → {0}", myIp, suffixes);
69+
socket.handler(packet -> handleQuery(
70+
vertx, socket, packet.data().getBytes(),
71+
packet.sender().host(), packet.sender().port(), myIp));
72+
} else {
73+
LOG.warnv("Embedded DNS server failed to bind on port 53: {0}", ar.cause().getMessage());
74+
}
75+
});
76+
} catch (Exception e) {
77+
LOG.warnv("Failed to initialize embedded DNS server: {0}", e.getMessage());
78+
}
79+
}
80+
81+
public Optional<String> getServerIp() {
82+
return Optional.ofNullable(serverIp);
83+
}
84+
85+
// ── packet handling ───────────────────────────────────────────────────────
86+
87+
private void handleQuery(Vertx vertx, DatagramSocket socket, byte[] data,
88+
String senderHost, int senderPort, String myIp) {
89+
try {
90+
ByteBuffer buf = ByteBuffer.wrap(data);
91+
short txId = buf.getShort();
92+
short flags = buf.getShort();
93+
short qdCount = buf.getShort();
94+
buf.getShort(); // ancount
95+
buf.getShort(); // nscount
96+
buf.getShort(); // arcount
97+
98+
if ((flags & 0x8000) != 0 || qdCount < 1) {
99+
return; // not a standard query
100+
}
101+
102+
int questionOffset = buf.position(); // always 12 for a standard query
103+
String qname = readName(buf, data);
104+
short qtype = buf.getShort();
105+
buf.getShort(); // qclass
106+
int questionEnd = buf.position();
107+
108+
if (qtype == 1 && matchesSuffix(qname)) {
109+
byte[] response = buildAResponse(data, txId, questionOffset, questionEnd, myIp);
110+
socket.send(Buffer.buffer(response), senderPort, senderHost, v -> {});
111+
} else {
112+
forwardAsync(vertx, socket, data, senderHost, senderPort);
113+
}
114+
} catch (Exception e) {
115+
LOG.debugv("DNS packet error: {0}", e.getMessage());
116+
}
117+
}
118+
119+
// ── helpers ───────────────────────────────────────────────────────────────
120+
121+
boolean matchesSuffix(String name) {
122+
if (name == null || name.isEmpty()) {
123+
return false;
124+
}
125+
String lower = name.toLowerCase();
126+
for (String suffix : suffixes) {
127+
String s = suffix.toLowerCase();
128+
if (lower.equals(s) || lower.endsWith("." + s)) {
129+
return true;
130+
}
131+
}
132+
return false;
133+
}
134+
135+
String readName(ByteBuffer buf, byte[] data) {
136+
StringBuilder sb = new StringBuilder();
137+
int safety = 0;
138+
while (buf.hasRemaining() && safety++ < 128) {
139+
int len = buf.get() & 0xFF;
140+
if (len == 0) {
141+
break;
142+
}
143+
if ((len & 0xC0) == 0xC0) {
144+
// compression pointer
145+
int offset = ((len & 0x3F) << 8) | (buf.get() & 0xFF);
146+
ByteBuffer ptr = ByteBuffer.wrap(data);
147+
ptr.position(offset);
148+
if (sb.length() > 0) {
149+
sb.append('.');
150+
}
151+
sb.append(readName(ptr, data));
152+
return sb.toString();
153+
}
154+
if (sb.length() > 0) {
155+
sb.append('.');
156+
}
157+
byte[] label = new byte[len];
158+
buf.get(label);
159+
sb.append(new String(label));
160+
}
161+
return sb.toString();
162+
}
163+
164+
byte[] buildAResponse(byte[] query, short txId, int questionOffset, int questionEnd, String ip) {
165+
int questionLength = questionEnd - questionOffset;
166+
// header(12) + question + answer(name-ptr(2) + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(4))
167+
ByteBuffer resp = ByteBuffer.allocate(12 + questionLength + 16);
168+
169+
// header
170+
resp.putShort(txId);
171+
resp.putShort((short) 0x8180); // QR=1, AA=1, RD=1, RCODE=0
172+
resp.putShort((short) 1); // qdcount
173+
resp.putShort((short) 1); // ancount
174+
resp.putShort((short) 0); // nscount
175+
resp.putShort((short) 0); // arcount
176+
177+
// question (copied verbatim from query)
178+
resp.put(query, questionOffset, questionLength);
179+
180+
// answer
181+
resp.putShort((short) 0xC00C); // name pointer to offset 12 (start of question name)
182+
resp.putShort((short) 1); // type A
183+
resp.putShort((short) 1); // class IN
184+
resp.putInt(TTL);
185+
resp.putShort((short) 4); // rdlength
186+
187+
for (String octet : ip.split("\\.")) {
188+
resp.put((byte) Integer.parseInt(octet));
189+
}
190+
191+
return resp.array();
192+
}
193+
194+
private void forwardAsync(Vertx vertx, DatagramSocket socket, byte[] query,
195+
String senderHost, int senderPort) {
196+
String upstream = upstreamDns;
197+
if (upstream == null) {
198+
return;
199+
}
200+
vertx.executeBlocking(() -> {
201+
try (java.net.DatagramSocket fwd = new java.net.DatagramSocket()) {
202+
fwd.setSoTimeout(2000);
203+
InetAddress addr = InetAddress.getByName(upstream);
204+
fwd.send(new DatagramPacket(query, query.length, addr, DNS_PORT));
205+
byte[] buf = new byte[512];
206+
DatagramPacket resp = new DatagramPacket(buf, buf.length);
207+
fwd.receive(resp);
208+
return Arrays.copyOf(resp.getData(), resp.getLength());
209+
}
210+
}).onSuccess(response ->
211+
socket.send(Buffer.buffer(response), senderPort, senderHost, v -> {})
212+
).onFailure(e ->
213+
LOG.debugv("DNS forwarding to {0} failed: {1}", upstream, e.getMessage())
214+
);
215+
}
216+
217+
private String readUpstreamDns() {
218+
try {
219+
for (String line : Files.readAllLines(Path.of("/etc/resolv.conf"))) {
220+
line = line.trim();
221+
if (line.startsWith("nameserver ")) {
222+
String server = line.substring("nameserver ".length()).trim();
223+
if (!server.equals("127.0.0.1")) {
224+
return server;
225+
}
226+
}
227+
}
228+
} catch (Exception e) {
229+
LOG.debugv("Could not read /etc/resolv.conf: {0}", e.getMessage());
230+
}
231+
return FALLBACK_UPSTREAM;
232+
}
233+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package io.github.hectorvent.floci.core.common.dns;
2+
3+
import org.junit.jupiter.api.BeforeEach;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.nio.ByteBuffer;
7+
import java.util.List;
8+
9+
import static org.junit.jupiter.api.Assertions.*;
10+
11+
class EmbeddedDnsServerTest {
12+
13+
private EmbeddedDnsServer dns;
14+
15+
@BeforeEach
16+
void setUp() {
17+
dns = new EmbeddedDnsServer(List.of("localhost.floci.io"));
18+
}
19+
20+
// ── matchesSuffix ─────────────────────────────────────────────────────────
21+
22+
@Test
23+
void matchesSuffix_exactMatch() {
24+
assertTrue(dns.matchesSuffix("localhost.floci.io"));
25+
}
26+
27+
@Test
28+
void matchesSuffix_singleSubdomain() {
29+
assertTrue(dns.matchesSuffix("my-bucket.localhost.floci.io"));
30+
}
31+
32+
@Test
33+
void matchesSuffix_deeplyNested() {
34+
assertTrue(dns.matchesSuffix("deeply.nested.bucket.localhost.floci.io"));
35+
}
36+
37+
@Test
38+
void matchesSuffix_caseInsensitive() {
39+
assertTrue(dns.matchesSuffix("My-Bucket.Localhost.Floci.IO"));
40+
}
41+
42+
@Test
43+
void matchesSuffix_noMatch() {
44+
assertFalse(dns.matchesSuffix("my-bucket.s3.amazonaws.com"));
45+
}
46+
47+
@Test
48+
void matchesSuffix_partialSuffixNoMatch() {
49+
assertFalse(dns.matchesSuffix("floci.io"));
50+
}
51+
52+
@Test
53+
void matchesSuffix_nullAndEmpty() {
54+
assertFalse(dns.matchesSuffix(null));
55+
assertFalse(dns.matchesSuffix(""));
56+
}
57+
58+
// ── readName ──────────────────────────────────────────────────────────────
59+
60+
@Test
61+
void readName_simple() {
62+
// my-bucket.localhost.floci.io encoded as DNS labels
63+
byte[] encoded = encodeName("my-bucket.localhost.floci.io");
64+
ByteBuffer buf = ByteBuffer.wrap(encoded);
65+
assertEquals("my-bucket.localhost.floci.io", dns.readName(buf, encoded));
66+
}
67+
68+
@Test
69+
void readName_singleLabel() {
70+
byte[] encoded = encodeName("floci");
71+
ByteBuffer buf = ByteBuffer.wrap(encoded);
72+
assertEquals("floci", dns.readName(buf, encoded));
73+
}
74+
75+
@Test
76+
void readName_withCompressionPointer() {
77+
// Build a buffer where the name at offset 12 is "floci.io" and
78+
// a pointer at offset 0 points to it.
79+
byte[] data = new byte[20];
80+
// pointer at offset 0 → offset 4
81+
data[0] = (byte) 0xC0;
82+
data[1] = 0x04;
83+
// "floci.io" at offset 4
84+
byte[] name = encodeName("floci.io");
85+
System.arraycopy(name, 0, data, 4, name.length);
86+
87+
ByteBuffer buf = ByteBuffer.wrap(data);
88+
assertEquals("floci.io", dns.readName(buf, data));
89+
}
90+
91+
// ── buildAResponse ────────────────────────────────────────────────────────
92+
93+
@Test
94+
void buildAResponse_hasCorrectTransactionId() {
95+
byte[] query = buildQuery("my-bucket.localhost.floci.io", (short) 0x1234);
96+
byte[] response = dns.buildAResponse(query, (short) 0x1234, 12, query.length, "172.19.0.2");
97+
short txId = ByteBuffer.wrap(response).getShort(0);
98+
assertEquals((short) 0x1234, txId);
99+
}
100+
101+
@Test
102+
void buildAResponse_flagsIndicateResponse() {
103+
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 1);
104+
byte[] response = dns.buildAResponse(query, (short) 1, 12, query.length, "10.0.0.1");
105+
short flags = ByteBuffer.wrap(response).getShort(2);
106+
assertTrue((flags & 0x8000) != 0, "QR bit must be set");
107+
}
108+
109+
@Test
110+
void buildAResponse_answerCountIsOne() {
111+
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 2);
112+
byte[] response = dns.buildAResponse(query, (short) 2, 12, query.length, "10.0.0.1");
113+
short ancount = ByteBuffer.wrap(response).getShort(6);
114+
assertEquals(1, ancount);
115+
}
116+
117+
@Test
118+
void buildAResponse_ipAddressIsCorrect() {
119+
byte[] query = buildQuery("bucket.localhost.floci.io", (short) 3);
120+
byte[] response = dns.buildAResponse(query, (short) 3, 12, query.length, "172.19.0.42");
121+
// IP starts at offset: 12 (header) + questionLength + 2+2+2+4+2 = questionLength + 24
122+
int questionLength = query.length - 12;
123+
ByteBuffer resp = ByteBuffer.wrap(response);
124+
resp.position(12 + questionLength + 10); // skip header + question + name-ptr(2) + type(2) + class(2) + ttl(4)
125+
short rdlen = resp.getShort();
126+
assertEquals(4, rdlen);
127+
assertEquals((byte) 172, resp.get());
128+
assertEquals((byte) 19, resp.get());
129+
assertEquals((byte) 0, resp.get());
130+
assertEquals((byte) 42, resp.get());
131+
}
132+
133+
// ── helpers ───────────────────────────────────────────────────────────────
134+
135+
private byte[] encodeName(String name) {
136+
String[] labels = name.split("\\.");
137+
int len = 1; // trailing zero
138+
for (String l : labels) len += 1 + l.length();
139+
byte[] buf = new byte[len];
140+
int pos = 0;
141+
for (String label : labels) {
142+
buf[pos++] = (byte) label.length();
143+
for (char c : label.toCharArray()) buf[pos++] = (byte) c;
144+
}
145+
buf[pos] = 0;
146+
return buf;
147+
}
148+
149+
private byte[] buildQuery(String name, short txId) {
150+
byte[] encodedName = encodeName(name);
151+
// header(12) + name + type(2) + class(2)
152+
ByteBuffer buf = ByteBuffer.allocate(12 + encodedName.length + 4);
153+
buf.putShort(txId);
154+
buf.putShort((short) 0x0100); // standard query, RD=1
155+
buf.putShort((short) 1); // qdcount
156+
buf.putShort((short) 0);
157+
buf.putShort((short) 0);
158+
buf.putShort((short) 0);
159+
buf.put(encodedName);
160+
buf.putShort((short) 1); // type A
161+
buf.putShort((short) 1); // class IN
162+
return buf.array();
163+
}
164+
}

0 commit comments

Comments
 (0)