lol

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.

+727
+1
nixos/modules/module-list.nix
··· 1175 1175 ./services/networking/kea.nix 1176 1176 ./services/networking/keepalived/default.nix 1177 1177 ./services/networking/keybase.nix 1178 + ./services/networking/kismet.nix 1178 1179 ./services/networking/knot.nix 1179 1180 ./services/networking/kresd.nix 1180 1181 ./services/networking/lambdabot.nix
+459
nixos/modules/services/networking/kismet.nix
··· 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 7 + 8 + let 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; 184 + in 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 + }
+1
nixos/tests/all-tests.nix
··· 702 702 keyd = handleTest ./keyd.nix { }; 703 703 keymap = handleTest ./keymap.nix { }; 704 704 kimai = runTest ./kimai.nix; 705 + kismet = runTest ./kismet.nix; 705 706 kmonad = runTest ./kmonad.nix; 706 707 knot = runTest ./knot.nix; 707 708 komga = handleTest ./komga.nix { };
+266
nixos/tests/kismet.nix
··· 1 + { pkgs, lib, ... }: 2 + 3 + let 4 + ssid = "Hydra SmokeNet"; 5 + psk = "stayoffmywifi"; 6 + wlanInterface = "wlan0"; 7 + in 8 + { 9 + name = "kismet"; 10 + 11 + nodes = 12 + let 13 + hostAddress = id: "192.168.1.${toString (id + 1)}"; 14 + serverAddress = hostAddress 1; 15 + in 16 + { 17 + airgap = 18 + { config, ... }: 19 + { 20 + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ 21 + { 22 + address = serverAddress; 23 + prefixLength = 24; 24 + } 25 + ]; 26 + services.vwifi = { 27 + server = { 28 + enable = true; 29 + ports.tcp = 8212; 30 + ports.spy = 8213; 31 + openFirewall = true; 32 + }; 33 + }; 34 + }; 35 + 36 + ap = 37 + { config, ... }: 38 + { 39 + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ 40 + { 41 + address = hostAddress 2; 42 + prefixLength = 24; 43 + } 44 + ]; 45 + services.hostapd = { 46 + enable = true; 47 + radios.${wlanInterface} = { 48 + channel = 1; 49 + networks.${wlanInterface} = { 50 + inherit ssid; 51 + authentication = { 52 + mode = "wpa3-sae"; 53 + saePasswords = [ { password = psk; } ]; 54 + enableRecommendedPairwiseCiphers = true; 55 + }; 56 + }; 57 + }; 58 + }; 59 + services.vwifi = { 60 + module = { 61 + enable = true; 62 + macPrefix = "74:F8:F6:00:01"; 63 + }; 64 + client = { 65 + enable = true; 66 + inherit serverAddress; 67 + }; 68 + }; 69 + }; 70 + 71 + station = 72 + { config, ... }: 73 + { 74 + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ 75 + { 76 + address = hostAddress 3; 77 + prefixLength = 24; 78 + } 79 + ]; 80 + networking.wireless = { 81 + # No, really, we want it enabled! 82 + enable = lib.mkOverride 0 true; 83 + interfaces = [ wlanInterface ]; 84 + networks = { 85 + ${ssid} = { 86 + inherit psk; 87 + authProtocols = [ "SAE" ]; 88 + }; 89 + }; 90 + }; 91 + services.vwifi = { 92 + module = { 93 + enable = true; 94 + macPrefix = "74:F8:F6:00:02"; 95 + }; 96 + client = { 97 + enable = true; 98 + inherit serverAddress; 99 + }; 100 + }; 101 + }; 102 + 103 + monitor = 104 + { config, ... }: 105 + { 106 + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ 107 + { 108 + address = hostAddress 4; 109 + prefixLength = 24; 110 + } 111 + ]; 112 + 113 + services.kismet = { 114 + enable = true; 115 + serverName = "NixOS Kismet Smoke Test"; 116 + serverDescription = "Server testing virtual wifi devices running on Hydra"; 117 + httpd.enable = true; 118 + # Check that the settings all eval correctly 119 + settings = { 120 + # Should append to log_types 121 + log_types' = "wiglecsv"; 122 + 123 + # Should all generate correctly 124 + wepkey = [ 125 + "00:DE:AD:C0:DE:00" 126 + "FEEDFACE42" 127 + ]; 128 + alert = [ 129 + [ 130 + "ADHOCCONFLICT" 131 + "5/min" 132 + "1/sec" 133 + ] 134 + [ 135 + "ADVCRYPTCHANGE" 136 + "5/min" 137 + "1/sec" 138 + ] 139 + ]; 140 + gps.gpsd = { 141 + host = "localhost"; 142 + port = 2947; 143 + }; 144 + apspoof.Foo1 = [ 145 + { 146 + ssid = "Bar1"; 147 + validmacs = [ 148 + "00:11:22:33:44:55" 149 + "aa:bb:cc:dd:ee:ff" 150 + ]; 151 + } 152 + { 153 + ssid = "Bar2"; 154 + validmacs = [ 155 + "01:12:23:34:45:56" 156 + "ab:bc:cd:de:ef:f0" 157 + ]; 158 + } 159 + ]; 160 + apspoof.Foo2 = [ 161 + { 162 + ssid = "Bar2"; 163 + validmacs = [ 164 + "00:11:22:33:44:55" 165 + "aa:bb:cc:dd:ee:ff" 166 + ]; 167 + } 168 + ]; 169 + 170 + # The actual source 171 + source.${wlanInterface} = { 172 + name = "Virtual Wifi"; 173 + }; 174 + }; 175 + extraConfig = '' 176 + # this comment should be ignored 177 + ''; 178 + }; 179 + 180 + services.vwifi = { 181 + module = { 182 + enable = true; 183 + macPrefix = "74:F8:F6:00:03"; 184 + }; 185 + client = { 186 + enable = true; 187 + spy = true; 188 + inherit serverAddress; 189 + }; 190 + }; 191 + 192 + environment.systemPackages = with pkgs; [ 193 + config.services.kismet.package 194 + config.services.vwifi.package 195 + jq 196 + ]; 197 + }; 198 + }; 199 + 200 + testScript = 201 + { nodes, ... }: 202 + '' 203 + import shlex 204 + 205 + # Wait for the vwifi server to come up 206 + airgap.start() 207 + airgap.wait_for_unit("vwifi-server.service") 208 + airgap.wait_for_open_port(${toString nodes.airgap.services.vwifi.server.ports.tcp}) 209 + 210 + httpd_port = ${toString nodes.monitor.services.kismet.httpd.port} 211 + server_name = "${nodes.monitor.services.kismet.serverName}" 212 + server_description = "${nodes.monitor.services.kismet.serverDescription}" 213 + wlan_interface = "${wlanInterface}" 214 + ap_essid = "${ssid}" 215 + ap_mac_prefix = "${nodes.ap.services.vwifi.module.macPrefix}" 216 + station_mac_prefix = "${nodes.station.services.vwifi.module.macPrefix}" 217 + 218 + # Spawn the other nodes. 219 + monitor.start() 220 + 221 + # Wait for the monitor to come up 222 + monitor.wait_for_unit("kismet.service") 223 + monitor.wait_for_open_port(httpd_port) 224 + 225 + # Should be up but require authentication. 226 + url = f"http://localhost:{httpd_port}" 227 + monitor.succeed(f"curl {url} | tee /dev/stderr | grep '<title>Kismet</title>'") 228 + 229 + # Have to set the password now. 230 + monitor.succeed("echo httpd_username=nixos >> ~kismet/.kismet/kismet_httpd.conf") 231 + monitor.succeed("echo httpd_password=hydra >> ~kismet/.kismet/kismet_httpd.conf") 232 + monitor.systemctl("restart kismet.service") 233 + monitor.wait_for_unit("kismet.service") 234 + monitor.wait_for_open_port(httpd_port) 235 + 236 + # Authentication should now work. 237 + url = f"http://nixos:hydra@localhost:{httpd_port}" 238 + 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'") 239 + 240 + # Wait for the station to connect to the AP while Kismet is monitoring 241 + ap.start() 242 + station.start() 243 + 244 + unit = f"wpa_supplicant-{wlan_interface}" 245 + 246 + # Generate handshakes until we detect both devices 247 + success = False 248 + for i in range(100): 249 + station.wait_for_unit(f"wpa_supplicant-{wlan_interface}.service") 250 + station.succeed(f"ifconfig {wlan_interface} down && ifconfig {wlan_interface} up") 251 + 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')}") 252 + station.succeed(f"journalctl --rotate --unit={shlex.quote(unit)}") 253 + station.succeed(f"sleep 3 && journalctl --vacuum-time=1s --unit={shlex.quote(unit)}") 254 + 255 + # We're connected, make sure Kismet sees both of our devices 256 + 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'") 257 + if status != 0: 258 + continue 259 + 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'") 260 + if status == 0: 261 + success = True 262 + break 263 + 264 + assert success 265 + ''; 266 + }