1{ lib, pkgs, ... }:
2
3let
4 dnstapSocket = "/var/run/vector/dnstap.sock";
5in
6{
7 name = "vector-dnstap";
8 meta.maintainers = [ pkgs.lib.maintainers.happysalada ];
9
10 nodes = {
11 clickhouse =
12 { config, pkgs, ... }:
13 {
14 networking.firewall.allowedTCPPorts = [ 6000 ];
15
16 services.vector = {
17 enable = true;
18
19 settings = {
20 sources = {
21 vector_dnstap_source = {
22 type = "vector";
23 address = "[::]:6000";
24 };
25 };
26
27 sinks = {
28 clickhouse = {
29 type = "clickhouse";
30 inputs = [
31 "vector_dnstap_source"
32 ];
33 endpoint = "http://localhost:8123";
34 database = "dnstap";
35 table = "records";
36 date_time_best_effort = true;
37 };
38 };
39 };
40 };
41
42 services.clickhouse.enable = true;
43 };
44
45 knot =
46 {
47 config,
48 nodes,
49 pkgs,
50 ...
51 }:
52 let
53 exampleZone = pkgs.writeTextDir "example.com.zone" ''
54 @ SOA ns.example.com. noc.example.com. 2019031301 86400 7200 3600000 172800
55 @ NS ns1
56 @ NS ns2
57 ns1 A 192.168.0.1
58 ns1 AAAA fd00::1
59 ns2 A 192.168.0.2
60 ns2 AAAA fd00::2
61 www A 192.0.2.1
62 www AAAA 2001:DB8::1
63 sub NS ns.example.com.
64 '';
65
66 knotZonesEnv = pkgs.buildEnv {
67 name = "knot-zones";
68 paths = [
69 exampleZone
70 ];
71 };
72 in
73 {
74 networking.firewall.allowedUDPPorts = [ 53 ];
75
76 services.vector = {
77 enable = true;
78
79 settings = {
80 sources = {
81 dnstap = {
82 type = "dnstap";
83 multithreaded = true;
84 mode = "unix";
85 lowercase_hostnames = true;
86 socket_file_mode = 504;
87 socket_path = "${dnstapSocket}";
88 };
89 };
90
91 sinks = {
92 vector_dnstap_sink = {
93 type = "vector";
94 inputs = [ "dnstap" ];
95 address = "clickhouse:6000";
96 };
97 };
98 };
99 };
100
101 systemd.services.vector.serviceConfig = {
102 RuntimeDirectory = "vector";
103 RuntimeDirectoryMode = "0770";
104 };
105
106 services.knot = {
107 enable = true;
108 settings = {
109 server = {
110 listen = [
111 "0.0.0.0@53"
112 "::@53"
113 ];
114 automatic-acl = true;
115 };
116 template.default = {
117 storage = knotZonesEnv;
118 dnssec-signing = false;
119 # Input-only zone files
120 # https://www.knot-dns.cz/docs/2.8/html/operation.html#example-3
121 # prevents modification of the zonefiles, since the zonefiles are immutable
122 zonefile-sync = -1;
123 zonefile-load = "difference";
124 journal-content = "changes";
125 global-module = "mod-dnstap/capture_all";
126 };
127 zone = {
128 "example.com".file = "example.com.zone";
129 };
130
131 mod-dnstap = [
132 {
133 id = "capture_all";
134 sink = "unix:${dnstapSocket}";
135 }
136 ];
137 };
138 };
139
140 systemd.services.knot = {
141 after = [ "vector.service" ];
142 wants = [ "vector.service" ];
143 serviceConfig = {
144 # DNSTAP access
145 ReadWritePaths = [ "/var/run/vector" ];
146 SupplementaryGroups = [ "vector" ];
147 };
148 };
149 };
150
151 unbound =
152 {
153 config,
154 nodes,
155 pkgs,
156 ...
157 }:
158 {
159 networking.firewall.allowedUDPPorts = [ 53 ];
160
161 services.vector = {
162 enable = true;
163
164 settings = {
165 sources = {
166 dnstap = {
167 type = "dnstap";
168 multithreaded = true;
169 mode = "unix";
170 lowercase_hostnames = true;
171 socket_file_mode = 504;
172 socket_path = "${dnstapSocket}";
173 };
174 };
175
176 sinks = {
177 file = {
178 type = "file";
179 inputs = [ "dnstap" ];
180 path = "/var/lib/vector/logs.log";
181 encoding = {
182 codec = "json";
183 };
184 };
185
186 vector_dnstap_sink = {
187 type = "vector";
188 inputs = [ "dnstap" ];
189 address = "clickhouse:6000";
190 };
191 };
192 };
193 };
194
195 systemd.services.vector.serviceConfig = {
196 RuntimeDirectory = "vector";
197 RuntimeDirectoryMode = "0770";
198 };
199
200 services.unbound = {
201 enable = true;
202 enableRootTrustAnchor = false;
203 package = pkgs.unbound-full;
204 settings = {
205 server = {
206 interface = [
207 "0.0.0.0"
208 "::"
209 ];
210 access-control = [
211 "192.168.0.0/24 allow"
212 "::/0 allow"
213 ];
214
215 domain-insecure = "local";
216 private-domain = "local";
217
218 local-zone = "local. static";
219 local-data = [
220 ''"test.local. 10800 IN A 192.168.123.5"''
221 ];
222 };
223
224 forward-zone = [
225 {
226 name = "example.com.";
227 forward-addr = [
228 nodes.knot.networking.primaryIPv6Address
229 nodes.knot.networking.primaryIPAddress
230 ];
231 }
232 ];
233
234 dnstap = {
235 dnstap-enable = "yes";
236 dnstap-socket-path = "${dnstapSocket}";
237 dnstap-send-identity = "yes";
238 dnstap-send-version = "yes";
239 dnstap-log-client-query-messages = "yes";
240 dnstap-log-client-response-messages = "yes";
241 };
242 };
243 };
244
245 systemd.services.unbound = {
246 after = [ "vector.service" ];
247 wants = [ "vector.service" ];
248 serviceConfig = {
249 # DNSTAP access
250 ReadWritePaths = [ "/var/run/vector" ];
251 SupplementaryGroups = [ "vector" ];
252 };
253 };
254 };
255
256 dnsclient =
257 { config, pkgs, ... }:
258 {
259 environment.systemPackages = [ pkgs.dig ];
260 };
261 };
262
263 testScript =
264 let
265 # work around quote/substitution complexity by Nix, Perl, bash and SQL.
266 databaseDDL = pkgs.writeText "database.sql" "CREATE DATABASE IF NOT EXISTS dnstap";
267
268 tableDDL = pkgs.writeText "table.sql" ''
269 CREATE TABLE IF NOT EXISTS dnstap.records (
270 timestamp DateTime64(6),
271 dataType Enum('Message' = 1),
272 messageType Enum(
273 'AuthQuery' = 1,
274 'AuthResponse' = 2,
275 'ResolverQuery' = 3,
276 'ResolverResponse' = 4,
277 'ClientQuery' = 5,
278 'ClientResponse' = 6,
279 'ForwarderQuery' = 7,
280 'ForwarderResponse' = 8,
281 'StubQuery' = 9,
282 'StubResponse' = 10,
283 'ToolQuery' = 11,
284 'ToolResponse' = 12,
285 'UpdateQuery' = 13,
286 'UpdateResponse' = 14
287 ),
288 queryZone Nullable(String),
289 requestData Nullable(JSON),
290 responseAddress String,
291 responseData Nullable(JSON),
292 responsePort UInt16,
293 serverId LowCardinality(String),
294 serverVersion LowCardinality(String),
295 socketFamily Enum('INET' = 1, 'INET6' = 2),
296 socketProtocol Enum(
297 'UDP' = 1,
298 'TCP' = 2,
299 'DOT' = 3,
300 'DOH' = 4,
301 'DNSCryptUDP' = 5,
302 'DNSCryptTCP' = 6,
303 'DOQ' = 7
304 ),
305 sourceAddress String,
306 sourcePort UInt16,
307 )
308 ENGINE = MergeTree()
309 ORDER BY (serverId, timestamp)
310 PARTITION BY toYYYYMM(timestamp)
311 '';
312
313 tableView = pkgs.writeText "view.sql" ''
314 CREATE MATERIALIZED VIEW dnstap.domains_view (
315 timestamp DateTime64(6),
316 serverId LowCardinality(String),
317 domain String,
318 record_type LowCardinality(String)
319 )
320 ENGINE = MergeTree()
321 PARTITION BY toYYYYMM(timestamp)
322 ORDER BY (serverId, toStartOfHour(timestamp), domain, timestamp)
323 POPULATE AS
324 SELECT
325 timestamp,
326 serverId,
327 JSONExtractString(requestData.question[1]::String, 'domainName') as domain,
328 JSONExtractString(requestData.question[1]::String, 'questionType') as record_type
329 FROM dnstap.records
330 WHERE messageType = 'ClientQuery'
331 '';
332
333 selectDomainCountQuery = pkgs.writeText "select-domain-count.sql" ''
334 SELECT
335 domain,
336 count(domain)
337 FROM dnstap.domains_view
338 GROUP BY domain
339 '';
340
341 selectAuthResponseQuery = pkgs.writeText "select-auth-response.sql" ''
342 SELECT
343 *
344 FROM dnstap.records
345 WHERE messageType = 'AuthResponse'
346 '';
347 in
348 ''
349 clickhouse.wait_for_unit("clickhouse")
350 clickhouse.wait_for_open_port(6000)
351 clickhouse.wait_for_open_port(8123)
352
353 clickhouse.succeed(
354 "cat ${databaseDDL} | clickhouse-client",
355 "cat ${tableDDL} | clickhouse-client",
356 "cat ${tableView} | clickhouse-client",
357 )
358
359 knot.wait_for_unit("knot")
360 unbound.wait_for_unit("unbound")
361
362 for machine in knot, unbound:
363 machine.wait_for_unit("vector")
364
365 machine.wait_until_succeeds(
366 "journalctl -o cat -u vector.service | grep 'Socket permissions updated to 0o770'"
367 )
368 machine.wait_until_succeeds(
369 "journalctl -o cat -u vector.service | grep 'component_type=dnstap' | grep 'Listening... path=\"${dnstapSocket}\"'"
370 )
371
372 machine.wait_for_open_unix_socket("${dnstapSocket}")
373
374 dnsclient.systemctl("start network-online.target")
375 dnsclient.wait_for_unit("network-online.target")
376 dnsclient.succeed(
377 "dig @unbound test.local",
378 "dig @unbound www.example.com"
379 )
380
381 unbound.wait_for_file("/var/lib/vector/logs.log")
382
383 unbound.wait_until_succeeds(
384 "grep ClientQuery /var/lib/vector/logs.log | grep '\"domainName\":\"test.local.\"' | grep '\"rcodeName\":\"NoError\"'"
385 )
386 unbound.wait_until_succeeds(
387 "grep ClientResponse /var/lib/vector/logs.log | grep '\"domainName\":\"test.local.\"' | grep '\"rData\":\"192.168.123.5\"'"
388 )
389
390 clickhouse.log(clickhouse.wait_until_succeeds(
391 "cat ${selectDomainCountQuery} | clickhouse-client | grep 'test.local.'"
392 ))
393
394 clickhouse.log(clickhouse.wait_until_succeeds(
395 "cat ${selectDomainCountQuery} | clickhouse-client | grep 'www.example.com.'"
396 ))
397
398 clickhouse.log(clickhouse.wait_until_succeeds(
399 "cat ${selectAuthResponseQuery} | clickhouse-client | grep 'Knot DNS ${pkgs.knot-dns.version}'"
400 ))
401 '';
402}