Unverified Commit 36cddaaa authored by Morgan Jones's avatar Morgan Jones
Browse files

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.
parent 583a74d8
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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
+459 −0
Original line number Diff line number Diff line
{
  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 −0
Original line number Diff line number Diff line
@@ -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 { };

nixos/tests/kismet.nix

0 → 100644
+266 −0
Original line number Diff line number Diff line
{ 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
    '';
}