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.
This commit is contained in:
Morgan Jones 2025-02-09 20:57:07 -08:00
parent 583a74d8ad
commit 36cddaaa6f
No known key found for this signature in database
GPG key ID: 5C3EB94D198F1491
4 changed files with 727 additions and 0 deletions

View file

@ -1175,6 +1175,7 @@
./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

View file

@ -0,0 +1,459 @@
{
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 ];
}

View file

@ -702,6 +702,7 @@ in
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 Normal file
View file

@ -0,0 +1,266 @@
{ 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
'';
}