Skip to content

Commit 34cb396

Browse files
committed
nixos/hickory-dns: Add test
1 parent 19f22fd commit 34cb396

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

nixos/tests/all-tests.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,7 @@ in
726726
healthchecks = runTest ./web-apps/healthchecks.nix;
727727
hedgedoc = runTest ./hedgedoc.nix;
728728
herbstluftwm = runTest ./herbstluftwm.nix;
729+
hickory-dns = runTest ./hickory-dns.nix;
729730
# 9pnet_virtio used to mount /nix partition doesn't support
730731
# hibernation. This test happens to work on x86_64-linux but
731732
# not on other platforms.

nixos/tests/hickory-dns.nix

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
{ pkgs, lib, ... }:
2+
3+
let
4+
cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
5+
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes \
6+
-subj '/CN=dns.example.local' \
7+
-addext 'subjectAltName = DNS:dns.example.local'
8+
mkdir -p $out
9+
cp key.pem cert.pem $out
10+
'';
11+
12+
zoneFile = pkgs.writeText "example.local.zone" ''
13+
$TTL 3600
14+
@ IN SOA dns.example.local. admin.example.local. (
15+
1 ; Serial
16+
3600 ; Refresh
17+
1800 ; Retry
18+
604800 ; Expire
19+
3600 ; Minimum TTL
20+
)
21+
NS dns.example.local.
22+
dns A 192.168.0.2
23+
dns AAAA fd21::2
24+
example.local. A 1.2.3.4
25+
example.local. AAAA abcd::eeff
26+
'';
27+
in
28+
{
29+
name = "hickory-dns";
30+
meta = with pkgs.lib.maintainers; {
31+
maintainers = [ jpds ];
32+
};
33+
34+
nodes = {
35+
authoritative =
36+
{ ... }:
37+
{
38+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
39+
{
40+
address = "192.168.0.1";
41+
prefixLength = 24;
42+
}
43+
];
44+
networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
45+
{
46+
address = "fd21::1";
47+
prefixLength = 64;
48+
}
49+
];
50+
networking.firewall.allowedTCPPorts = [ 53 ];
51+
networking.firewall.allowedUDPPorts = [ 53 ];
52+
53+
services.hickory-dns = {
54+
enable = true;
55+
settings = {
56+
listen_addrs_ipv4 = [ "0.0.0.0" ];
57+
listen_addrs_ipv6 = [ "::0" ];
58+
zones = [
59+
{
60+
zone = "example.local";
61+
zone_type = "Primary";
62+
file = toString zoneFile;
63+
}
64+
];
65+
};
66+
};
67+
};
68+
69+
forwarder =
70+
{ ... }:
71+
{
72+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
73+
{
74+
address = "192.168.0.2";
75+
prefixLength = 24;
76+
}
77+
];
78+
networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
79+
{
80+
address = "fd21::2";
81+
prefixLength = 64;
82+
}
83+
];
84+
networking.firewall.allowedTCPPorts = [
85+
53
86+
443 # DNS over HTTPS
87+
853 # DNS over TLS
88+
];
89+
networking.firewall.allowedUDPPorts = [ 53 ];
90+
91+
services.hickory-dns = {
92+
enable = true;
93+
settings = {
94+
listen_addrs_ipv4 = [ "0.0.0.0" ];
95+
listen_addrs_ipv6 = [ "::0" ];
96+
tls_listen_port = 853;
97+
https_listen_port = 443;
98+
tls_cert = {
99+
path = "${cert}/cert.pem";
100+
endpoint_name = "dns.example.local";
101+
private_key = "${cert}/key.pem";
102+
};
103+
zones = [
104+
{
105+
zone = "example.local";
106+
zone_type = "External";
107+
stores = {
108+
type = "forward";
109+
name_servers = [
110+
{
111+
ip = "192.168.0.1";
112+
trust_negative_responses = false;
113+
connections = [
114+
{
115+
protocol = {
116+
type = "udp";
117+
};
118+
}
119+
{
120+
protocol = {
121+
type = "tcp";
122+
};
123+
}
124+
];
125+
}
126+
];
127+
};
128+
}
129+
];
130+
};
131+
};
132+
};
133+
134+
client =
135+
{ lib, nodes, ... }:
136+
{
137+
environment.systemPackages = [
138+
pkgs.hickory-dns # resolve binary
139+
pkgs.knot-dns # kdig for DoT/DoH (resolve doesn't support TLS transports)
140+
];
141+
networking.nameservers = [
142+
(lib.head nodes.forwarder.networking.interfaces.eth1.ipv6.addresses).address
143+
(lib.head nodes.forwarder.networking.interfaces.eth1.ipv4.addresses).address
144+
];
145+
networking.interfaces.eth1.ipv4.addresses = [
146+
{
147+
address = "192.168.0.10";
148+
prefixLength = 24;
149+
}
150+
];
151+
networking.interfaces.eth1.ipv6.addresses = [
152+
{
153+
address = "fd21::10";
154+
prefixLength = 64;
155+
}
156+
];
157+
security.pki.certificateFiles = [ "${cert}/cert.pem" ];
158+
networking.hosts = {
159+
"192.168.0.2" = [ "dns.example.local" ];
160+
"fd21::2" = [ "dns.example.local" ];
161+
};
162+
};
163+
};
164+
165+
testScript =
166+
{ nodes, ... }:
167+
let
168+
forwarderIPv4 = (lib.head nodes.forwarder.networking.interfaces.eth1.ipv4.addresses).address;
169+
forwarderIPv6 = (lib.head nodes.forwarder.networking.interfaces.eth1.ipv6.addresses).address;
170+
in
171+
''
172+
import typing
173+
174+
zone = "example.local."
175+
records = [("AAAA", "abcd::eeff"), ("A", "1.2.3.4")]
176+
177+
178+
def resolve_query(
179+
machine,
180+
host: str,
181+
query_type: str,
182+
name: str,
183+
expected: typing.Optional[str] = None,
184+
tcp: bool = False,
185+
):
186+
port = 53
187+
addr = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
188+
189+
proto_flag = "--tcp" if tcp else "--udp"
190+
191+
raw = machine.succeed(
192+
f"resolve -t {query_type} {proto_flag} --nameserver {addr} {name}"
193+
)
194+
195+
answers = []
196+
in_answer = False
197+
198+
for line in raw.splitlines():
199+
if ";; ANSWER SECTION:" in line:
200+
in_answer = True
201+
elif in_answer and line.startswith(";; "):
202+
break
203+
elif in_answer and line.startswith("\t"):
204+
answers.append(line.split()[-1])
205+
206+
out = "\n".join(answers)
207+
208+
machine.log(f"{host} replied with {out}")
209+
210+
if expected is not None:
211+
assert expected == out, f"Expected `{expected}` but got `{out}`"
212+
213+
214+
def kdig_query(
215+
machine,
216+
host: str,
217+
query_type: str,
218+
name: str,
219+
expected: typing.Optional[str] = None,
220+
args: typing.Optional[typing.List[str]] = None,
221+
):
222+
text_args = " ".join(args or [])
223+
224+
raw = machine.succeed(
225+
f"kdig {text_args} {name} {query_type} @{host} +short"
226+
).strip()
227+
228+
out = "\n".join(line for line in raw.splitlines() if not line.startswith(";;")).strip()
229+
230+
machine.log(f"{host} replied with {out}")
231+
232+
if expected is not None:
233+
assert expected == out, f"Expected `{expected}` but got `{out}`"
234+
235+
236+
def test(machine, remotes, zone=zone, records=records):
237+
for query_type, expected in records:
238+
for remote in remotes:
239+
# Test UDP
240+
resolve_query(machine, remote, query_type, zone, expected, tcp=False)
241+
kdig_query(machine, remote, query_type, zone, expected)
242+
243+
# Test TCP
244+
resolve_query(machine, remote, query_type, zone, expected, tcp=True)
245+
kdig_query(machine, remote, query_type, zone, expected, ["+tcp"])
246+
247+
# Test DoT/DoH
248+
kdig_query(machine, "dns.example.local", query_type, zone, expected, ["+tcp", "+tls"])
249+
kdig_query(machine, "dns.example.local", query_type, zone, expected, ["+https"])
250+
251+
252+
authoritative.wait_for_unit("hickory-dns.service")
253+
forwarder.wait_for_unit("hickory-dns.service")
254+
forwarder.wait_for_open_port(53)
255+
forwarder.wait_for_open_port(853)
256+
forwarder.wait_for_open_port(443)
257+
258+
client.systemctl("start network-online.target")
259+
client.wait_for_unit("network-online.target")
260+
261+
with subtest("forwarder resolves queries via authoritative nameserver"):
262+
test(
263+
client,
264+
["${forwarderIPv6}", "${forwarderIPv4}"],
265+
)
266+
'';
267+
}

0 commit comments

Comments
 (0)