nixos/kismet: init module

Use vwifi to write a proper test for Kismet. This test demonstrates how
to simulate wireless networks in NixOS tests, and extract meaningful
data by putting an interface in monitor mode using Kismet.

Changed files
+727
nixos
modules
services
networking
tests
+1
nixos/modules/module-list.nix
···
./services/networking/kea.nix
./services/networking/keepalived/default.nix
./services/networking/keybase.nix
+
./services/networking/kismet.nix
./services/networking/knot.nix
./services/networking/kresd.nix
./services/networking/lambdabot.nix
+459
nixos/modules/services/networking/kismet.nix
···
+
{
+
config,
+
lib,
+
pkgs,
+
...
+
}:
+
+
let
+
inherit (lib.trivial) isFloat isInt isBool;
+
inherit (lib.modules) mkIf;
+
inherit (lib.options)
+
literalExpression
+
mkOption
+
mkPackageOption
+
mkEnableOption
+
;
+
inherit (lib.strings)
+
isString
+
escapeShellArg
+
escapeShellArgs
+
concatMapStringsSep
+
concatMapAttrsStringSep
+
replaceStrings
+
substring
+
stringLength
+
hasInfix
+
hasSuffix
+
typeOf
+
match
+
;
+
inherit (lib.lists) all isList flatten;
+
inherit (lib.attrsets)
+
attrsToList
+
filterAttrs
+
optionalAttrs
+
mapAttrs'
+
mapAttrsToList
+
nameValuePair
+
;
+
inherit (lib.generators) toKeyValue;
+
inherit (lib) types;
+
+
# Deeply checks types for a given type function. Calls `override` with type and value.
+
deep =
+
func: override: type:
+
let
+
prev = func type;
+
in
+
prev
+
// {
+
check = value: prev.check value && (override type value);
+
};
+
+
# Deep listOf.
+
listOf' = deep types.listOf (type: value: all type.check value);
+
+
# Deep attrsOf.
+
attrsOf' = deep types.attrsOf (type: value: all (item: type.check item.value) (attrsToList value));
+
+
# Kismet config atoms.
+
atom =
+
with types;
+
oneOf [
+
number
+
bool
+
str
+
];
+
+
# Composite types.
+
listOfAtom = listOf' atom;
+
atomOrList = with types; either atom listOfAtom;
+
lists = listOf' atomOrList;
+
kvPair = attrsOf' atomOrList;
+
kvPairs = listOf' kvPair;
+
+
# Options that eval to a string with a header (foo:key=value)
+
headerKvPair = attrsOf' (attrsOf' atomOrList);
+
headerKvPairs = attrsOf' (listOf' (attrsOf' atomOrList));
+
+
# Toplevel config type.
+
topLevel =
+
let
+
topLevel' =
+
with types;
+
oneOf [
+
headerKvPairs
+
headerKvPair
+
kvPairs
+
kvPair
+
listOfAtom
+
lists
+
atom
+
];
+
in
+
topLevel'
+
// {
+
description = "Kismet config stanza";
+
};
+
+
# Throws invalid.
+
invalid = atom: throw "invalid value '${toString atom}' of type '${typeOf atom}'";
+
+
# Converts an atom.
+
mkAtom =
+
atom:
+
if isString atom then
+
if hasInfix "\"" atom || hasInfix "," atom then
+
''"${replaceStrings [ ''"'' ] [ ''\"'' ] atom}"''
+
else
+
atom
+
else if isFloat atom || isInt atom || isBool atom then
+
toString atom
+
else
+
invalid atom;
+
+
# Converts an inline atom or list to a string.
+
mkAtomOrListInline =
+
atomOrList:
+
if isList atomOrList then
+
mkAtom "${concatMapStringsSep "," mkAtom atomOrList}"
+
else
+
mkAtom atomOrList;
+
+
# Converts an out of line atom or list to a string.
+
mkAtomOrList =
+
atomOrList:
+
if isList atomOrList then
+
"${concatMapStringsSep "," mkAtomOrListInline atomOrList}"
+
else
+
mkAtom atomOrList;
+
+
# Throws if the string matches the given regex.
+
deny =
+
regex: str:
+
assert (match regex str) == null;
+
str;
+
+
# Converts a set of k/v pairs.
+
convertKv = concatMapAttrsStringSep "," (
+
name: value: "${mkAtom (deny "=" name)}=${mkAtomOrListInline value}"
+
);
+
+
# Converts k/v pairs with a header.
+
convertKvWithHeader = header: attrs: "${mkAtom (deny ":" header)}:${convertKv attrs}";
+
+
# Converts the entire config.
+
convertConfig = mapAttrs' (
+
name: value:
+
let
+
# Convert foo' into 'foo+' for support for '+=' syntax.
+
newName = if hasSuffix "'" name then substring 0 (stringLength name - 1) name + "+" else name;
+
+
# Get the stringified value.
+
newValue =
+
if headerKvPairs.check value then
+
flatten (
+
mapAttrsToList (header: values: (map (value: convertKvWithHeader header value) values)) value
+
)
+
else if headerKvPair.check value then
+
mapAttrsToList convertKvWithHeader value
+
else if kvPairs.check value then
+
map convertKv value
+
else if kvPair.check value then
+
convertKv value
+
else if listOfAtom.check value then
+
mkAtomOrList value
+
else if lists.check value then
+
map mkAtomOrList value
+
else if atom.check value then
+
mkAtom value
+
else
+
invalid value;
+
in
+
nameValuePair newName newValue
+
);
+
+
mkKismetConf =
+
options:
+
(toKeyValue { listsAsDuplicateKeys = true; }) (
+
filterAttrs (_: value: value != null) (convertConfig options)
+
);
+
+
cfg = config.services.kismet;
+
in
+
{
+
options.services.kismet = {
+
enable = mkEnableOption "kismet";
+
package = mkPackageOption pkgs "kismet" { };
+
user = mkOption {
+
description = "The user to run Kismet as.";
+
type = types.str;
+
default = "kismet";
+
};
+
group = mkOption {
+
description = "The group to run Kismet as.";
+
type = types.str;
+
default = "kismet";
+
};
+
serverName = mkOption {
+
description = "The name of the server.";
+
type = types.str;
+
default = "Kismet";
+
};
+
serverDescription = mkOption {
+
description = "The description of the server.";
+
type = types.str;
+
default = "NixOS Kismet server";
+
};
+
logTypes = mkOption {
+
description = "The log types.";
+
type = with types; listOf str;
+
default = [ "kismet" ];
+
};
+
dataDir = mkOption {
+
description = "The Kismet data directory.";
+
type = types.path;
+
default = "/var/lib/kismet";
+
};
+
httpd = {
+
enable = mkOption {
+
description = "True to enable the HTTP server.";
+
type = types.bool;
+
default = false;
+
};
+
address = mkOption {
+
description = "The address to listen on. Note that this cannot be a hostname or Kismet will not start.";
+
type = types.str;
+
default = "127.0.0.1";
+
};
+
port = mkOption {
+
description = "The port to listen on.";
+
type = types.port;
+
default = 2501;
+
};
+
};
+
settings = mkOption {
+
description = ''
+
Options for Kismet. See:
+
https://www.kismetwireless.net/docs/readme/configuring/configfiles/
+
'';
+
default = { };
+
type = with types; attrsOf topLevel;
+
example = literalExpression ''
+
{
+
/* Examples for atoms */
+
# dot11_link_bssts=false
+
dot11_link_bssts = false; # Boolean
+
+
# dot11_related_bss_window=10000000
+
dot11_related_bss_window = 10000000; # Integer
+
+
# devicefound=00:11:22:33:44:55
+
devicefound = "00:11:22:33:44:55"; # String
+
+
# log_types+=wiglecsv
+
log_types' = "wiglecsv";
+
+
/* Examples for lists of atoms */
+
# wepkey=00:DE:AD:C0:DE:00,FEEDFACE42
+
wepkey = [ "00:DE:AD:C0:DE:00" "FEEDFACE42" ];
+
+
# alert=ADHOCCONFLICT,5/min,1/sec
+
# alert=ADVCRYPTCHANGE,5/min,1/sec
+
alert = [
+
[ "ADHOCCONFLICT" "5/min" "1/sec" ]
+
[ "ADVCRYPTCHANGE" "5/min" "1/sec" ]
+
];
+
+
/* Examples for sets of atoms */
+
# source=wlan0:name=ath11k
+
source.wlan0 = { name = "ath11k"; };
+
+
/* Examples with colon-suffixed headers */
+
# gps=gpsd:host=localhost,port=2947
+
gps.gpsd = {
+
host = "localhost";
+
port = 2947;
+
};
+
+
# apspoof=Foo1:ssid=Bar1,validmacs="00:11:22:33:44:55,aa:bb:cc:dd:ee:ff"
+
# apspoof=Foo1:ssid=Bar2,validmacs="01:12:23:34:45:56,ab:bc:cd:de:ef:f0"
+
# apspoof=Foo2:ssid=Baz1,validmacs="11:22:33:44:55:66,bb:cc:dd:ee:ff:00"
+
apspoof.Foo1 = [
+
{ ssid = "Bar1"; validmacs = [ "00:11:22:33:44:55" "aa:bb:cc:dd:ee:ff" ]; }
+
{ ssid = "Bar2"; validmacs = [ "01:12:23:34:45:56" "ab:bc:cd:de:ef:f0" ]; }
+
];
+
+
# because Foo1 is a list, Foo2 needs to be as well
+
apspoof.Foo2 = [
+
{
+
ssid = "Bar2";
+
validmacs = [ "00:11:22:33:44:55" "aa:bb:cc:dd:ee:ff" ];
+
};
+
];
+
}
+
'';
+
};
+
extraConfig = mkOption {
+
description = ''
+
Literal Kismet config lines appended to the site config.
+
Note that `services.kismet.settings` allows you to define
+
all options here using Nix attribute sets.
+
'';
+
default = "";
+
type = types.str;
+
example = ''
+
# Looks like the following in `services.kismet.settings`:
+
# wepkey = [ "00:DE:AD:C0:DE:00" "FEEDFACE42" ];
+
wepkey=00:DE:AD:C0:DE:00,FEEDFACE42
+
'';
+
};
+
};
+
+
config =
+
let
+
configDir = "${cfg.dataDir}/.kismet";
+
settings =
+
cfg.settings
+
// {
+
server_name = cfg.serverName;
+
server_description = cfg.serverDescription;
+
logging_enabled = cfg.logTypes != [ ];
+
log_types = cfg.logTypes;
+
}
+
// optionalAttrs cfg.httpd.enable {
+
httpd_bind_address = cfg.httpd.address;
+
httpd_port = cfg.httpd.port;
+
httpd_auth_file = "${configDir}/kismet_httpd.conf";
+
httpd_home = "${cfg.package}/share/kismet/httpd";
+
};
+
in
+
mkIf cfg.enable {
+
systemd.tmpfiles.settings = {
+
"10-kismet" = {
+
${cfg.dataDir} = {
+
d = {
+
inherit (cfg) user group;
+
mode = "0750";
+
};
+
};
+
${configDir} = {
+
d = {
+
inherit (cfg) user group;
+
mode = "0750";
+
};
+
};
+
};
+
};
+
systemd.services.kismet =
+
let
+
kismetConf = pkgs.writeText "kismet.conf" ''
+
${mkKismetConf settings}
+
${cfg.extraConfig}
+
'';
+
in
+
{
+
description = "Kismet monitoring service";
+
wants = [ "basic.target" ];
+
after = [
+
"basic.target"
+
"network.target"
+
];
+
wantedBy = [ "multi-user.target" ];
+
serviceConfig =
+
let
+
capabilities = [
+
"CAP_NET_ADMIN"
+
"CAP_NET_RAW"
+
];
+
kismetPreStart = pkgs.writeShellScript "kismet-pre-start" ''
+
owner=${escapeShellArg "${cfg.user}:${cfg.group}"}
+
mkdir -p ~/.kismet
+
+
# Ensure permissions on directories Kismet uses.
+
chown "$owner" ~/ ~/.kismet
+
cd ~/.kismet
+
+
package=${cfg.package}
+
if [ -d "$package/etc" ]; then
+
for file in "$package/etc"/*.conf; do
+
# Symlink the config files if they exist or are already a link.
+
base="''${file##*/}"
+
if [ ! -f "$base" ] || [ -L "$base" ]; then
+
ln -sf "$file" "$base"
+
fi
+
done
+
fi
+
+
for file in kismet_httpd.conf; do
+
# Un-symlink these files.
+
if [ -L "$file" ]; then
+
cp "$file" ".$file"
+
rm -f "$file"
+
mv ".$file" "$file"
+
chmod 0640 "$file"
+
chown "$owner" "$file"
+
fi
+
done
+
+
# Link the site config.
+
ln -sf ${kismetConf} kismet_site.conf
+
'';
+
in
+
{
+
Type = "simple";
+
ExecStart = escapeShellArgs [
+
"${cfg.package}/bin/kismet"
+
"--homedir"
+
cfg.dataDir
+
"--confdir"
+
configDir
+
"--datadir"
+
"${cfg.package}/share"
+
"--no-ncurses"
+
"-f"
+
"${configDir}/kismet.conf"
+
];
+
WorkingDirectory = cfg.dataDir;
+
ExecStartPre = "+${kismetPreStart}";
+
Restart = "always";
+
KillMode = "control-group";
+
CapabilityBoundingSet = capabilities;
+
AmbientCapabilities = capabilities;
+
LockPersonality = true;
+
NoNewPrivileges = true;
+
PrivateDevices = false;
+
PrivateTmp = true;
+
PrivateUsers = false;
+
ProtectClock = true;
+
ProtectControlGroups = true;
+
ProtectHome = true;
+
ProtectHostname = true;
+
ProtectKernelLogs = true;
+
ProtectKernelModules = true;
+
ProtectKernelTunables = true;
+
ProtectProc = "invisible";
+
ProtectSystem = "full";
+
RestrictNamespaces = true;
+
RestrictSUIDSGID = true;
+
User = cfg.user;
+
Group = cfg.group;
+
UMask = "0007";
+
TimeoutStopSec = 30;
+
};
+
+
# Allow it to restart if the wifi interface is not up
+
unitConfig.StartLimitIntervalSec = 5;
+
};
+
users.groups.${cfg.group} = { };
+
users.users.${cfg.user} = {
+
inherit (cfg) group;
+
description = "User for running Kismet";
+
isSystemUser = true;
+
home = cfg.dataDir;
+
};
+
};
+
+
meta.maintainers = with lib.maintainers; [ numinit ];
+
}
+1
nixos/tests/all-tests.nix
···
keyd = handleTest ./keyd.nix { };
keymap = handleTest ./keymap.nix { };
kimai = runTest ./kimai.nix;
+
kismet = runTest ./kismet.nix;
kmonad = runTest ./kmonad.nix;
knot = runTest ./knot.nix;
komga = handleTest ./komga.nix { };
+266
nixos/tests/kismet.nix
···
+
{ pkgs, lib, ... }:
+
+
let
+
ssid = "Hydra SmokeNet";
+
psk = "stayoffmywifi";
+
wlanInterface = "wlan0";
+
in
+
{
+
name = "kismet";
+
+
nodes =
+
let
+
hostAddress = id: "192.168.1.${toString (id + 1)}";
+
serverAddress = hostAddress 1;
+
in
+
{
+
airgap =
+
{ config, ... }:
+
{
+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
+
{
+
address = serverAddress;
+
prefixLength = 24;
+
}
+
];
+
services.vwifi = {
+
server = {
+
enable = true;
+
ports.tcp = 8212;
+
ports.spy = 8213;
+
openFirewall = true;
+
};
+
};
+
};
+
+
ap =
+
{ config, ... }:
+
{
+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
+
{
+
address = hostAddress 2;
+
prefixLength = 24;
+
}
+
];
+
services.hostapd = {
+
enable = true;
+
radios.${wlanInterface} = {
+
channel = 1;
+
networks.${wlanInterface} = {
+
inherit ssid;
+
authentication = {
+
mode = "wpa3-sae";
+
saePasswords = [ { password = psk; } ];
+
enableRecommendedPairwiseCiphers = true;
+
};
+
};
+
};
+
};
+
services.vwifi = {
+
module = {
+
enable = true;
+
macPrefix = "74:F8:F6:00:01";
+
};
+
client = {
+
enable = true;
+
inherit serverAddress;
+
};
+
};
+
};
+
+
station =
+
{ config, ... }:
+
{
+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
+
{
+
address = hostAddress 3;
+
prefixLength = 24;
+
}
+
];
+
networking.wireless = {
+
# No, really, we want it enabled!
+
enable = lib.mkOverride 0 true;
+
interfaces = [ wlanInterface ];
+
networks = {
+
${ssid} = {
+
inherit psk;
+
authProtocols = [ "SAE" ];
+
};
+
};
+
};
+
services.vwifi = {
+
module = {
+
enable = true;
+
macPrefix = "74:F8:F6:00:02";
+
};
+
client = {
+
enable = true;
+
inherit serverAddress;
+
};
+
};
+
};
+
+
monitor =
+
{ config, ... }:
+
{
+
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
+
{
+
address = hostAddress 4;
+
prefixLength = 24;
+
}
+
];
+
+
services.kismet = {
+
enable = true;
+
serverName = "NixOS Kismet Smoke Test";
+
serverDescription = "Server testing virtual wifi devices running on Hydra";
+
httpd.enable = true;
+
# Check that the settings all eval correctly
+
settings = {
+
# Should append to log_types
+
log_types' = "wiglecsv";
+
+
# Should all generate correctly
+
wepkey = [
+
"00:DE:AD:C0:DE:00"
+
"FEEDFACE42"
+
];
+
alert = [
+
[
+
"ADHOCCONFLICT"
+
"5/min"
+
"1/sec"
+
]
+
[
+
"ADVCRYPTCHANGE"
+
"5/min"
+
"1/sec"
+
]
+
];
+
gps.gpsd = {
+
host = "localhost";
+
port = 2947;
+
};
+
apspoof.Foo1 = [
+
{
+
ssid = "Bar1";
+
validmacs = [
+
"00:11:22:33:44:55"
+
"aa:bb:cc:dd:ee:ff"
+
];
+
}
+
{
+
ssid = "Bar2";
+
validmacs = [
+
"01:12:23:34:45:56"
+
"ab:bc:cd:de:ef:f0"
+
];
+
}
+
];
+
apspoof.Foo2 = [
+
{
+
ssid = "Bar2";
+
validmacs = [
+
"00:11:22:33:44:55"
+
"aa:bb:cc:dd:ee:ff"
+
];
+
}
+
];
+
+
# The actual source
+
source.${wlanInterface} = {
+
name = "Virtual Wifi";
+
};
+
};
+
extraConfig = ''
+
# this comment should be ignored
+
'';
+
};
+
+
services.vwifi = {
+
module = {
+
enable = true;
+
macPrefix = "74:F8:F6:00:03";
+
};
+
client = {
+
enable = true;
+
spy = true;
+
inherit serverAddress;
+
};
+
};
+
+
environment.systemPackages = with pkgs; [
+
config.services.kismet.package
+
config.services.vwifi.package
+
jq
+
];
+
};
+
};
+
+
testScript =
+
{ nodes, ... }:
+
''
+
import shlex
+
+
# Wait for the vwifi server to come up
+
airgap.start()
+
airgap.wait_for_unit("vwifi-server.service")
+
airgap.wait_for_open_port(${toString nodes.airgap.services.vwifi.server.ports.tcp})
+
+
httpd_port = ${toString nodes.monitor.services.kismet.httpd.port}
+
server_name = "${nodes.monitor.services.kismet.serverName}"
+
server_description = "${nodes.monitor.services.kismet.serverDescription}"
+
wlan_interface = "${wlanInterface}"
+
ap_essid = "${ssid}"
+
ap_mac_prefix = "${nodes.ap.services.vwifi.module.macPrefix}"
+
station_mac_prefix = "${nodes.station.services.vwifi.module.macPrefix}"
+
+
# Spawn the other nodes.
+
monitor.start()
+
+
# Wait for the monitor to come up
+
monitor.wait_for_unit("kismet.service")
+
monitor.wait_for_open_port(httpd_port)
+
+
# Should be up but require authentication.
+
url = f"http://localhost:{httpd_port}"
+
monitor.succeed(f"curl {url} | tee /dev/stderr | grep '<title>Kismet</title>'")
+
+
# Have to set the password now.
+
monitor.succeed("echo httpd_username=nixos >> ~kismet/.kismet/kismet_httpd.conf")
+
monitor.succeed("echo httpd_password=hydra >> ~kismet/.kismet/kismet_httpd.conf")
+
monitor.systemctl("restart kismet.service")
+
monitor.wait_for_unit("kismet.service")
+
monitor.wait_for_open_port(httpd_port)
+
+
# Authentication should now work.
+
url = f"http://nixos:hydra@localhost:{httpd_port}"
+
monitor.succeed(f"curl {url}/system/status.json | tee /dev/stderr | jq -e --arg serverName {shlex.quote(server_name)} --arg serverDescription {shlex.quote(server_description)} '.\"kismet.system.server_name\" == $serverName and .\"kismet.system.server_description\" == $serverDescription'")
+
+
# Wait for the station to connect to the AP while Kismet is monitoring
+
ap.start()
+
station.start()
+
+
unit = f"wpa_supplicant-{wlan_interface}"
+
+
# Generate handshakes until we detect both devices
+
success = False
+
for i in range(100):
+
station.wait_for_unit(f"wpa_supplicant-{wlan_interface}.service")
+
station.succeed(f"ifconfig {wlan_interface} down && ifconfig {wlan_interface} up")
+
station.wait_until_succeeds(f"journalctl -u {shlex.quote(unit)} -e | grep -Eqi {shlex.quote(wlan_interface + ': CTRL-EVENT-CONNECTED - Connection to ' + ap_mac_prefix + '[0-9a-f:]* completed')}")
+
station.succeed(f"journalctl --rotate --unit={shlex.quote(unit)}")
+
station.succeed(f"sleep 3 && journalctl --vacuum-time=1s --unit={shlex.quote(unit)}")
+
+
# We're connected, make sure Kismet sees both of our devices
+
status, stdout = monitor.execute(f"curl {url}/devices/views/all/last-time/0/devices.json | tee /dev/stderr | jq -e --arg macPrefix {shlex.quote(ap_mac_prefix)} --arg ssid {shlex.quote(ap_essid)} '. | (map(select((.\"kismet.device.base.macaddr\"? | startswith($macPrefix)) and .\"dot11.device\"?.\"dot11.device.last_beaconed_ssid_record\"?.\"dot11.advertisedssid.ssid\" == $ssid)) | length) == 1'")
+
if status != 0:
+
continue
+
status, stdout = monitor.execute(f"curl {url}/devices/views/all/last-time/0/devices.json | tee /dev/stderr | jq -e --arg macPrefix {shlex.quote(station_mac_prefix)} '. | (map(select((.\"kismet.device.base.macaddr\"? | startswith($macPrefix)))) | length) == 1'")
+
if status == 0:
+
success = True
+
break
+
+
assert success
+
'';
+
}