1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 inherit (lib.trivial) isFloat isInt isBool;
10 inherit (lib.modules) mkIf;
11 inherit (lib.options)
12 literalExpression
13 mkOption
14 mkPackageOption
15 mkEnableOption
16 ;
17 inherit (lib.strings)
18 isString
19 escapeShellArg
20 escapeShellArgs
21 concatMapStringsSep
22 concatMapAttrsStringSep
23 replaceStrings
24 substring
25 stringLength
26 hasInfix
27 hasSuffix
28 typeOf
29 match
30 ;
31 inherit (lib.lists) all isList flatten;
32 inherit (lib.attrsets)
33 attrsToList
34 filterAttrs
35 optionalAttrs
36 mapAttrs'
37 mapAttrsToList
38 nameValuePair
39 ;
40 inherit (lib.generators) toKeyValue;
41 inherit (lib) types;
42
43 # Deeply checks types for a given type function. Calls `override` with type and value.
44 deep =
45 func: override: type:
46 let
47 prev = func type;
48 in
49 prev
50 // {
51 check = value: prev.check value && (override type value);
52 };
53
54 # Deep listOf.
55 listOf' = deep types.listOf (type: value: all type.check value);
56
57 # Deep attrsOf.
58 attrsOf' = deep types.attrsOf (type: value: all (item: type.check item.value) (attrsToList value));
59
60 # Kismet config atoms.
61 atom =
62 with types;
63 oneOf [
64 number
65 bool
66 str
67 ];
68
69 # Composite types.
70 listOfAtom = listOf' atom;
71 atomOrList = with types; either atom listOfAtom;
72 lists = listOf' atomOrList;
73 kvPair = attrsOf' atomOrList;
74 kvPairs = listOf' kvPair;
75
76 # Options that eval to a string with a header (foo:key=value)
77 headerKvPair = attrsOf' (attrsOf' atomOrList);
78 headerKvPairs = attrsOf' (listOf' (attrsOf' atomOrList));
79
80 # Toplevel config type.
81 topLevel =
82 let
83 topLevel' =
84 with types;
85 oneOf [
86 headerKvPairs
87 headerKvPair
88 kvPairs
89 kvPair
90 listOfAtom
91 lists
92 atom
93 ];
94 in
95 topLevel'
96 // {
97 description = "Kismet config stanza";
98 };
99
100 # Throws invalid.
101 invalid = atom: throw "invalid value '${toString atom}' of type '${typeOf atom}'";
102
103 # Converts an atom.
104 mkAtom =
105 atom:
106 if isString atom then
107 if hasInfix "\"" atom || hasInfix "," atom then
108 ''"${replaceStrings [ ''"'' ] [ ''\"'' ] atom}"''
109 else
110 atom
111 else if isFloat atom || isInt atom || isBool atom then
112 toString atom
113 else
114 invalid atom;
115
116 # Converts an inline atom or list to a string.
117 mkAtomOrListInline =
118 atomOrList:
119 if isList atomOrList then
120 mkAtom "${concatMapStringsSep "," mkAtom atomOrList}"
121 else
122 mkAtom atomOrList;
123
124 # Converts an out of line atom or list to a string.
125 mkAtomOrList =
126 atomOrList:
127 if isList atomOrList then
128 "${concatMapStringsSep "," mkAtomOrListInline atomOrList}"
129 else
130 mkAtom atomOrList;
131
132 # Throws if the string matches the given regex.
133 deny =
134 regex: str:
135 assert (match regex str) == null;
136 str;
137
138 # Converts a set of k/v pairs.
139 convertKv = concatMapAttrsStringSep "," (
140 name: value: "${mkAtom (deny "=" name)}=${mkAtomOrListInline value}"
141 );
142
143 # Converts k/v pairs with a header.
144 convertKvWithHeader = header: attrs: "${mkAtom (deny ":" header)}:${convertKv attrs}";
145
146 # Converts the entire config.
147 convertConfig = mapAttrs' (
148 name: value:
149 let
150 # Convert foo' into 'foo+' for support for '+=' syntax.
151 newName = if hasSuffix "'" name then substring 0 (stringLength name - 1) name + "+" else name;
152
153 # Get the stringified value.
154 newValue =
155 if headerKvPairs.check value then
156 flatten (
157 mapAttrsToList (header: values: (map (value: convertKvWithHeader header value) values)) value
158 )
159 else if headerKvPair.check value then
160 mapAttrsToList convertKvWithHeader value
161 else if kvPairs.check value then
162 map convertKv value
163 else if kvPair.check value then
164 convertKv value
165 else if listOfAtom.check value then
166 mkAtomOrList value
167 else if lists.check value then
168 map mkAtomOrList value
169 else if atom.check value then
170 mkAtom value
171 else
172 invalid value;
173 in
174 nameValuePair newName newValue
175 );
176
177 mkKismetConf =
178 options:
179 (toKeyValue { listsAsDuplicateKeys = true; }) (
180 filterAttrs (_: value: value != null) (convertConfig options)
181 );
182
183 cfg = config.services.kismet;
184in
185{
186 options.services.kismet = {
187 enable = mkEnableOption "kismet";
188 package = mkPackageOption pkgs "kismet" { };
189 user = mkOption {
190 description = "The user to run Kismet as.";
191 type = types.str;
192 default = "kismet";
193 };
194 group = mkOption {
195 description = "The group to run Kismet as.";
196 type = types.str;
197 default = "kismet";
198 };
199 serverName = mkOption {
200 description = "The name of the server.";
201 type = types.str;
202 default = "Kismet";
203 };
204 serverDescription = mkOption {
205 description = "The description of the server.";
206 type = types.str;
207 default = "NixOS Kismet server";
208 };
209 logTypes = mkOption {
210 description = "The log types.";
211 type = with types; listOf str;
212 default = [ "kismet" ];
213 };
214 dataDir = mkOption {
215 description = "The Kismet data directory.";
216 type = types.path;
217 default = "/var/lib/kismet";
218 };
219 httpd = {
220 enable = mkOption {
221 description = "True to enable the HTTP server.";
222 type = types.bool;
223 default = false;
224 };
225 address = mkOption {
226 description = "The address to listen on. Note that this cannot be a hostname or Kismet will not start.";
227 type = types.str;
228 default = "127.0.0.1";
229 };
230 port = mkOption {
231 description = "The port to listen on.";
232 type = types.port;
233 default = 2501;
234 };
235 };
236 settings = mkOption {
237 description = ''
238 Options for Kismet. See:
239 https://www.kismetwireless.net/docs/readme/configuring/configfiles/
240 '';
241 default = { };
242 type = with types; attrsOf topLevel;
243 example = literalExpression ''
244 {
245 /* Examples for atoms */
246 # dot11_link_bssts=false
247 dot11_link_bssts = false; # Boolean
248
249 # dot11_related_bss_window=10000000
250 dot11_related_bss_window = 10000000; # Integer
251
252 # devicefound=00:11:22:33:44:55
253 devicefound = "00:11:22:33:44:55"; # String
254
255 # log_types+=wiglecsv
256 log_types' = "wiglecsv";
257
258 /* Examples for lists of atoms */
259 # wepkey=00:DE:AD:C0:DE:00,FEEDFACE42
260 wepkey = [ "00:DE:AD:C0:DE:00" "FEEDFACE42" ];
261
262 # alert=ADHOCCONFLICT,5/min,1/sec
263 # alert=ADVCRYPTCHANGE,5/min,1/sec
264 alert = [
265 [ "ADHOCCONFLICT" "5/min" "1/sec" ]
266 [ "ADVCRYPTCHANGE" "5/min" "1/sec" ]
267 ];
268
269 /* Examples for sets of atoms */
270 # source=wlan0:name=ath11k
271 source.wlan0 = { name = "ath11k"; };
272
273 /* Examples with colon-suffixed headers */
274 # gps=gpsd:host=localhost,port=2947
275 gps.gpsd = {
276 host = "localhost";
277 port = 2947;
278 };
279
280 # apspoof=Foo1:ssid=Bar1,validmacs="00:11:22:33:44:55,aa:bb:cc:dd:ee:ff"
281 # apspoof=Foo1:ssid=Bar2,validmacs="01:12:23:34:45:56,ab:bc:cd:de:ef:f0"
282 # apspoof=Foo2:ssid=Baz1,validmacs="11:22:33:44:55:66,bb:cc:dd:ee:ff:00"
283 apspoof.Foo1 = [
284 { ssid = "Bar1"; validmacs = [ "00:11:22:33:44:55" "aa:bb:cc:dd:ee:ff" ]; }
285 { ssid = "Bar2"; validmacs = [ "01:12:23:34:45:56" "ab:bc:cd:de:ef:f0" ]; }
286 ];
287
288 # because Foo1 is a list, Foo2 needs to be as well
289 apspoof.Foo2 = [
290 {
291 ssid = "Bar2";
292 validmacs = [ "00:11:22:33:44:55" "aa:bb:cc:dd:ee:ff" ];
293 };
294 ];
295 }
296 '';
297 };
298 extraConfig = mkOption {
299 description = ''
300 Literal Kismet config lines appended to the site config.
301 Note that `services.kismet.settings` allows you to define
302 all options here using Nix attribute sets.
303 '';
304 default = "";
305 type = types.str;
306 example = ''
307 # Looks like the following in `services.kismet.settings`:
308 # wepkey = [ "00:DE:AD:C0:DE:00" "FEEDFACE42" ];
309 wepkey=00:DE:AD:C0:DE:00,FEEDFACE42
310 '';
311 };
312 };
313
314 config =
315 let
316 configDir = "${cfg.dataDir}/.kismet";
317 settings =
318 cfg.settings
319 // {
320 server_name = cfg.serverName;
321 server_description = cfg.serverDescription;
322 logging_enabled = cfg.logTypes != [ ];
323 log_types = cfg.logTypes;
324 }
325 // optionalAttrs cfg.httpd.enable {
326 httpd_bind_address = cfg.httpd.address;
327 httpd_port = cfg.httpd.port;
328 httpd_auth_file = "${configDir}/kismet_httpd.conf";
329 httpd_home = "${cfg.package}/share/kismet/httpd";
330 };
331 in
332 mkIf cfg.enable {
333 systemd.tmpfiles.settings = {
334 "10-kismet" = {
335 ${cfg.dataDir} = {
336 d = {
337 inherit (cfg) user group;
338 mode = "0750";
339 };
340 };
341 ${configDir} = {
342 d = {
343 inherit (cfg) user group;
344 mode = "0750";
345 };
346 };
347 };
348 };
349 systemd.services.kismet =
350 let
351 kismetConf = pkgs.writeText "kismet.conf" ''
352 ${mkKismetConf settings}
353 ${cfg.extraConfig}
354 '';
355 in
356 {
357 description = "Kismet monitoring service";
358 wants = [ "basic.target" ];
359 after = [
360 "basic.target"
361 "network.target"
362 ];
363 wantedBy = [ "multi-user.target" ];
364 serviceConfig =
365 let
366 capabilities = [
367 "CAP_NET_ADMIN"
368 "CAP_NET_RAW"
369 ];
370 kismetPreStart = pkgs.writeShellScript "kismet-pre-start" ''
371 owner=${escapeShellArg "${cfg.user}:${cfg.group}"}
372 mkdir -p ~/.kismet
373
374 # Ensure permissions on directories Kismet uses.
375 chown "$owner" ~/ ~/.kismet
376 cd ~/.kismet
377
378 package=${cfg.package}
379 if [ -d "$package/etc" ]; then
380 for file in "$package/etc"/*.conf; do
381 # Symlink the config files if they exist or are already a link.
382 base="''${file##*/}"
383 if [ ! -f "$base" ] || [ -L "$base" ]; then
384 ln -sf "$file" "$base"
385 fi
386 done
387 fi
388
389 for file in kismet_httpd.conf; do
390 # Un-symlink these files.
391 if [ -L "$file" ]; then
392 cp "$file" ".$file"
393 rm -f "$file"
394 mv ".$file" "$file"
395 chmod 0640 "$file"
396 chown "$owner" "$file"
397 fi
398 done
399
400 # Link the site config.
401 ln -sf ${kismetConf} kismet_site.conf
402 '';
403 in
404 {
405 Type = "simple";
406 ExecStart = escapeShellArgs [
407 "${cfg.package}/bin/kismet"
408 "--homedir"
409 cfg.dataDir
410 "--confdir"
411 configDir
412 "--datadir"
413 "${cfg.package}/share"
414 "--no-ncurses"
415 "-f"
416 "${configDir}/kismet.conf"
417 ];
418 WorkingDirectory = cfg.dataDir;
419 ExecStartPre = "+${kismetPreStart}";
420 Restart = "always";
421 KillMode = "control-group";
422 CapabilityBoundingSet = capabilities;
423 AmbientCapabilities = capabilities;
424 LockPersonality = true;
425 NoNewPrivileges = true;
426 PrivateDevices = false;
427 PrivateTmp = true;
428 PrivateUsers = false;
429 ProtectClock = true;
430 ProtectControlGroups = true;
431 ProtectHome = true;
432 ProtectHostname = true;
433 ProtectKernelLogs = true;
434 ProtectKernelModules = true;
435 ProtectKernelTunables = true;
436 ProtectProc = "invisible";
437 ProtectSystem = "full";
438 RestrictNamespaces = true;
439 RestrictSUIDSGID = true;
440 User = cfg.user;
441 Group = cfg.group;
442 UMask = "0007";
443 TimeoutStopSec = 30;
444 };
445
446 # Allow it to restart if the wifi interface is not up
447 unitConfig.StartLimitIntervalSec = 5;
448 };
449 users.groups.${cfg.group} = { };
450 users.users.${cfg.user} = {
451 inherit (cfg) group;
452 description = "User for running Kismet";
453 isSystemUser = true;
454 home = cfg.dataDir;
455 };
456 };
457
458 meta.maintainers = with lib.maintainers; [ numinit ];
459}