├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── TODO ├── bin ├── blue_hydra ├── rfkill-reset └── test-discovery ├── ico └── bluehydra.png ├── lib ├── blue_hydra.rb └── blue_hydra │ ├── btmon_handler.rb │ ├── chunker.rb │ ├── cli_user_interface.rb │ ├── cli_user_interface_tracker.rb │ ├── command.rb │ ├── device.rb │ ├── parser.rb │ ├── pulse.rb │ ├── runner.rb │ └── sync_version.rb ├── packaging ├── openrc │ ├── blue_hydra.confd │ └── blue_hydra.initd └── systemd │ └── blue_hydra.service └── spec ├── btmon_handler_spec.rb ├── chunker_spec.rb ├── command_spec.rb ├── device_spec.rb ├── fixtures └── btmon.stdout ├── hex_spec.rb ├── parser_spec.rb ├── px_realtime_bluetooth_spec.rb ├── runner_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /vendor/bundle 26 | /lib/bundler/man/ 27 | 28 | # for a library or gem, you might want to ignore these files since the code is 29 | # intended to run in multiple environments; otherwise, check them in: 30 | # Gemfile.lock 31 | # .ruby-version 32 | # .ruby-gemset 33 | 34 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 35 | .rvmrc 36 | 37 | *.log 38 | *.db 39 | Gemfile.lock 40 | blue_hydra.json 41 | blue_hydra.yml 42 | *.log.gz 43 | *.corrupt 44 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | Encoding.default_external = Encoding::UTF_8 2 | Encoding.default_internal = Encoding::UTF_8 3 | source 'http://rubygems.org' 4 | 5 | gem 'dm-migrations' 6 | gem 'dm-sqlite-adapter' 7 | gem 'dm-timestamps' 8 | gem 'dm-validations' 9 | gem 'louis' 10 | 11 | group :development do 12 | gem 'pry' 13 | end 14 | 15 | group :test, :development do 16 | gem 'rake' 17 | gem 'rspec' 18 | end 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016, Rapid Focus Security Inc d/b/a Pwnie Express 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. Neither the names of Rapid Focus Security Inc., Pwnie Express, or any of its affiliates 13 | nor the names of its contributors may be used to endorse or promote products derived from 14 | this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BlueHydra 2 | 3 | BlueHydra is a Bluetooth device discovery service built on top of the `bluez` 4 | library. BlueHydra makes use of ubertooth where available and attempts to track 5 | both classic and low energy (LE) bluetooth devices over time. 6 | 7 | ## Installation 8 | 9 | The files in this repository can be run directly. 10 | 11 | Ensure that the following packages are installed: 12 | 13 | ``` 14 | bluez 15 | bluez-test-scripts 16 | python3-bluez 17 | python3-dbus 18 | ubertooth # where applicable 19 | sqlite3 20 | libsqlite3-dev 21 | ``` 22 | 23 | If your chosen distro is still on bluez 4 please choose a more up to date distro. Bluez 5 was released in 2012 and is required. 24 | 25 | On Debian-based systems, these packages can be installed with the following command line: 26 | 27 | ```sudo apt-get install bluez bluez-test-scripts python3-bluez python3-dbus libsqlite3-dev ubertooth``` 28 | 29 | To install the needed gems it may be helpful (but not required) to use bundler: 30 | 31 | ``` 32 | sudo apt-get install ruby-dev bundler 33 | (from inside the blue_hydra directory) 34 | bundle install 35 | ``` 36 | 37 | In addition to the Bluetooth packages listed above you will need to have Ruby 38 | version 2.1 or higher installed, as well as Ruby development headers for gem compilation (on 39 | Debian based systems, this is the `ruby-dev` package). With ruby installed add the `bundler` gem and 40 | then run `bundle install` inside the checkout directory. 41 | 42 | Once all dependencies are met simply run `./bin/blue_hydra` to start discovery. 43 | If you experience gem inconsistency try running `bundle exec ./bin/blue_hydra` instead. 44 | 45 | There are a few flags that can be passed to this script: 46 | 47 | * `-d` or `--daemonize`: suppress CLI output and run in background 48 | * `-z` or `--demo`: run with CLI output but mask displayed macs for demo purposes 49 | * `-p` or `--pulse`: attempt to send data to Pwn Pulse 50 | 51 | 52 | ## Recommended Hardware 53 | BlueHydra should function with most internal bluetooth cards but we recommend 54 | using the Sena UD100 adapter. 55 | 56 | Additionally you can make use of Ubertooth One hardware to detect active devices 57 | not in discoverable mode. 58 | 59 | **Note:** using an Ubertooth One is _not_ a replacement for a conventional 60 | bluetooth dongle. 61 | 62 | ## Configuring Options 63 | 64 | The config file `blue_hydra.yml` is located in the install directory, unless /etc/blue_hydra exists, 65 | then it is in /etc/blue_hydra. The config file is located in `/opt/pwnix/data/blue_hydra/blue_hydra.yml` on 66 | Pwnie devices. 67 | 68 | The following options can be set: 69 | 70 | * `log_level`: defaults to info level, can be set to debug for much more verbosity. If set to `false` no log or rssi log will be created. 71 | * `bt_device`: specify device to use as main bluetooth interface, defaults to `hci0` 72 | * `info_scan_rate`: rate at which to run info scan in seconds, defaults to 240. Values too small will be set to 45. Value of 0 disables info scanning. 73 | * `status_sync_rate`: rate at which to sync device status to Pulse in seconds 74 | * `btmon_log`: `true|false`, if set to true will log filtered btmon output 75 | * `btmon_rawlog`: `true|false`, if set to true will log unfiltered btmon output 76 | * `file`: if set to a filepath that file will be read in rather than doing live device interactions 77 | * `rssi_log`: `true|false`, if set will log serialized RSSI values 78 | * `aggressive_rssi`: `true|false`, if set will agressively send RSSIs to Pulse 79 | * `ui_inc_filter_mode`: `:disabled|:hilight|:exclusive`, set ui filtering to this mode by default 80 | * `ui_inc_filter_mac`: `- FF:FF:00:00:59:25`, set inclusive filter on this mac, each goes on a newline proceeded by hiphon and space 81 | * `ui_inc_filter_prox`: `- 669a0c20-0008-9191-e411-1b11d05d7707-9001-3364`, set inclusive filter on this proximity_uuid-major_number-minor_number, each goes on a newline proceeded by hiphon and space 82 | * `ui_exc_filter_mac`: same syntax as ui_inc_filter_mac, but exclude instead 83 | * `ui_exc_filter_prox`: same syntax as ui_inc_filter_prox, but exclude instead 84 | * `ignore_mac`: same syntax as ui_inc_filter mac, but entirely ignore device, both db and ui 85 | 86 | ## Usage 87 | 88 | It may also be useful to check blue_hydra --help for additional command line options. At this time it looks like this: 89 | 90 | ``` 91 | Usage: blue_hydra [options] 92 | -d, --daemonize Suppress output and run in daemon mode 93 | -z, --demo Hide mac addresses in CLI UI 94 | -p, --pulse Send results to hermes 95 | --pulse-debug Store results in a file for review 96 | --no-db Keep db in ram only 97 | --rssi-api Open 127.0.0.1:1124 to allow other processes to poll for seen devices and rssi 98 | --no-info For the purposes for fox hunting, don't info scan. Some info may be missing, but there will be less gaps during tracking 99 | 100 | -h, --help Show this message 101 | ``` 102 | 103 | ## Logging 104 | 105 | All data is logged to an sqlite database (unless --no-db) is passed at the command line. The database `blue_hydra.db` is located in the blue_hydra 106 | directory, unless /etc/blue_hydra exists, and then it is placed in /etc/blue_hydra. On Pwnie Express sensors, it will be in /opt/pwnix/data. 107 | 108 | The database will automatically be cleaned of older devices to ensure performance. If you want to keep information about devices which haven't been seen in more than a week it is your responsibility to offload data using one of the available options (`--pulse`, `--pulse-debug`) or manually back up the database once a week. 109 | 110 | An example for a script wrapping blue_hydra and creating a csv output after run is available here: 111 | https://github.com/pwnieexpress/pwn_pad_sources/blob/develop/scripts/blue_hydra.sh 112 | This script will simply take a timestamp before blue_hydra starts, and then again after it exits, then grab a few interesting values from the db and output in csv format. 113 | 114 | ## Helping with Development 115 | 116 | PR's should be targeted against the "develop" branch. 117 | Develop branch gets merged to master branch and tagged during the release process. 118 | 119 | ## Troubleshooting 120 | 121 | ### `Parser thread "\xC3" on US-ASCII` 122 | 123 | If you encounter an error like `Parser Thread "\xC3" on US-ASCII` it may be due 124 | to an encoding misconfiguration on your system. 125 | 126 | On Debian like systems, this can be resolved by setting locale encodings as follows: 127 | 128 | ``` 129 | sudo locale-gen en_US.UTF-8 130 | sudo locale-gen en en_US en_US.UTF-8 131 | sudo dpkg-reconfigure locales 132 | export LC_ALL="en_US.UTF-8" 133 | ``` 134 | 135 | This issue and solution brought up by [llazzaro](https://github.com/llazzaro) 136 | [here](https://github.com/pwnieexpress/blue_hydra/issues/65). 137 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $:.unshift(File.dirname(File.expand_path('../lib/blue_hydra.rb',__FILE__))) 2 | require 'blue_hydra' 3 | require 'pry' 4 | 5 | desc "Print the version." 6 | task "version" do 7 | puts BlueHydra::VERSION 8 | end 9 | 10 | desc "Sync all records to pulse" 11 | task "sync_all" do 12 | BlueHydra::Device.all.each do |dev| 13 | puts "Syncing #{dev.address}" 14 | dev.sync_to_pulse(true) 15 | end 16 | end 17 | 18 | desc "BlueHydra Console" 19 | task "console" do 20 | binding.pry 21 | end 22 | 23 | desc "Summarize Devices" 24 | task "summary" do 25 | BlueHydra::Device.all.each do |dev| 26 | puts "Device -- #{dev.address}" 27 | dev.attributes.each do |name, val| 28 | next if [:address, :classic_rssi, :le_rssi].include?(name) 29 | if %w{ 30 | classic_features le_features le_flags classic_channels 31 | le_16_bit_service_uuids classic_16_bit_service_uuids 32 | le_128_bit_service_uuids classic_128_bit_service_uuids classic_class 33 | le_rssi classic_rssi primary_services 34 | }.map(&:to_sym).include?(name) 35 | unless val == '[]' || val == nil 36 | puts " #{name}:" 37 | JSON.parse(val).each do |v| 38 | puts " #{v}" 39 | end 40 | end 41 | else 42 | unless val == nil 43 | puts " #{name}: #{val}" 44 | end 45 | end 46 | end 47 | end 48 | end 49 | 50 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | calibrate range for sena dongle 2 | migrate away from dbus/test-discovery 3 | do we need to send status with aggressive_rssi? 4 | 5 | add in a passive le mode 6 | add in a passive classic mode 7 | when ubertooth is available, specifically detect inquiries 8 | add support for ubertooth index 9 | 10 | refactor pass & code cleanup 11 | extend tests a bit 12 | 13 | Some stuff to do 14 | * handle alt UUIDs which contain paren 15 | * rate limit incoming RSSIs to 1 per timeframe 16 | * Investigate duplicate classic_features_bitmaps... 17 | * catch bluez chunk start lines by number instead of randomly abbreviated header 18 | 19 | * add summary rake tasks to extract data from CLI after CUI is not running or from daemon mode service. 20 | 21 | 22 | ``` 23 | W, [2016-01-27T15:51:44.838857 #18723] WARN -- : 00:61:71:D0:E1:EF multiple values detected for classic_features_bitmap: ["0xbf 0xfe 0xcf 0xfe 0xdb 0xff 0x7b 0x87", "0x07 0x00 0x00 0x00 0x00 0x00 0x00 0x00", "0x000002a8"]. Using first value... 24 | ``` 25 | ^^ this looks severely like we are missing a (most likely le) start block, because that looks like a classic features bitmap colliding with an le one. 26 | 27 | We should lookup 16 bit uuid's as they are assigned: 28 | - https://www.bluetooth.com/specifications/assigned-numbers/16-bit-uuids-for-members 29 | - https://www.bluetooth.com/specifications/assigned-numbers/16-bit-uuids-for-sdos 30 | 31 | We should look up more things which are assigned 32 | - https://www.bluetooth.com/specifications/assigned-numbers 33 | 34 | Some le uuids 35 | https://gitlab.com/sdalu/ruby-ble 36 | -------------------------------------------------------------------------------- /bin/blue_hydra: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: UTF-8 3 | Encoding.default_external = Encoding::UTF_8 4 | Encoding.default_internal = Encoding::UTF_8 5 | $0 = "BlueHydra" 6 | Version = '1.9.21-git' 7 | 8 | # add ../lib/ to load path 9 | $:.unshift(File.dirname(File.expand_path('../../lib/blue_hydra.rb',__FILE__))) 10 | 11 | # require for option parsing 12 | require 'optparse' 13 | 14 | # parse all command line arguments and store in options Hash for run time 15 | # configurations 16 | options = {} 17 | 18 | OptionParser.new do |opts| 19 | opts.on("-d", "--daemonize", "Suppress output and run in daemon mode") do |v| 20 | options[:daemonize] = true 21 | end 22 | opts.on("-z", "--demo", "Hide mac addresses in CLI UI") do |v| 23 | options[:demo] = true 24 | end 25 | opts.on("-p", "--pulse", "Send results to hermes") do |v| 26 | options[:pulse] = true 27 | end 28 | opts.on("--pulse-debug", "Store results in a file for review") do |v| 29 | options[:pulse_debug] = true 30 | end 31 | # This gets parsed directly and is not available as BlueHydra.db 32 | opts.on("--no-db", "Keep db in ram only") do |v| 33 | options[:no_db] = true 34 | end 35 | opts.on("--rssi-api", "Open 127.0.0.1:1124 to allow other processes to poll for seen devices and rssi") do |v| 36 | options[:signal_spitter] = true 37 | end 38 | opts.on("--no-info", "For the purposes for fox hunting, don't info scan. Some info may be missing, but there will be less gaps during tracking") do |v| 39 | options[:no_info_scan] = true 40 | end 41 | opts.on("--mohawk-api", "For the purposes of making a hat to cover a mohawk, shit out the ui as json at /dev/shm/blue_hydra.json") do |v| 42 | options[:file_api] = true 43 | end 44 | opts.on("-v", "--version", "Show version and quit") do |v| 45 | puts "#{$0}: #{Version}" 46 | exit 47 | end 48 | opts.on_tail("-h", "--help", "Show this message") do 49 | puts opts 50 | exit 51 | end 52 | end.parse! 53 | 54 | unless Process.uid == 0 55 | puts "BlueHydra must be run as root to function" 56 | exit 1 57 | end 58 | 59 | 60 | # require the actual Blue Hydra code from lib 61 | require 'blue_hydra' 62 | 63 | # Daemon mode will run the service in the background with no CLI output 64 | if options[:daemonize] 65 | BlueHydra.daemon_mode = true 66 | else 67 | BlueHydra.daemon_mode = false 68 | end 69 | 70 | # Demo mode will disguise macs detected for demo purposes, only affects CLI 71 | # output, not what is stored in DB 72 | if options[:demo] 73 | BlueHydra.demo_mode = true 74 | else 75 | BlueHydra.demo_mode = false 76 | end 77 | 78 | # If the pulse flag is set the service will attempt to send results to 79 | # PwnPulse. This defaults to false and can be ignored unless the system 80 | # running Blue Hydra is a Pwnie Express sensor. 81 | if options[:pulse] 82 | BlueHydra.pulse = true 83 | else 84 | BlueHydra.pulse = false 85 | end 86 | 87 | if options[:pulse_debug] 88 | BlueHydra.pulse_debug = true 89 | else 90 | BlueHydra.pulse_debug = false 91 | end 92 | 93 | if options[:no_db] 94 | BlueHydra.no_db = true 95 | else 96 | BlueHydra.no_db = false 97 | end 98 | 99 | if options[:signal_spitter] || BlueHydra.config["signal_spitter"] 100 | BlueHydra.signal_spitter = true 101 | require 'timeout' #easier to do this here than anywhere else 102 | # we also need json and socket but those are unconditional requirements of pulse (which should be conditional) 103 | else 104 | BlueHydra.signal_spitter = false 105 | end 106 | 107 | if options[:no_info_scan] 108 | BlueHydra.info_scan = false 109 | else 110 | BlueHydra.info_scan = true 111 | end 112 | 113 | if options[:file_api] 114 | BlueHydra.file_api = true 115 | else 116 | BlueHydra.file_api = false 117 | end 118 | 119 | # This file is used by the service scan to kill the process and should be 120 | # cleaned up when this crashes or is killed via the service scan. 121 | PID_FILE = '/var/run/blue_hydra.pid' 122 | File.write(PID_FILE, Process.pid) 123 | 124 | # this flag gets used to safely trap interrupts from the keyboard and 125 | # gracefully stop the running process without violently killing and potentially 126 | # losing data 127 | done = false 128 | trap('SIGINT') do 129 | done = true 130 | end 131 | 132 | got_sighup = false 133 | trap('SIGHUP') do 134 | got_sighup = true 135 | end 136 | 137 | begin 138 | BlueHydra.logger.info("BlueHydra Starting...") 139 | # Start the main workers... 140 | runner = BlueHydra::Runner.new 141 | runner.start 142 | 143 | # This blocking loop keeps the scanner alive in its threads. Refer to the 144 | # BlueHydra::Runner to understand the main work threads. 145 | loop do 146 | 147 | if done 148 | BlueHydra.logger.info("BlueHydra Killed! Exiting... SIGINT") 149 | exit_status = 0 150 | break 151 | end 152 | 153 | if got_sighup 154 | BlueHydra.initialize_logger 155 | BlueHydra.update_logger 156 | got_sighup = false 157 | end 158 | 159 | # check the status of the runner and make sure all threads are alive 160 | status = runner.status 161 | 162 | unless status[:stopping] 163 | threads = [ 164 | :btmon_thread, 165 | :chunker_thread, 166 | :parser_thread, 167 | :result_thread 168 | ] 169 | 170 | unless BlueHydra.config["file"] 171 | threads << :discovery_thread 172 | end 173 | 174 | if BlueHydra.signal_spitter 175 | threads << :signal_spitter_thread 176 | threads << :empty_spittoon_thread 177 | end 178 | 179 | threads.each do |thread_key| 180 | if status[thread_key] == nil || status[thread_key] == false 181 | raise FailedThreadError, thread_key 182 | end 183 | end 184 | else 185 | done = true 186 | end 187 | sleep 1 unless done 188 | end 189 | 190 | # raised above in threads check when one of the threads has died for some 191 | # reason 192 | rescue FailedThreadError => e 193 | BlueHydra.logger.error("Thread failure: #{e.message}") 194 | exit_status = 1 195 | 196 | # this traps unexpected or non specified errors 197 | rescue => e 198 | BlueHydra.logger.error("Generic Error: #{e.to_s}") 199 | e.backtrace.each do |line| 200 | BlueHydra.logger.error(line) 201 | end 202 | exit_status = 1 203 | 204 | # we need to stop the runner and clean up the PIDFILE 205 | # 206 | # stopping the runner should allow the processing Queue to drain and may take a 207 | # moment depending on how many devices are being seen. 208 | ensure 209 | runner.stop 210 | File.unlink(PID_FILE) 211 | BlueHydra.logger.info("GOODBYE! ^_^") 212 | exit_status ||= 7 213 | exit exit_status 214 | end 215 | -------------------------------------------------------------------------------- /bin/rfkill-reset: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ ! -c /dev/rfkill ]; then 3 | printf "rfkill not supported\n" 4 | exit 2 5 | fi 6 | if [ ! -x "$(command -v rfkill 2>&1)" ]; then 7 | printf "Your kernel supports rfkill but you don't have rfkill installed.\n" 8 | printf "To ensure devices are unblocked you must install rfkill.\n" 9 | exit 3 10 | fi 11 | 12 | index="$(rfkill list | grep ${1} | awk -F: '{print $1}')" 13 | if [ -z "$index" ]; then 14 | exit 187 15 | fi 16 | 17 | rfkill_check() { 18 | rfkill_status="$(rfkill list ${index} 2>&1)" 19 | if [ $? != 0 ]; then 20 | printf "rfkill error: ${rfkill_status}\n" 21 | return 187 22 | elif [ -z "${rfkill_status}" ]; then 23 | printf "rfkill had no output, something went wrong.\n" 24 | exit 1 25 | else 26 | soft=$(printf "${rfkill_status}" | grep -i soft | awk '{print $3}') 27 | hard=$(printf "${rfkill_status}" | grep -i hard | awk '{print $3}') 28 | if [ "${soft}" = "yes" ] && [ "${hard}" = "no" ]; then 29 | return 1 30 | elif [ "${soft}" = "no" ] && [ "${hard}" = "yes" ]; then 31 | return 2 32 | elif [ "${soft}" = "yes" ] && [ "${hard}" = "yes" ]; then 33 | return 3 34 | fi 35 | fi 36 | return 0 37 | } 38 | 39 | rfkill_reset() { 40 | #attempt block and CHECK SUCCESS 41 | rfkill_status="$(rfkill unblock ${1} 2>&1)" 42 | if [ $? != 0 ]; then 43 | printf "rfkill error: ${rfkill_status}\n" 44 | printf "Unable to block.\n" 45 | return 1 46 | else 47 | sleep 1 48 | rfkill_unblock 49 | return $? 50 | fi 51 | } 52 | 53 | rfkill_unblock() { 54 | #attempt unblock and CHECK SUCCESS 55 | rfkill_status="$(rfkill unblock ${1} 2>&1)" 56 | if [ $? != 0 ]; then 57 | printf "rfkill error: ${rfkill_status}\n" 58 | printf "Unable to unblock.\n" 59 | return 1 60 | else 61 | sleep 1 62 | return 0 63 | fi 64 | } 65 | 66 | #check if rfkill is set and cry if it is 67 | rfkill_check $index 68 | rfkill_retcode="$?" 69 | case ${rfkill_retcode} in 70 | 0) rfkill_reset $index ;; 71 | 1) rfkill_unblock $index ;; 72 | *) printf "Unable to automagically fix\n" ;; 73 | esac 74 | -------------------------------------------------------------------------------- /bin/test-discovery: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from __future__ import absolute_import 4 | 5 | from optparse import OptionParser, make_option 6 | import dbus 7 | import dbus.mainloop.glib 8 | import sys 9 | # gentoo 10 | sys.path.append('/usr/lib64/bluez/test') 11 | # gentoo - arm 12 | sys.path.append('/usr/lib/bluez/test') 13 | # kali2 14 | sys.path.append('/usr/share/doc/bluez-test-scripts/examples') 15 | # ubuntu 16 | sys.path.append('/usr/share/doc/bluez-tests/examples') 17 | import bluezutils 18 | import time 19 | 20 | 21 | def property_changed(name, value): 22 | if (name == "Discovering" and not value): 23 | sys.exit(0) 24 | 25 | 26 | if __name__ == '__main__': 27 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 28 | 29 | bus = dbus.SystemBus() 30 | 31 | option_list = [ 32 | make_option("-i", "--device", action="store", 33 | type="string", dest="dev_id"), 34 | make_option("-t", "--timeout", action="store", 35 | type="int", dest="timeout"), 36 | ] 37 | parser = OptionParser(option_list=option_list) 38 | 39 | (options, args) = parser.parse_args() 40 | 41 | adapter = bluezutils.find_adapter(options.dev_id) 42 | 43 | bus.add_signal_receiver(property_changed, 44 | dbus_interface = "org.bluez.Adapter1", 45 | signal_name = "PropertyChanged") 46 | 47 | adapter.StartDiscovery() 48 | time.sleep(options.timeout) 49 | adapter.StopDiscovery() 50 | -------------------------------------------------------------------------------- /ico/bluehydra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroChaos-/blue_hydra/c7462b9e7570c390b80b7a03b844bcb1d3d5ecdc/ico/bluehydra.png -------------------------------------------------------------------------------- /lib/blue_hydra.rb: -------------------------------------------------------------------------------- 1 | # Core Libs 2 | require 'pty' 3 | require 'logger' 4 | require 'json' 5 | require 'open3' 6 | require 'securerandom' 7 | require 'zlib' 8 | require 'yaml' 9 | require 'fileutils' 10 | require 'socket' 11 | 12 | # Gems 13 | require 'dm-migrations' 14 | require 'dm-timestamps' 15 | require 'dm-validations' 16 | require 'louis' 17 | 18 | # Add to Load Path 19 | $:.unshift(File.dirname(__FILE__)) 20 | 21 | # Helpful Errors to raise in specific cased. 22 | class BluezNotReadyError < StandardError; end 23 | class FailedThreadError < StandardError; end 24 | class BtmonExitedError < StandardError; end 25 | 26 | # Primary 27 | module BlueHydra 28 | # 0.0.1 first stable verison 29 | # 0.0.2 timestamps, feedback loop for info scans, l2ping 30 | # 0.1.0 first working version with frozen models for Pwn Pulse 31 | # 1.0.0 many refactors, already in stable sensor release as per 1.7.2 32 | # 1.1.0 CUI, readability refactor, many small improvements 33 | # 1.1.1 Range monitoring based on TX power, OSS cleanup 34 | # 1.1.2 Add pulse reset 35 | # 1.2.0 drop status sync, restart will use a reset message to reset statuses if we miss both the original message and the daily changed syncs 36 | VERSION = '1.2.0' 37 | 38 | # Config file located in /etc/blue_hydra/blue_hydra.yml when installed system-wide 39 | # or in the local directory if run from a git checkout. 40 | CONFIG_FILE = if ENV["BLUE_HYDRA"] == "test" 41 | File.expand_path('../../blue_hydra.yml', __FILE__) 42 | elsif Dir.exist?('/etc/blue_hydra') 43 | '/etc/blue_hydra/blue_hydra.yml' 44 | else 45 | File.expand_path('../../blue_hydra.yml', __FILE__) 46 | end 47 | 48 | # Default configuration values 49 | # 50 | # Note: "file" can also be set but has no default value 51 | DEFAULT_CONFIG = { 52 | "log_level" => "info", 53 | "bt_device" => "hci0", # change for external ud100 54 | "ubertooth_index" => "0", # ubertooth device index 55 | "info_scan_rate" => 240, # 4 minutes in seconds 56 | "btmon_log" => false, # if set will write used btmon output to a log file 57 | "btmon_rawlog" => false, # if set will write raw btmon output to a log file 58 | "file" => false, # if set will read from file, not hci dev 59 | "rssi_log" => false, # if set will log rssi 60 | "aggressive_rssi" => false, # if set will sync all rssi to pulse 61 | "ui_inc_filter_mode" => :disabled, # default ui filter mode to start in 62 | "ui_inc_filter_mac" => [], # inclusive ui filter by mac 63 | "ui_inc_filter_prox" => [], # inclusive ui filter by prox uuid / major /minor 64 | "ui_exc_filter_mac" => [], # exclude ui filter by mac 65 | "ui_exc_filter_prox" => [], # exclude ui filter by prox uuid / major /minor 66 | "ignore_mac" => [], # completely ignore a mac address, both ui and db 67 | "signal_spitter" => false, # make raw signal strength api available on localhost:1124 68 | "chunker_debug" => false 69 | } 70 | 71 | # Create config file with defaults if missing or load and update. 72 | @@config = if File.exist?(CONFIG_FILE) 73 | new_config = YAML.load(File.read(CONFIG_FILE)) 74 | #error checking 75 | # throw something in here to detect non-nil but bad values. such as using .is_a?(Array) for things 76 | # which we expect to be an array 77 | 78 | if new_config["info_scan_rate"] 79 | # handle people putting in negative number by changing them to positive ones 80 | new_config["info_scan_rate"] = new_config["info_scan_rate"].abs 81 | # handle set non-sense low values to a sane minimum 82 | if ( new_config["info_scan_rate"] < 45 && new_config["info_scan_rate"] != 0 ) 83 | new_config["info_scan_rate"] = 45 84 | end 85 | end 86 | #conversions 87 | new_config["ui_inc_filter_mac"].map{|mac|mac.upcase!} if new_config["ui_inc_filter_mac"] 88 | new_config["ui_inc_filter_prox"].map{|prox|prox.downcase!} if new_config["ui_inc_filter_prox"] 89 | new_config["ui_exc_filter_mac"].map{|emac|emac.upcase!} if new_config["ui_exc_filter_mac"] 90 | new_config["ui_exc_filter_prox"].map{|eprox|eprox.downcase!} if new_config["ui_exc_filter_prox"] 91 | new_config["ignore_mac"].map{|imac|imac.upcase!} if new_config["ignore_mac"] 92 | #migration 93 | (new_config["ui_inc_filter_mode"] = new_config["ui_filter_mode"]) if new_config["ui_filter_mode"] 94 | new_config.reject!{|k,v| v == nil} 95 | DEFAULT_CONFIG.merge(new_config) 96 | else 97 | DEFAULT_CONFIG 98 | end 99 | 100 | #remove keys we migrated away from 101 | @@config.keep_if{|k,_| DEFAULT_CONFIG.include?(k)} 102 | 103 | # update the config file with any new values not present, will leave 104 | # configured values intact but should allow users to pull code changes with 105 | # new config options and have them show up in the file after running 106 | File.write(CONFIG_FILE, @@config.to_yaml.gsub("---\n",'')) 107 | 108 | # blue_hydra.log will be written to /var/log/blue_hydra if the path exists, or in the local directory 109 | LOGFILE = if ENV["BLUE_HYDRA"] == "test" 110 | File.expand_path('../../blue_hydra.log', __FILE__) 111 | elsif Dir.exist?('/var/log/blue_hydra') 112 | File.expand_path('/var/log/blue_hydra/blue_hydra.log', __FILE__) 113 | else 114 | File.expand_path('../../blue_hydra.log', __FILE__) 115 | end 116 | 117 | # override logger which does nothing 118 | class NilLogger 119 | # nil! :) 120 | def initialize; end 121 | def level=(lvl); end 122 | def fatal(msg); end 123 | def error(msg); end 124 | def warn(msg); end 125 | def info(msg); end 126 | def debug(msg); end 127 | def formatter=(fm); end 128 | end 129 | 130 | def self.initialize_logger 131 | # set log level from config 132 | @@logger = if @@config["log_level"] 133 | Logger.new(LOGFILE) 134 | else 135 | NilLogger.new 136 | end 137 | @@logger.level = Logger::DEBUG 138 | end 139 | 140 | def self.update_logger 141 | @@logger.level = case @@config["log_level"] 142 | when "fatal" 143 | Logger::FATAL 144 | when "error" 145 | Logger::ERROR 146 | when "warn" 147 | Logger::WARN 148 | when "info" 149 | Logger::INFO 150 | when "debug" 151 | Logger::DEBUG 152 | else 153 | Logger::INFO 154 | end 155 | end 156 | 157 | initialize_logger 158 | update_logger 159 | 160 | # the RSSI log will only get used if the appropriate config value is set 161 | # 162 | # blue_hydra_rssi.log will be written to /var/log/blue_hydra if the path exists, or in the local directory 163 | RSSI_LOGFILE = if ENV["BLUE_HYDRA"] == "test" 164 | File.expand_path('../../blue_hydra_rssi.log', __FILE__) 165 | elsif Dir.exist?('/var/log/blue_hydra') 166 | File.expand_path('/var/log/blue_hydra/blue_hydra_rssi.log', __FILE__) 167 | else 168 | File.expand_path('../../blue_hydra_rssi.log', __FILE__) 169 | end 170 | 171 | @@rssi_logger = if @@config["log_level"] 172 | Logger.new(RSSI_LOGFILE) 173 | else 174 | NilLogger.new 175 | end 176 | @@rssi_logger.level = Logger::INFO 177 | 178 | # we dont want logger formatting here, the code defines what we want these 179 | # lines to be 180 | @@rssi_logger.formatter = proc {|s,d,p,m| "#{m}\n"} 181 | 182 | # the chunk log will only get used if the appropriate config value is set 183 | # 184 | # blue_hydra_chunk.log will be written to /var/log/blue_hydra if the path exists, or in the local directory 185 | CHUNK_LOGFILE = if ENV["BLUE_HYDRA"] == "test" 186 | File.expand_path('../../blue_hydra_chunk.log', __FILE__) 187 | elsif Dir.exist?('/var/log/blue_hydra') 188 | File.expand_path('/var/log/blue_hydra/blue_hydra_chunk.log', __FILE__) 189 | else 190 | File.expand_path('../../blue_hydra_chunk.log', __FILE__) 191 | end 192 | 193 | @@chunk_logger = if @@config["log_level"] 194 | Logger.new(CHUNK_LOGFILE) 195 | else 196 | NilLogger.new 197 | end 198 | @@chunk_logger.level = Logger::INFO 199 | 200 | # we dont want logger formatting here, the code defines what we want these 201 | # lines to be 202 | @@chunk_logger.formatter = proc {|s,d,p,m| "#{m}\n"} 203 | 204 | # expose the logger as a module function 205 | def logger 206 | @@logger 207 | end 208 | 209 | # expose the logger as a module function 210 | def rssi_logger 211 | @@rssi_logger 212 | end 213 | 214 | # expose the logger as a module function 215 | def chunk_logger 216 | @@chunk_logger 217 | end 218 | 219 | # expose the config as module function 220 | def config 221 | @@config 222 | end 223 | 224 | # getter for daemon mode option 225 | def daemon_mode 226 | @@daemon_mode ||= false 227 | end 228 | 229 | # setter for daemon mode option 230 | def daemon_mode=(setting) 231 | @@daemon_mode = setting 232 | end 233 | 234 | # getter for fileapi option 235 | def file_api 236 | @@file_api ||= false 237 | end 238 | 239 | # setter for file api option 240 | def file_api=(setting) 241 | @@file_api = setting 242 | end 243 | 244 | # getter for demo mode option 245 | def demo_mode 246 | @@demo_mode ||= false 247 | end 248 | 249 | # setter for demo mode option 250 | def demo_mode=(setting) 251 | @@demo_mode = setting 252 | end 253 | 254 | # getter for pulse option 255 | def pulse 256 | @@pulse ||= false 257 | end 258 | 259 | # setter for pulse mode option 260 | def pulse=(setting) 261 | @@pulse = setting 262 | end 263 | 264 | # setter/getter/better 265 | def pulse_debug 266 | @@pulse_debug ||= false 267 | end 268 | def pulse_debug=(setting) 269 | @@pulse_debug = setting 270 | end 271 | 272 | def no_db 273 | @@no_db ||= false 274 | end 275 | 276 | def no_db=(setting) 277 | @@no_db = setting 278 | end 279 | 280 | def signal_spitter 281 | @@signal_spitter ||= false 282 | end 283 | 284 | def signal_spitter=(setting) 285 | @@signal_spitter = setting 286 | end 287 | 288 | def info_scan 289 | if defined? @@info_scan 290 | return @@info_scan 291 | else 292 | return true 293 | end 294 | end 295 | 296 | def info_scan=(setting) 297 | @@info_scan = setting 298 | end 299 | 300 | module_function :logger, :config, :daemon_mode, :daemon_mode=, :pulse, 301 | :pulse=, :rssi_logger, :demo_mode, :demo_mode=, 302 | :pulse_debug, :pulse_debug=, :no_db, :no_db=, 303 | :signal_spitter, :signal_spitter=, :chunk_logger, 304 | :info_scan, :info_scan=, :file_api, :file_api= 305 | end 306 | 307 | # require the actual code 308 | require 'blue_hydra/btmon_handler' 309 | require 'blue_hydra/parser' 310 | require 'blue_hydra/pulse' 311 | require 'blue_hydra/chunker' 312 | require 'blue_hydra/runner' 313 | require 'blue_hydra/command' 314 | require 'blue_hydra/device' 315 | require 'blue_hydra/sync_version' 316 | require 'blue_hydra/cli_user_interface' 317 | require 'blue_hydra/cli_user_interface_tracker' 318 | 319 | # Here we enumerate the local hci adapter hardware address and make it 320 | # available as an internal value 321 | 322 | BlueHydra::EnumLocalAddr = Proc.new do 323 | BlueHydra::Command.execute3( 324 | "hciconfig #{BlueHydra.config["bt_device"]}")[:stdout].scan( 325 | /((?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2})/i 326 | ).flatten 327 | end 328 | 329 | begin 330 | BlueHydra::LOCAL_ADAPTER_ADDRESS = BlueHydra::EnumLocalAddr.call.first 331 | rescue 332 | if ENV["BLUE_HYDRA"] == "test" 333 | BlueHydra::LOCAL_ADAPTER_ADDRESS = "JE:NK:IN:SJ:EN:KI" 334 | puts "Failed to find mac address for #{BlueHydra.config["bt_device"]}, faking for tests" 335 | else 336 | msg = "Unable to read the mac address from #{BlueHydra.config["bt_device"]}" 337 | BlueHydra::Pulse.send_event("blue_hydra", { 338 | key: 'blue_hydra_bt_device_mac_read_error', 339 | title: "Blue Hydra cant read mac from BT device #{BlueHydra.config["bt_device"]}", 340 | message: msg, 341 | severity: 'FATAL' 342 | }) 343 | BlueHydra.logger.error(msg) 344 | puts msg unless BlueHydra.daemon_mode 345 | exit 1 346 | end 347 | end 348 | 349 | # set all String properties to have a default length of 255 350 | DataMapper::Property::String.length(255) 351 | 352 | DB_DIR = '/etc/blue_hydra' 353 | DB_NAME = 'blue_hydra.db' 354 | DB_PATH = File.join(DB_DIR, DB_NAME) 355 | 356 | # The database will be stored in /etc/blue_hydra/blue_hydra.db if we are installed 357 | # system-wide. Otherwise it will attempt to create a sqlite db whereever the run was initiated. 358 | # 359 | # When running the rspec tets the BLUE_HYDRA environmental value will be set to 360 | # 'test' and all tests should run with an in-memory db. 361 | db_path = if ENV["BLUE_HYDRA"] == "test" || BlueHydra.no_db 362 | 'sqlite::memory:?cache=shared' 363 | elsif Dir.exist?(DB_DIR) 364 | "sqlite:#{DB_PATH}" 365 | else 366 | "sqlite:#{DB_NAME}" 367 | end 368 | 369 | # create the db file 370 | DataMapper.setup(:default, db_path) 371 | 372 | def brains_to_floor 373 | # in the case of an invalid / blank/ corrupt DB file we will back up the old 374 | # file and then create a new db to proceed. 375 | db_file = if Dir.exist?('/etc/blue_hydra/') 376 | "/etc/blue_hydra/blue_hydra.db" 377 | else 378 | "blue_hydra.db" 379 | end 380 | BlueHydra.logger.error("#{db_file} is not valid. Backing up to #{db_file}.corrupt and recreating...") 381 | BlueHydra::Pulse.send_event("blue_hydra", { 382 | key: 'blue_hydra_db_corrupt', 383 | title: 'Blue Hydra DB Corrupt', 384 | message: "#{db_file} is not valid. Backing up to #{db_file}.corrupt and recreating...", 385 | severity: 'ERROR' 386 | }) 387 | File.rename(db_file, "#{db_file}.corrupt") #=> 0 388 | BlueHydra.logger.fatal("Blue_Hydra needs to be restarted for this to take effect.") 389 | puts("Blue_Hydra needs to be restarted for this to take effect.") 390 | exit 1 391 | ## I really wish this works but it doesn't reopen the file and holds the handle on the renamed file 392 | #DataMapper.setup(:default, db_path) 393 | #DataMapper.auto_upgrade! 394 | end 395 | 396 | # DB Migration and upgrade logic 397 | begin 398 | begin 399 | # Upgrade the db.. 400 | # This requires a patched data_objects to work with ruby 3.2 and higher 401 | DataMapper.auto_upgrade! 402 | rescue DataObjects::ConnectionError 403 | brains_to_floor 404 | rescue NameError 405 | BlueHydra.logger.fatal("data_objects, part of datamapper, is not compatible with your version of ruby") 406 | BlueHydra.logger.fatal("A patch is availble here: https://pentoo.org/~zero/data_objects-fixnum2integer.patch") 407 | puts("data_objects, part of datamapper, is not compatible with your version of ruby") 408 | puts("A patch is availble here: https://pentoo.org/~zero/data_objects-fixnum2integer.patch") 409 | exit 1 410 | end 411 | 412 | #okay, database doesn't appear corrupt at first glance, but let's try a little bit harder... 413 | we_cool = DataMapper.repository.adapter.select('PRAGMA integrity_check') 414 | unless we_cool == ["ok"] 415 | brains_to_floor 416 | end 417 | 418 | DataMapper.finalize 419 | 420 | # massive speed up of sqlite by using in memory journal, this results in an 421 | # increased potential of corrupted DBs so the above code is used to recover 422 | # from that. 423 | DataMapper.repository.adapter.select('PRAGMA synchronous = OFF') 424 | DataMapper.repository.adapter.select('PRAGMA journal_mode = MEMORY') 425 | rescue => e 426 | BlueHydra.logger.error("#{e.class}: #{e.message}") 427 | log_message = "" 428 | e.backtrace.each do |line| 429 | BlueHydra.logger.error(line) 430 | log_message << line 431 | end 432 | BlueHydra::Pulse.send_event("blue_hydra", { 433 | key: 'blue_hydra_db_error', 434 | title: 'Blue Hydra Encountered DB Migration Error', 435 | message: log_message, 436 | severity: 'FATAL' 437 | }) 438 | exit 1 439 | end 440 | 441 | if BlueHydra::SyncVersion.count == 0 442 | BlueHydra::SyncVersion.new.save 443 | end 444 | 445 | BlueHydra::SYNC_VERSION = BlueHydra::SyncVersion.first.version 446 | -------------------------------------------------------------------------------- /lib/blue_hydra/btmon_handler.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | 3 | # This class is responsible for running the Bluetooth monitor. It can also be 4 | # passed other commands such as "cat btmonoutput.txt" to allow replaying of 5 | # recorded bluetooth scans. 6 | class BtmonHandler 7 | 8 | # initialize an instance of the class to run a command and push filtered 9 | # output into the parsing and processing pipeline 10 | # 11 | # == Parameters: 12 | # command :: 13 | # the command to get data from, in most cases this is `btmon -T` 14 | # parse_queue :: 15 | # Queue object to push results into 16 | def initialize(command, parse_queue) 17 | @command = command 18 | @parse_queue = parse_queue 19 | 20 | # # log used btmon output for review if requested 21 | if BlueHydra.config["btmon_log"] 22 | @log_file = if Dir.exist? ('/var/log/blue_hydra') 23 | File.open("/var/log/blue_hydra/btmon_#{Time.now.to_i}.log.gz",'w+') 24 | else 25 | File.open("btmon_#{Time.now.to_i}.log.gz",'w+') 26 | end 27 | @log_writer = Zlib::GzipWriter.wrap(@log_file) 28 | end 29 | # # log raw btmon output for review if requested 30 | if BlueHydra.config["btmon_rawlog"] 31 | @rawlog_file = if Dir.exist?('/var/log/blue_hydra') 32 | File.open("/var/log/blue_hydra/btmon_raw_#{Time.now.to_i}.log.gz",'w+') 33 | else 34 | File.open("btmon_raw_#{Time.now.to_i}.log.gz",'w+') 35 | end 36 | @rawlog_writer = Zlib::GzipWriter.wrap(@rawlog_file) 37 | end 38 | 39 | # initialize itself calls the method that spanws the PTY which runs the 40 | # command 41 | begin 42 | spawn 43 | ensure 44 | @log_writer.close if @log_writer 45 | @rawlog_writer.close if @rawlog_writer 46 | end 47 | end 48 | 49 | # spawn a PTY to run @command 50 | def spawn 51 | PTY.spawn(@command) do |stdout, stdin, pid| 52 | 53 | # lines of output will be stacked up here until a message is complete 54 | # and pushed into @parse_queue 55 | buffer = [] 56 | 57 | begin 58 | # handle the streaming output line by line 59 | stdout.each do |line| 60 | 61 | # log used btmon output for review if we are in debug mode 62 | if BlueHydra.config["btmon_rawlog"] && !BlueHydra.config["file"] 63 | @rawlog_writer.puts(line.chomp) 64 | end 65 | 66 | # strip out color codes 67 | known_colors = [ 68 | "\e[0;30m", "\e[1;30m", 69 | "\e[0;31m", "\e[1;31m", 70 | "\e[0;32m", "\e[1;32m", 71 | "\e[0;33m", "\e[1;33m", 72 | "\e[0;34m", "\e[1;34m", 73 | "\e[0;35m", "\e[1;35m", 74 | "\e[0;36m", "\e[1;36m", 75 | "\e[0;37m", "\e[1;37m", 76 | "\e[0m", 77 | ] 78 | 79 | begin 80 | known_colors.each do |c| 81 | line = line.gsub(c, "") 82 | end 83 | rescue ArgumentError 84 | BlueHydra.logger.warn("Non UTF-8 encoding in line: #{line.chomp}") 85 | next 86 | end 87 | 88 | # Messages are indented under a header as follows 89 | # 90 | # Message A 91 | # Data A1 92 | # Data A2 93 | # Message B 94 | # Data B1 95 | # Data B1a 96 | # Data B2 97 | # 98 | # If the line starts with whitespace we are still in a nested 99 | # message otherwise we hit a new message and should empy the buffer 100 | # 101 | # \s == whitespace 102 | # \S == non whitespace 103 | # 104 | # When we get a line that starts with non-whitespace we are dealing 105 | # with a new message starting 106 | if line =~ /^\S/ 107 | 108 | # if we have nothing in the buffer its our first message of the 109 | # run so we dont need to do anything but if we have a non-zero 110 | # sized buffer we push the contents of the buffer into the 111 | # @parse_queue 112 | if buffer.size > 0 113 | enqueue(buffer) 114 | end 115 | 116 | # reset the buffer 117 | buffer = [] 118 | end 119 | 120 | buffer << line 121 | end 122 | rescue Errno::EIO 123 | # File has completed or command has crashed, either way flush last 124 | # chunks to buffer 125 | enqueue(buffer) 126 | 127 | raise BtmonExitedError 128 | end 129 | end 130 | end 131 | 132 | # filter and then push an array of lines into the @parse_queue 133 | def enqueue(buffer) 134 | 135 | # discard anything which we sent to the modem as those lines 136 | # will start with < 137 | # also discard anything prefixed with @ (local events) 138 | # drop command complete messages and similar messages that do not seem to be useful 139 | # 140 | # numbers from bluez monitor/packet.c static const struct event_data event_table 141 | return if buffer.first =~ /^ HCI Event: .* \(0x0f\)/ # "Command Status" 144 | return if buffer.first =~ /^> HCI Event: .* \(0x13\)/ # "Number of Completed Packets" 145 | return if buffer.first =~ /^> HCI Event: Unknown \(0x00\)/ 146 | return if buffer.first =~ /^Bluetooth monitor ver/ 147 | return if (buffer[0] =~ /^> HCI Event: .* \(0x0e\)/ && buffer[1] !~ /Remote/ ) # "Command Complete" this filters out local stuff 148 | return if buffer.first =~ /^= bluetoothd: Unable to/ 149 | return if buffer.first =~ /^= New Index:/ 150 | return if buffer.first =~ /^= Delete Index:/ 151 | return if buffer.first =~ /^= Open Index:/ 152 | return if buffer.first =~ /^= Close Index:/ 153 | return if buffer.first =~ /^= Index Info:/ 154 | return if buffer.first =~ /^= Note:/ 155 | 156 | # l2ping against a host that is gone will result in a good connect 157 | # complete message with a timed out status indicating the ping failed 158 | # do not send this to the parser as it will 'online' the record 159 | # when we actually want to let it time out. 160 | # 161 | # TODO add a positive feed back loop to indicate we have attempted 162 | # and failed to ping a device, for now, throw out everything that isn't Success 163 | # (l2pinging a down host results in "Page Timeout") 164 | # additional observed values include "ACL Connection Already Exists", "Command Disallowed" 165 | # "LMP Response Timeout / LL Response Timeout", "Connection Accept Timeout Exceeded" 166 | # "Connection Timeout" 167 | # This breaks if it starts with ^, no clue why 168 | return if (buffer[0] =~ /^> HCI Event: .* \(0x(03|07)\)/ && buffer[1] !~ /\sStatus: Success \(0x00\)/ ) # "Connect Complete|Remote Name Req Complete" 169 | 170 | # log used btmon output for review 171 | if BlueHydra.config["btmon_log"] && !BlueHydra.config["file"] && !BlueHydra.config["btmon_rawlog"] 172 | buffer.each do |line| 173 | @log_writer.puts(line.chomp) 174 | end 175 | end 176 | 177 | # unless this is a filtered message enqueue the buffer for realz. 178 | @parse_queue.push(buffer) 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/blue_hydra/chunker.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | # this class take incoming and outgoing queues and batches messages coming 3 | # out of the btmon handler 4 | class Chunker 5 | 6 | # initialize with incoming (from btmon) and outgoing (to parser) queues 7 | def initialize(incoming_q, outgoing_q) 8 | @incoming_q = incoming_q 9 | @outgoing_q = outgoing_q 10 | end 11 | 12 | # Worker method which takes in batches of data from the incoming queue, 13 | # treating the messages as chunks of a set of data this method will 14 | # group the chunked messages into a bigger set and then flush to the 15 | # parser when a new device starts to appear 16 | def chunk_it_up 17 | 18 | # start with an empty working set before any messages have been received 19 | working_set = [] 20 | 21 | # pop a chunk (array of lines of filtered btmon output) off the 22 | # incoming queue 23 | while current_msg = @incoming_q.pop 24 | 25 | # test if the message indicates the start of a new message 26 | # 27 | # also bypass if our working set is empty as this indicates we are 28 | # receiving our first device of the run 29 | if starting_chunk?(current_msg) && !working_set.empty? 30 | 31 | # if we just got a new message shovel the working set into the 32 | # outgoing queue and reset it 33 | address_list = working_set.flatten.reject{|x| x =~ /Direct address/}.join("").scan(/^\s*.*ddress: ((?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})/).flatten.uniq 34 | address_count = address_list.count 35 | if address_count == 1 36 | unless BlueHydra.config["ignore_mac"].include?(address_list[0]) 37 | @outgoing_q.push working_set 38 | end 39 | elsif address_count < 1 40 | if BlueHydra.config["chunker_debug"] 41 | working_set.flatten.each{|msg| BlueHydra.chunk_logger.info(msg.chomp) } 42 | BlueHydra.chunk_logger.info("-------------------------------------------------------------------------------") 43 | else 44 | BlueHydra.logger.warn("Got a chunk with no addresses, dazed and confused, discarding...") 45 | end 46 | BlueHydra::Pulse.send_event('blue_hydra', 47 | { 48 | key: 'bluehydra_chunk_0_address', 49 | title: 'BlueHydra chunked a chunk with 0 addresses.', 50 | message: 'BlueHydra chunked a chunk with 0 addresses', 51 | severity: 'FATAL' 52 | }) 53 | else 54 | if BlueHydra.config["chunker_debug"] 55 | working_set.flatten.each{|msg| BlueHydra.chunk_logger.info(msg.chomp) } 56 | BlueHydra.chunk_logger.info("-------------------------------------------------------------------------------") 57 | else 58 | BlueHydra.logger.warn("Got a chunk with multiple addresss, missing a start block. Discarding corrupted data...") 59 | end 60 | BlueHydra::Pulse.send_event('blue_hydra', 61 | { 62 | key: 'bluehydra_chunk_2_address', 63 | title: 'BlueHydra chunked a chunk with more than 1 uniq address.', 64 | message: 'BlueHydra chunked a chunk with more than 1 uniq address.', 65 | severity: 'FATAL' 66 | }) 67 | end 68 | #always clear the working set 69 | working_set = [] 70 | end 71 | 72 | # inject a timestamp onto the message parsed out of the first line of 73 | # btmon output 74 | ts = Time.parse(current_msg.first.strip.scan(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d*$/)[0]).to_i 75 | current_msg << "last_seen: #{ts}" 76 | 77 | # add the current message to the working set 78 | working_set << current_msg 79 | end 80 | end 81 | 82 | # test if the message indicates the start of a new message 83 | def starting_chunk?(chunk=[]) 84 | 85 | # numbers from bluez monitor/packet.c static const struct event_data event_table 86 | chunk_zero_strings =[ 87 | "03", # Connect Complete 88 | "12", # Role Change 89 | "2f", # Extended Inquiry Result 90 | "22", # Inquiry Result with RSSI 91 | "07", # Remote Name Req Complete 92 | "3d", # Remote Host Supported Features 93 | "04", # Connect Request 94 | "0e", # Command Complete 95 | ] 96 | 97 | # if the first line of the message chunk matches one of these patterns 98 | # it indicates a start chunk 99 | if chunk[0] =~ / \(0x(#{chunk_zero_strings.join('|')})\)/ 100 | true 101 | 102 | # LE start chunks are identified by patterns in their first and second 103 | # lines 104 | elsif chunk[0] =~ / \(0x3e\)/ && # LE Meta Event 105 | # Numbers from bluez monitor/packet.h static const struct subevent_data le_meta_event_table 106 | chunk[1] =~ / \(0x0[12d]\)/ # LE Connection Complete / LE Advertising Report / LE Extended Advertising Report 107 | true 108 | 109 | #name has been moved into MGMT Event, not sure what else, at least it has an address 110 | elsif chunk[0] =~/@ MGMT Event: .* \(0x0012\)/ # @ MGMT Event: Device Fo.. (0x0012) 111 | true 112 | 113 | # otherwise this will get grouped with the current working set in the 114 | # chunk it up method 115 | else 116 | false 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/blue_hydra/cli_user_interface.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | # This class is responsible for generating the CLI user interface views. Its 3 | # a bit crusty and probably could stand for a loving refactor someday. 4 | # 5 | # Someday soon... 6 | class CliUserInterface 7 | attr_accessor :runner, :cui_timeout, :l2ping_threshold 8 | 9 | # When we initialize this CUI we pass the runner which allows us to pull 10 | # information about the threads and queues for our own purposes 11 | def initialize(runner, cui_timeout=300) 12 | @runner = runner 13 | @cui_timeout = cui_timeout 14 | @l2ping_threshold = (@cui_timeout - 45) 15 | end 16 | 17 | def cui_status 18 | #this gets called a lot, but we need it to always be clean. 19 | #let it auto clean itself on call, at most once every 60 seconds. 20 | @last_clean ||= 0 21 | if Time.now.to_i - @last_clean > 59 22 | # remove devices from the cui_status which have expired 23 | unless BlueHydra.config["file"] 24 | @runner.cui_status.keys.select do |x| 25 | @runner.cui_status[x][:last_seen] < (Time.now.to_i - cui_timeout) 26 | end.each do |x| 27 | @runner.cui_status.delete(x) 28 | end 29 | end 30 | @last_clean = Time.now.to_i 31 | end 32 | 33 | return @runner.cui_status 34 | end 35 | 36 | # the following methods are simply alliasing data to be passed through from 37 | # the actual runner class 38 | def scanner_status 39 | @runner.scanner_status 40 | end 41 | 42 | def ubertooth_thread 43 | @runner.ubertooth_thread 44 | end 45 | 46 | def result_queue 47 | @runner.result_queue 48 | end 49 | 50 | def info_scan_queue 51 | if BlueHydra.info_scan && BlueHydra.config["info_scan_rate"].to_i != 0 52 | return @runner.info_scan_queue.length 53 | else 54 | return "disabled" 55 | end 56 | end 57 | 58 | def l2ping_queue 59 | @runner.l2ping_queue 60 | end 61 | 62 | def query_history 63 | @runner.query_history 64 | end 65 | 66 | def stop! 67 | puts "Exiting......." 68 | @runner.stop 69 | end 70 | 71 | # This is the message that gets printed before starting the CUI. It waits 72 | # til the user hits [Enter] before returning 73 | def help_message 74 | puts "\e[H\e[2J" 75 | 76 | msg = < e 312 | BlueHydra.logger.error("API thread #{e.message}") 313 | e.backtrace.each do |x| 314 | BlueHydra.logger.error("#{x}") 315 | end 316 | BlueHydra::Pulse.send_event("blue_hydra", 317 | {key:'blue_hydra_cui_thread_error', 318 | title:'Blue Hydras api Thread Encountered An Error', 319 | message:"#{e.message}", 320 | severity:'ERROR' 321 | }) 322 | end 323 | end 324 | 325 | # this method gets called over and over in the cui loop to print the data 326 | # table 327 | # 328 | # == Parameters: 329 | # max_height :: 330 | # integer value for height of output terminal 331 | # sort :: 332 | # symbol key to indicate what attribute table should be sorted on 333 | # order :: 334 | # symbol key to determine if we should reverse the sort order from asc 335 | # to desc 336 | # printable_keys :: 337 | # list of keys to be printed as table headers 338 | def render_cui(max_height,sort,order,printable_keys,filter_mode) 339 | begin 340 | 341 | # skip if we are reading from a file 342 | unless BlueHydra.config["file"] 343 | # check status of test discovery 344 | if scanner_status[:test_discovery] 345 | discovery_time = Time.now.to_i - scanner_status[:test_discovery] 346 | else 347 | discovery_time = "not started" 348 | end 349 | 350 | # check status of ubertooth 351 | if scanner_status[:ubertooth] 352 | if scanner_status[:ubertooth].class == Integer 353 | ubertooth_time = Time.now.to_i - scanner_status[:ubertooth] 354 | else 355 | ubertooth_time = scanner_status[:ubertooth] 356 | end 357 | else 358 | ubertooth_time = "Starting detection..." 359 | end 360 | end 361 | 362 | # pbuff is the print buffer we build up to write to the screen, each 363 | # time we append lines to pbuff we need to increment the lines count 364 | # so that we know how many lines we are trying to output. 365 | pbuff = "" 366 | lines = 1 367 | 368 | # clear screen, doesn't require line increment cause it wipes 369 | # everything 370 | pbuff << "\e[H\e[2J" 371 | 372 | # first line, blue hydra wrapped in blue 373 | pbuff << "\e[34;1mBlue Hydra\e[0m : " 374 | # unless we are reading from a file we will ad this to the first line 375 | if BlueHydra.config["file"] 376 | pbuff << "Reading data from " + BlueHydra.config["file"] 377 | else 378 | pbuff << "Devices Seen in last #{cui_timeout}s" 379 | end 380 | pbuff << ", processing_speed: #{@runner.processing_speed.round}/s, DB Stunned: #{@runner.stunned}" 381 | pbuff << "\n" 382 | lines += 1 383 | 384 | # second line, information about runner queues to help determine if we 385 | # have a backlog. backlogs mean that the data being displayed may be 386 | # delayed 387 | pbuff << "Queue status: result_queue: #{result_queue.length}, info_scan_queue: #{info_scan_queue}, l2ping_queue: #{l2ping_queue.length}\n" 388 | lines += 1 389 | 390 | # unless we are reading from a file we add a line with information 391 | # about the status of the discovery and ubertooth timers from the 392 | # runner 393 | unless BlueHydra.config["file"] 394 | pbuff << "Discovery status timer: #{discovery_time}, Ubertooth status: #{ubertooth_time}, Filter mode: #{filter_mode}\n" 395 | lines += 1 396 | end 397 | 398 | # initialize a hash to track column widths, default value is 0 399 | max_lengths = Hash.new(0) 400 | 401 | # guide for how we should justify (left / right), default is left so 402 | # really only adding overrides at this point. 403 | justifications = { 404 | _seen: :right, 405 | rssi: :right, 406 | range: :right 407 | } 408 | 409 | # nothing to do if cui_status is empty (no devices or all expired) 410 | unless cui_status.empty? 411 | 412 | # for each of the values we need to 413 | cui_status.values.each do |hsh| 414 | # fake a :_seen key with info derived from the :last_seen value 415 | hsh[:_seen] = " +#{Time.now.to_i - hsh[:last_seen]}s" 416 | # loop through the keys and figure out what the max value for the 417 | # width of the column is. This includes the length of the actual 418 | # header key itself 419 | printable_keys.each do |key| 420 | key_length = key.to_s.length 421 | if v = hsh[key].to_s 422 | if v.length > max_lengths[key] 423 | if v.length > key_length 424 | max_lengths[key] = v.length 425 | else 426 | max_lengths[key] = key_length 427 | end 428 | end 429 | end 430 | end 431 | end 432 | 433 | # select the keys which have some value greater than 0 434 | keys = printable_keys.select{|k| max_lengths[k] > 0} 435 | 436 | # reusable proc for formatting the keys 437 | prettify_key = Proc.new do |key| 438 | 439 | # shorten some names 440 | k = case key 441 | when :le_major_num 442 | :major 443 | when :le_minor_num 444 | :minor 445 | else 446 | key 447 | end 448 | 449 | # upcase the key 450 | k = k.upcase 451 | 452 | # if the key is the same as the sort value we need to add an 453 | # indicator and also determine if the values are sorted ascending 454 | # (^) or descending (v) 455 | if key == sort 456 | 457 | # determine order and add the sort indicator to the key 458 | z = order == "ascending" ? "^" : "v" 459 | k = "#{k} #{z}" 460 | 461 | # expand max length for the key column if adding the sort 462 | # indicator makes the key length greater than the current 463 | # tracked length for the column width 464 | if k.length > max_lengths[key] 465 | max_lengths[key] = k.length 466 | end 467 | end 468 | 469 | # replace underscores with spaces and left justify 470 | k.to_s.ljust(max_lengths[key]).gsub("_"," ") 471 | end 472 | 473 | # map across the keys and use the pretify key to clean up the key 474 | # before joining with | characters to create the header row 475 | header = keys.map{|k| prettify_key.call(k)}.join(' | ') 476 | 477 | # underline and add to pbuff 478 | pbuff << "\e[0;4m#{header}\e[0m\n" 479 | lines += 1 480 | 481 | # customize some of the sort options to handle integer values 482 | # which may be string wrapped in strange ways 483 | d = cui_status.values.sort_by do |x| 484 | if sort == :rssi || sort == :_seen 485 | x[sort].to_s.strip.to_i 486 | elsif sort == :range 487 | x[sort].strip.to_f rescue 2**256 488 | else 489 | # default sort is alpha sort 490 | x[sort].to_s 491 | end 492 | end 493 | 494 | # rssi values are neg numbers and so we want to just go ahead and 495 | # reverse the sort to beging by default 496 | if sort == :rssi 497 | d.reverse! 498 | end 499 | 500 | # if order is reverse we should go ahead and reverse the table data 501 | if order == "descending" 502 | d.reverse! 503 | end 504 | 505 | # iterate across the sorted data 506 | d.each do |data| 507 | 508 | #here we handle exclude filters 509 | if BlueHydra.config["ui_exc_filter_mac"].include?(data[:address]) 510 | next 511 | end 512 | if BlueHydra.config["ui_exc_filter_prox"].include?("#{data[:le_proximity_uuid]}-#{data[:le_major_num]}-#{data[:le_minor_num]}") 513 | next 514 | end 515 | 516 | #here we handle inc filter/hilight control 517 | hilight = "0" 518 | unless filter_mode == :disabled 519 | skip_data = true 520 | if BlueHydra.config["ui_inc_filter_mac"].empty? && BlueHydra.config["ui_inc_filter_prox"].empty? 521 | skip_data = false 522 | else 523 | if BlueHydra.config["ui_inc_filter_mac"].include?(data[:address]) 524 | skip_data = false 525 | hilight = "7" if filter_mode == :hilight 526 | elsif BlueHydra.config["ui_inc_filter_prox"].include?("#{data[:le_proximity_uuid]}-#{data[:le_major_num]}-#{data[:le_minor_num]}") 527 | skip_data = false 528 | hilight = "7" if filter_mode == :hilight 529 | end 530 | end 531 | next if ( skip_data && filter_mode == :exclusive ) 532 | end 533 | 534 | #prevent classic devices from expiring by forcing them onto the l2ping queue 535 | unless BlueHydra.config["file"] 536 | if data[:vers] =~ /cl/i 537 | ping_time = (Time.now.to_i - l2ping_threshold) 538 | query_history[data[:address]] ||= {} 539 | if (query_history[data[:address]][:l2ping].to_i < ping_time) && (data[:last_seen] < ping_time) 540 | l2ping_queue.push({ 541 | command: :l2ping, 542 | address: data[:address] 543 | }) 544 | 545 | query_history[data[:address]][:l2ping] = Time.now.to_i 546 | end 547 | end 548 | end 549 | 550 | # stop printing if we are at the max_height value. this is why 551 | # incrementing lines is important 552 | next if lines >= max_height 553 | 554 | # choose a color code for the row based on how recently its been 555 | # since initially detecting 556 | color = case 557 | when data[:created] > Time.now.to_i - 10 # in last 10 seconds 558 | "\e[#{hilight};32m" # green 559 | when data[:created] > Time.now.to_i - 30 # in last 30 seconds 560 | "\e[#{hilight};33m" # yellow 561 | when data[:last_seen] < (Time.now.to_i - cui_timeout + 20) # within 20 seconds expiring 562 | "\e[#{hilight};31m" # red 563 | else 564 | "\e[#{hilight}m" 565 | end 566 | 567 | # for each key determin if the data should be left or right 568 | # justified 569 | x = keys.map do |k| 570 | 571 | if data[k] 572 | if justifications[k] == :right 573 | data[k].to_s.rjust(max_lengths[k]) 574 | else 575 | v = data[k] 576 | if BlueHydra.demo_mode 577 | if k == :address 578 | mac_chars = "A-F0-9:" 579 | v = v.gsub( 580 | /^[#{mac_chars}]{5}|[#{mac_chars}]{5}$/, 581 | '**:**' 582 | ) 583 | end 584 | end 585 | v.to_s.ljust(max_lengths[k]) 586 | end 587 | else 588 | ''.ljust(max_lengths[k]) 589 | end 590 | end 591 | 592 | # join the data after justifying and add to the pbuff 593 | # 594 | # We did it! :D 595 | pbuff << "#{color}#{x.join(' | ')}\e[0m\n" 596 | lines += 1 597 | end 598 | else 599 | # when empty just tack on this line to the pbuff 600 | pbuff << "No recent devices..." 601 | end 602 | 603 | # print the entire pbuff to screen! ... phew 604 | puts pbuff 605 | 606 | # keys are returned back to the cui_loop so it can update its 607 | # pre-processing for sort etc 608 | return keys 609 | 610 | rescue => e 611 | BlueHydra.logger.error("CUI thread #{e.message}") 612 | e.backtrace.each do |x| 613 | BlueHydra.logger.error("#{x}") 614 | end 615 | BlueHydra::Pulse.send_event("blue_hydra", 616 | {key:'blue_hydra_cui_thread_error', 617 | title:'Blue Hydras CUI Thread Encountered An Error', 618 | message:"#{e.message}", 619 | severity:'ERROR' 620 | }) 621 | end 622 | end 623 | end 624 | end 625 | -------------------------------------------------------------------------------- /lib/blue_hydra/cli_user_interface_tracker.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module BlueHydra 4 | class CliUserInterfaceTracker 5 | attr_accessor :runner, :chunk, :attrs, :address, :uuid 6 | 7 | # This method initializes with a runner and some data and then handles 8 | # updating the cui_status to track the devices for the CUI 9 | # 10 | # == Parameters: 11 | # run :: 12 | # BlueHydra::Runner instance 13 | # attrs :: 14 | # record attributes to update with 15 | # addr :: 16 | # device addr 17 | def initialize(run, chnk, attrs, addr) 18 | @runner = run 19 | @chunk = chnk #todo, rm, deprecated 20 | @attrs = attrs 21 | @address = addr 22 | 23 | cui_k = cui_status.keys 24 | cui_v = cui_status.values 25 | 26 | match1 = cui_v.select{|x| 27 | x[:address] == @address 28 | }.first 29 | 30 | # if there is already a key with this address get the uuid from the 31 | # keys 32 | if match1 33 | @uuid = cui_k[cui_v.index(match1)] 34 | end 35 | 36 | # if we don't have uuid attempt a second match using le meta info 37 | unless @uuid 38 | @lpu = attrs[:le_proximity_uuid].first if attrs[:le_proximity_uuid] 39 | @lmn = attrs[:le_major_num].first if attrs[:le_major_num] 40 | @lmn2 = attrs[:le_minor_num].first if attrs[:le_minor_num] 41 | 42 | match2 = cui_v.select{|x| 43 | x[:le_proximity_uuid] && x[:le_proximity_uuid] == @lpu && 44 | x[:le_major_num] && x[:le_major_num] == @lmn && 45 | x[:le_minor_num] && x[:le_minor_num] == @lmn2 46 | }.first 47 | 48 | if match2 49 | @uuid = cui_k[cui_v.index(match2)] 50 | end 51 | end 52 | 53 | # if we don't have a uuid attempt a third match using company meta info 54 | unless @uuid 55 | @c = attrs[:company].first.split('(').first if attrs[:company] 56 | @d = attrs[:le_company_data].first if attrs[:le_company_data] 57 | 58 | match3 = cui_v.select{|x| 59 | x[:company] && x[:company] == @c && 60 | x[:le_company_data] && x[:le_company_data] == @d 61 | }.first 62 | 63 | if match3 64 | @uuid = cui_k[cui_v.index(match3)] 65 | end 66 | end 67 | 68 | # if still no uuid, generate a random one 69 | unless @uuid 70 | @uuid = SecureRandom.uuid 71 | end 72 | end 73 | 74 | # alias for cui status blob from the runner object 75 | def cui_status 76 | runner.cui_status 77 | end 78 | 79 | # update the cui_status in the runner 80 | def update_cui_status 81 | # initialize with a created timestampe or leave alone if uuid already exits 82 | cui_status[@uuid] ||= {created: Time.now.to_i} 83 | 84 | # update lap unless we have one 85 | cui_status[@uuid][:lap] = address.split(":")[3,3].join(":") unless cui_status[@uuid][:lap] 86 | 87 | # test to see if the data chunk is le or classic 88 | if chunk[0] && chunk[0][0] 89 | bt_mode = chunk[0][0] =~ /^\s+LE/ ? "le" : "classic" 90 | end 91 | 92 | # use lmp version to make a simplified copy of the version for table 93 | # display, set as :vers under the uuid key 94 | if bt_mode == "le" 95 | if attrs[:lmp_version] && attrs[:lmp_version].first !~ /0x(00|FF|ff)/ 96 | cui_status[@uuid][:vers] = "LE#{attrs[:lmp_version].first.split(" ")[1]}" 97 | elsif !cui_status[@uuid][:vers] 98 | cui_status[@uuid][:vers] = "BTLE" 99 | end 100 | else 101 | if attrs[:lmp_version] && attrs[:lmp_version].first !~ /0x(00|ff|FF)/ 102 | cui_status[@uuid][:vers] = "CL#{attrs[:lmp_version].first.split(" ")[1]}" 103 | elsif !cui_status[@uuid][:vers] 104 | cui_status[@uuid][:vers] = "CL/BR" 105 | end 106 | end 107 | 108 | # update the following attributes with a little of massaging to get the 109 | # attributes more presentable for human consumption 110 | [ 111 | :last_seen, :name, :address, :classic_rssi, :le_rssi, 112 | :le_proximity_uuid, :le_major_num, :le_minor_num, :ibeacon_range, 113 | :company, :le_company_data 114 | ].each do |key| 115 | if attrs[key] && attrs[key].first 116 | if cui_status[@uuid][key] != attrs[key].first 117 | if key == :le_rssi || key == :classic_rssi 118 | cui_status[@uuid][:rssi] = attrs[key].first[:rssi].gsub('dBm','') 119 | elsif key == :ibeacon_range 120 | cui_status[@uuid][:range] = "#{attrs[key].first}m" 121 | elsif key == :company 122 | cui_status[@uuid][:company] = attrs[key].first.split('(').first 123 | else 124 | cui_status[@uuid][key] = attrs[key].first 125 | end 126 | end 127 | end 128 | end 129 | 130 | # simplified copy of internal tracking uuid 131 | cui_status[@uuid][:uuid] = @uuid.split('-')[0] 132 | 133 | # if we have a short name set it as the name attribute 134 | if attrs[:short_name] 135 | unless attrs[:short_name] == [nil] || cui_status[@uuid][:name] 136 | cui_status[@uuid][:name] = attrs[:short_name].first 137 | #BlueHydra.logger.warn("short name found: #{attrs[:short_name]}") 138 | end 139 | end 140 | 141 | # set appearance 142 | if attrs[:appearance] 143 | cui_status[@uuid][:type] = attrs[:appearance].first.split('(').first 144 | end 145 | 146 | # set minor class or as uncategorized as appropriate 147 | if attrs[:classic_minor_class] 148 | if attrs[:classic_minor_class].first =~ /Uncategorized/i 149 | cui_status[@uuid][:type] = "Uncategorized" 150 | else 151 | cui_status[@uuid][:type] = attrs[:classic_minor_class].first.split('(').first 152 | end 153 | end 154 | 155 | # set :manuf key from a few different fields or Louis gem depending on a 156 | # few conditions, we are overloading this field so its populated 157 | if [nil, "Unknown"].include?(cui_status[@uuid][:manuf]) 158 | if bt_mode == "classic" || (attrs[:le_address_type] && attrs[:le_address_type].first =~ /public/i) 159 | vendor = Louis.lookup(address) 160 | 161 | cui_status[@uuid][:manuf] = if vendor["short_vendor"] 162 | vendor["short_vendor"] 163 | else 164 | vendor["long_vendor"] 165 | end 166 | else 167 | cmp = nil 168 | 169 | if attrs[:company_type] && attrs[:company_type].first !~ /unknown/i 170 | cmp = attrs[:company_type].first 171 | elsif attrs[:company] && attrs[:company].first !~ /not assigned/i 172 | cmp = attrs[:company].first 173 | elsif attrs[:manufacturer] && attrs[:manufacturer].first !~ /\(65535\)/ 174 | cmp = attrs[:manufacturer].first 175 | else 176 | cmp = "Unknown" 177 | end 178 | 179 | if cmp 180 | cui_status[@uuid][:manuf] = cmp.split('(').first 181 | end 182 | end 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/blue_hydra/command.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra::Command 2 | # execute a command using Open3 3 | # 4 | # == Parameters 5 | # command :: 6 | # the command to execute 7 | # 8 | # == Returns 9 | # Hash containing :stdout, :stderr, :exit_code from the command 10 | def execute3(command, timeout=false, timeout_signal="SIGKILL") 11 | begin 12 | BlueHydra.logger.debug("Executing Command: #{command}") 13 | output = {} 14 | if timeout 15 | stop_time = Time.now.to_i + timeout.to_i 16 | end 17 | 18 | stdin, stdout, stderr, thread = Open3.popen3(command) 19 | stdin.close 20 | 21 | if timeout 22 | until Time.now.to_i > stop_time || thread.status == false 23 | sleep 0.1 24 | end 25 | BlueHydra.logger.debug("Timeout on command: #{command}") 26 | 27 | begin 28 | Process.kill(timeout_signal, thread.pid) unless thread.status == false 29 | rescue Errno::ESRCH 30 | BlueHydra.logger.warn("Command: #{command} exited unnaturally.") 31 | BlueHydra::Pulse.send_event("blue_hydra", 32 | {key:'blue_hydra_command_error', 33 | title:'Blue Hydra subprocess exited unnaturally', 34 | message:"Command: #{command} exited unnaturally.", 35 | severity:'WARN' 36 | }) 37 | end 38 | end 39 | 40 | if (out = stdout.read.chomp) != "" 41 | output[:stdout] = out 42 | end 43 | 44 | if (err = stderr.read.chomp) != "" 45 | output[:stderr] = err 46 | end 47 | 48 | output[:exit_code] = thread.value.exitstatus 49 | 50 | output 51 | rescue Errno::ENOMEM, NoMemoryError 52 | BlueHydra.logger.fatal("System couldn't allocate enough memory to run an external command.") 53 | BlueHydra::Pulse.send_event('blue_hydra', 54 | { 55 | key: "bluehydra_oom", 56 | title: "BlueHydra couldnt allocate enough memory to run external command. Sensor OOM.", 57 | message: "BlueHydra couldnt allocate enough memory to run external command. Sensor OOM.", 58 | severity: "FATAL" 59 | }) 60 | exit 1 61 | end 62 | end 63 | 64 | module_function :execute3 65 | end 66 | -------------------------------------------------------------------------------- /lib/blue_hydra/device.rb: -------------------------------------------------------------------------------- 1 | # this is the bluetooth Device model stored in the DB 2 | class BlueHydra::Device 3 | 4 | attr_accessor :filthy_attributes 5 | 6 | # this is a DataMapper model... 7 | include DataMapper::Resource 8 | 9 | # Attributes for the DB 10 | property :id, Serial 11 | 12 | # TODO: migrate this column to be called sync_id 13 | property :uuid, String 14 | 15 | property :name, String 16 | property :status, String 17 | property :address, String 18 | property :uap_lap, String 19 | 20 | property :vendor, Text 21 | property :appearance, String 22 | property :company, String 23 | property :company_type, String 24 | property :lmp_version, String 25 | property :manufacturer, String 26 | property :firmware, String 27 | 28 | # classic mode specific attributes 29 | property :classic_mode, Boolean, default: false 30 | property :classic_service_uuids, Text 31 | property :classic_channels, Text 32 | property :classic_major_class, String 33 | property :classic_minor_class, String 34 | property :classic_class, Text 35 | property :classic_rssi, Text 36 | property :classic_tx_power, Text 37 | property :classic_features, Text 38 | property :classic_features_bitmap, Text 39 | 40 | # low energy mode specific attributes 41 | property :le_mode, Boolean, default: false 42 | property :le_service_uuids, Text 43 | property :le_address_type, String 44 | property :le_random_address_type, String 45 | property :le_company_data, String, :length => 255 46 | property :le_company_uuid, String 47 | property :le_proximity_uuid, String 48 | property :le_major_num, String 49 | property :le_minor_num, String 50 | property :le_flags, Text 51 | property :le_rssi, Text 52 | property :le_tx_power, Text 53 | property :le_features, Text 54 | property :le_features_bitmap, Text 55 | property :ibeacon_range, String 56 | 57 | property :created_at, DateTime 58 | property :updated_at, DateTime 59 | property :last_seen, Integer 60 | 61 | # regex to validate macs 62 | MAC_REGEX = /^((?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2})$/i 63 | 64 | # validate the address. the only validation currently 65 | validates_format_of :address, with: MAC_REGEX 66 | 67 | # before saving set the vendor info and the mode flags (le/classic) 68 | before :save, :set_vendor 69 | before :save, :set_uap_lap 70 | before :save, :set_uuid 71 | before :save, :prepare_the_filth 72 | 73 | # after saving send up to pulse 74 | after :save, :sync_to_pulse 75 | 76 | # 1 week in seconds == 7 * 24 * 60 * 60 == 604800 77 | def self.sync_all_to_pulse(since=Time.at(Time.now.to_i - 604800)) 78 | BlueHydra::Device.all(:updated_at.gte => since).each do |dev| 79 | dev.sync_to_pulse(true) 80 | end 81 | end 82 | 83 | # mark hosts as 'offline' if we haven't seen for a while 84 | def self.mark_old_devices_offline(startup=false) 85 | if startup 86 | # efficiently kill old things with fire 87 | if DataMapper.repository.adapter.select("select uuid from blue_hydra_devices where updated_at between \"1970-01-01\" AND \"#{Time.at(Time.now.to_i-1209600).to_s.split(" ")[0]}\" limit 5000;").count == 5000 88 | DataMapper.repository.adapter.select("delete from blue_hydra_devices where updated_at between \"1970-01-01\" AND \"#{Time.at(Time.now.to_i-1209600).to_s.split(" ")[0]}\" ;") 89 | BlueHydra::Pulse.hard_reset 90 | end 91 | 92 | # unknown mode devices have 15 min timeout (SHOULD NOT EXIST, BUT WILL CLEAN 93 | # OLD DBS) 94 | BlueHydra::Device.all( 95 | le_mode: false, 96 | classic_mode: false, 97 | status: "online" 98 | ).select{|x| 99 | x.last_seen < (Time.now.to_i - (15*60)) 100 | }.each{|device| 101 | device.status = 'offline' 102 | device.save 103 | } 104 | end 105 | 106 | # Kill old things with fire 107 | BlueHydra::Device.all(:updated_at.lte => Time.at(Time.now.to_i - 604800*2)).each do |dev| 108 | dev.status = 'offline' 109 | dev.sync_to_pulse(true) 110 | BlueHydra.logger.debug("Destroying #{dev.address} #{dev.uuid}") 111 | dev.destroy 112 | end 113 | 114 | # classic mode devices have 15 min timeout 115 | BlueHydra::Device.all(classic_mode: true, status: "online").select{|x| 116 | x.last_seen < (Time.now.to_i - (15*60)) 117 | }.each{|device| 118 | device.status = 'offline' 119 | device.save 120 | } 121 | 122 | # le mode devices have 3 min timeout 123 | BlueHydra::Device.all(le_mode: true, status: "online").select{|x| 124 | x.last_seen < (Time.now.to_i - (60*3)) 125 | }.each{|device| 126 | device.status = 'offline' 127 | device.save 128 | } 129 | end 130 | 131 | # this class method is take a result Hash and convert it into a new or update 132 | # an existing record 133 | # 134 | # == Parameters : 135 | # result :: 136 | # Hash of results from parser 137 | def self.update_or_create_from_result(result) 138 | 139 | result = result.dup 140 | 141 | address = result[:address].first 142 | 143 | lpu = result[:le_proximity_uuid].first if result[:le_proximity_uuid] 144 | lmn = result[:le_major_num].first if result[:le_major_num] 145 | lmn2 = result[:le_minor_num].first if result[:le_minor_num] 146 | 147 | c = result[:company].first if result[:company] 148 | d = result[:le_company_data].first if result[:le_company_data] 149 | 150 | record = self.all(address: address).first || 151 | self.find_by_uap_lap(address) || 152 | (lpu && lmn && lmn2 && self.all( 153 | le_proximity_uuid: lpu, 154 | le_major_num: lmn, 155 | le_minor_num: lmn2 156 | ).first) || 157 | (c && d && c =~ /Gimbal/i && self.all( 158 | le_company_data: d 159 | ).first) || 160 | self.new 161 | 162 | # if we are processing things here we have, implicitly seen them so 163 | # mark as online? 164 | record.status = "online" 165 | 166 | # set last_seen or default value if missing 167 | if result[:last_seen] && 168 | result[:last_seen].class == Array && 169 | !result[:last_seen].empty? 170 | record.last_seen = result[:last_seen].sort.last # latest value 171 | else 172 | record.last_seen = Time.now.to_i 173 | end 174 | 175 | # update normal attributes 176 | %w{ 177 | address name manufacturer short_name lmp_version firmware 178 | classic_major_class classic_minor_class le_tx_power classic_tx_power 179 | le_address_type company appearance 180 | le_random_address_type le_company_uuid le_company_data le_proximity_uuid 181 | le_major_num le_minor_num classic_mode le_mode 182 | }.map(&:to_sym).each do |attr| 183 | if result[attr] 184 | # we should only get a single value for these so we need to warn if 185 | # we are getting multiple values for these keys.. it should NOT be... 186 | if result[attr].uniq.count > 1 187 | BlueHydra.logger.debug( 188 | "#{address} multiple values detected for #{attr}: #{result[attr].inspect}. Using first value..." 189 | ) 190 | end 191 | record.send("#{attr.to_s}=", result.delete(attr).uniq.sort.first) 192 | end 193 | end 194 | 195 | # this is probably a band-aie, likely devices have multiple company type elements 196 | #update flappy company_type 197 | if result[:company_type] 198 | data = result.delete(:company_type).uniq.sort.first 199 | if data =~ /Unknown/ 200 | data = "Unknown" 201 | record.send("#{:company_type}=", data) 202 | end 203 | end 204 | 205 | # update array attributes 206 | %w{ 207 | classic_features le_features le_flags classic_channels classic_class le_rssi 208 | classic_rssi le_service_uuids classic_service_uuids le_features_bitmap classic_features_bitmap 209 | }.map(&:to_sym).each do |attr| 210 | if result[attr] 211 | record.send("#{attr.to_s}=", result.delete(attr)) 212 | end 213 | end 214 | 215 | if record.valid? 216 | record.save 217 | if self.all(uap_lap: record.uap_lap).count > 1 218 | BlueHydra.logger.warn("Duplicate UAP/LAP detected: #{record.uap_lap}.") 219 | end 220 | else 221 | BlueHydra.logger.warn("#{address} can not save.") 222 | record.errors.keys.each do |key| 223 | BlueHydra.logger.warn("#{key.to_s}: #{record.errors[key].inspect} (#{record[key]})") 224 | end 225 | end 226 | 227 | record 228 | end 229 | 230 | # look up the vendor for the address in the Louis gem 231 | # and set it 232 | def set_vendor(force=false) 233 | if self.le_address_type == "Random" 234 | self.vendor = "N/A - Random Address" 235 | else 236 | if self.vendor == nil || self.vendor == "Unknown" || force 237 | vendor = Louis.lookup(address) 238 | self.vendor = vendor["long_vendor"] ? vendor["long_vendor"] : vendor["short_vendor"] 239 | end 240 | end 241 | end 242 | 243 | # set a sync id as a UUID 244 | def set_uuid 245 | unless self.uuid 246 | new_uuid = SecureRandom.uuid 247 | 248 | until BlueHydra::Device.all(uuid: new_uuid).count == 0 249 | new_uuid = SecureRandom.uuid 250 | end 251 | 252 | self.uuid = new_uuid 253 | end 254 | end 255 | 256 | 257 | # set the last 4 octets of the mac as the uap_lap values 258 | # 259 | # These values are from mac addresses for bt devices as follows 260 | # 261 | # |NAP |UAP |LAP 262 | # DE : AD : BE : EF : CA : FE 263 | def set_uap_lap 264 | self[:uap_lap] = self.address.split(":")[2,4].join(":") 265 | end 266 | 267 | # lookup helper method for uap_lap 268 | def self.find_by_uap_lap(address) 269 | uap_lap = address.split(":")[2,4].join(":") 270 | self.all(uap_lap: uap_lap).first 271 | end 272 | 273 | def syncable_attributes 274 | [ 275 | :name, :vendor, :appearance, :company, :le_company_data, :company_type, 276 | :lmp_version, :manufacturer, :le_features_bitmap, :firmware, 277 | :classic_mode, :classic_features_bitmap, :classic_major_class, 278 | :classic_minor_class, :le_mode, :le_address_type, 279 | :le_random_address_type, :le_tx_power, :last_seen, :classic_tx_power, 280 | :le_features, :classic_features, :le_service_uuids, 281 | :classic_service_uuids, :classic_channels, :classic_class, :classic_rssi, 282 | :le_flags, :le_rssi, :le_company_uuid 283 | ] 284 | end 285 | 286 | def is_serialized?(attr) 287 | [ 288 | :classic_channels, 289 | :classic_class, 290 | :classic_features, 291 | :le_features, 292 | :le_flags, 293 | :le_service_uuids, 294 | :classic_service_uuids, 295 | :classic_rssi, 296 | :le_rssi 297 | ].include?(attr) 298 | end 299 | 300 | 301 | # This is a helper method to track what attributes change because all 302 | # attributes lose their 'dirty' status after save and the sync method is an 303 | # after save so we need to keep a record of what changed to only sync relevant 304 | def prepare_the_filth 305 | @filthy_attributes ||= [] 306 | syncable_attributes.each do |attr| 307 | @filthy_attributes << attr if self.attribute_dirty?(attr) 308 | end 309 | end 310 | 311 | # sync record to pulse 312 | def sync_to_pulse(sync_all=false) 313 | if BlueHydra.pulse || BlueHydra.pulse_debug 314 | 315 | send_data = { 316 | type: "bluetooth", 317 | source: "blue-hydra", 318 | version: BlueHydra::VERSION, 319 | data: {} 320 | } 321 | 322 | # always include uuid, address, status 323 | send_data[:data][:sync_id] = self.uuid 324 | send_data[:data][:status] = self.status 325 | send_data[:data][:sync_version] = BlueHydra::SYNC_VERSION 326 | 327 | if self.le_proximity_uuid 328 | send_data[:data][:le_proximity_uuid] = self.le_proximity_uuid 329 | end 330 | 331 | if self.le_major_num 332 | send_data[:data][:le_major_num] = self.le_major_num 333 | end 334 | 335 | if self.le_minor_num 336 | send_data[:data][:le_minor_num] = self.le_minor_num 337 | end 338 | 339 | # always include both of these if they are both set, otherwise they will 340 | # be set as part of syncable_attributes below 341 | if self.le_company_data && self.company 342 | send_data[:data][:le_company_data] = self.le_company_data 343 | send_data[:data][:company] = self.company 344 | end 345 | 346 | 347 | # TODO once pulse is using uuid to lookup records we can move 348 | # address into the syncable_attributes list and only include it if 349 | # changes, unless of course we want to handle the case where the db gets 350 | # reset and we have to resync hosts based on address alone or something 351 | # but, like, that'll never happen right? 352 | # 353 | # XXX for cases like Gimbal the only thing that prevents us from sending 60 354 | # address updates a minute is the fact that address is *not* in syncable attributes 355 | # and it only gets sent when something else changes (like rssi). 356 | # This was originally unintentional but it's really saving out bacon, don't change this for now 357 | send_data[:data][:address] = self.address 358 | 359 | @filthy_attributes ||= [] 360 | 361 | syncable_attributes.each do |attr| 362 | # ignore nil value attributes 363 | if @filthy_attributes.include?(attr) || sync_all 364 | val = self.send(attr) 365 | unless [nil, "[]"].include?(val) 366 | if is_serialized?(attr) 367 | send_data[:data][attr] = JSON.parse(val) 368 | else 369 | send_data[:data][attr] = val 370 | end 371 | end 372 | end 373 | end 374 | 375 | # create the json 376 | json_msg = JSON.generate(send_data) 377 | # send the json 378 | BlueHydra::Pulse.do_send(json_msg) 379 | end 380 | end 381 | 382 | # set the :name attribute from the :short_name key only if name is not already 383 | # set 384 | # 385 | # == Parameters 386 | # new :: 387 | # new short name value 388 | def short_name=(new) 389 | unless ["",nil].include?(new) || self.name 390 | self.name = new 391 | end 392 | end 393 | 394 | # set the :classic_channels attribute by merging with previously seen values 395 | # 396 | # == Parameters 397 | # channels :: 398 | # new channels 399 | def classic_channels=(channels) 400 | new = channels.map{|x| x.split(", ").reject{|x| x =~ /^0x/}}.flatten.sort.uniq 401 | current = JSON.parse(self.classic_class || '[]') 402 | self[:classic_channels] = JSON.generate((new + current).uniq) 403 | end 404 | 405 | # set the :classic_class attribute by merging with previously seen values 406 | # 407 | # == Parameters 408 | # new_classes :: 409 | # new classes 410 | def classic_class=(new_classes) 411 | new = new_classes.flatten.uniq.reject{|x| x =~ /^0x/} 412 | current = JSON.parse(self.classic_class || '[]') 413 | self[:classic_class] = JSON.generate((new + current).uniq) 414 | end 415 | 416 | # set the :classic_features attribute by merging with previously seen values 417 | # 418 | # == Parameters 419 | # new_features :: 420 | # new features 421 | def classic_features=(new_features) 422 | new = new_features.map{|x| x.split(", ").reject{|x| x =~ /^0x/}}.flatten.sort.uniq 423 | current = JSON.parse(self.classic_features || '[]') 424 | self[:classic_features] = JSON.generate((new + current).uniq) 425 | end 426 | 427 | # set the :le_features attribute by merging with previously seen values 428 | # 429 | # == Parameters 430 | # new_features :: 431 | # new features 432 | def le_features=(new_features) 433 | new = new_features.map{|x| x.split(", ").reject{|x| x =~ /^0x/}}.flatten.sort.uniq 434 | current = JSON.parse(self.le_features || '[]') 435 | self[:le_features] = JSON.generate((new + current).uniq) 436 | end 437 | 438 | # set the :le_flags attribute by merging with previously seen values 439 | # 440 | # == Parameters 441 | # new_flags :: 442 | # new flags 443 | def le_flags=(flags) 444 | new = flags.map{|x| x.split(", ").reject{|x| x =~ /^0x/}}.flatten.sort.uniq 445 | current = JSON.parse(self.le_flags || '[]') 446 | self[:le_flags] = JSON.generate((new + current).uniq) 447 | end 448 | 449 | # set the :le_service_uuids attribute by merging with previously seen values 450 | # 451 | # == Parameters 452 | # new_uuids :: 453 | # new uuids 454 | def le_service_uuids=(new_uuids) 455 | current = JSON.parse(self.le_service_uuids || '[]') 456 | 457 | #first we fix our old data if needed 458 | current_fixed = current.map do |x| 459 | if x.split(':')[1] 460 | #example x "(UUID 0xfe9f): 0000000000000000000000000000000000000000" 461 | # this split/scan handles removing the service data we used to capture and normalizing it to just show uuid 462 | x.split(':')[0].scan(/\(([^)]+)\)/).flatten[0].split('UUID ')[1] 463 | else 464 | x 465 | end 466 | end 467 | 468 | new = (new_uuids + current_fixed) 469 | 470 | new.map! do |uuid| 471 | if uuid =~ /\(/ 472 | uuid 473 | else 474 | "Unknown (#{ uuid })" 475 | end 476 | end 477 | 478 | self[:le_service_uuids] = JSON.generate(new.uniq) 479 | end 480 | 481 | # set the :cassic_service_uuids attribute by merging with previously seen values 482 | # 483 | # Wrap some uuids in Unknown(uuid) as needed 484 | # 485 | # == Parameters 486 | # new_uuids :: 487 | # new uuids 488 | def classic_service_uuids=(new_uuids) 489 | current = JSON.parse(self.classic_service_uuids || '[]') 490 | new = (new_uuids + current) 491 | 492 | new.map! do |uuid| 493 | if uuid =~ /\(/ 494 | uuid 495 | else 496 | "Unknown (#{ uuid })" 497 | end 498 | end 499 | 500 | self[:classic_service_uuids] = JSON.generate(new.uniq) 501 | end 502 | 503 | 504 | # set the :classic_rss attribute by merging with previously seen values 505 | # 506 | # limit to last 100 rssis 507 | # 508 | # == Parameters 509 | # rssis :: 510 | # new rssis 511 | def classic_rssi=(rssis) 512 | current = JSON.parse(self.classic_rssi || '[]') 513 | new = current + rssis 514 | 515 | until new.count <= 100 516 | new.shift 517 | end 518 | 519 | self[:classic_rssi] = JSON.generate(new) 520 | end 521 | 522 | # set the :le_rssi attribute by merging with previously seen values 523 | # 524 | # limit to last 100 rssis 525 | # 526 | # == Parameters 527 | # rssis :: 528 | # new rssis 529 | def le_rssi=(rssis) 530 | current = JSON.parse(self.le_rssi || '[]') 531 | new = current + rssis 532 | 533 | until new.count <= 100 534 | new.shift 535 | end 536 | 537 | self[:le_rssi] = JSON.generate(new) 538 | end 539 | 540 | # set the :le_address_type carefully , may also result in the 541 | # le_random_address_type being nil'd out if the type value is "public" 542 | # 543 | # == Parameters 544 | # type :: 545 | # new type to set 546 | def le_address_type=(type) 547 | type = type.split(' ')[0] 548 | if type =~ /Public/ 549 | self[:le_address_type] = type 550 | self[:le_random_address_type] = nil if self.le_address_type 551 | elsif type =~ /Random/ 552 | self[:le_address_type] = type 553 | end 554 | end 555 | 556 | # set the :le_random_address_type unless the le_address_type is set 557 | # 558 | # == Parameters 559 | # type :: 560 | # new type to set 561 | def le_random_address_type=(type) 562 | unless le_address_type && le_address_type =~ /Public/ 563 | self[:le_random_address_type] = type 564 | end 565 | end 566 | 567 | # set the addres field but only conditionally set vendor based on some whether 568 | # or not we have an appropriate address to use for vendor lookup. Don't do 569 | # vendor lookups if address starts with 00:00 570 | def address=(new) 571 | if new 572 | current = self.address 573 | 574 | self[:address] = new 575 | 576 | if current =~ /^00:00/ || new !~ /^00:00/ 577 | set_vendor(true) 578 | end 579 | end 580 | end 581 | 582 | def le_features_bitmap=(arr) 583 | current = JSON.parse(self.le_features_bitmap||'{}') 584 | arr.each do |(page, bitmap)| 585 | current[page] = bitmap 586 | end 587 | self[:le_features_bitmap] = JSON.generate(current) 588 | end 589 | 590 | def classic_features_bitmap=(arr) 591 | current = JSON.parse(self.classic_features_bitmap||'{}') 592 | arr.each do |(page, bitmap)| 593 | current[page] = bitmap 594 | end 595 | self[:classic_features_bitmap] = JSON.generate(current) 596 | end 597 | end 598 | -------------------------------------------------------------------------------- /lib/blue_hydra/parser.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | 3 | # class responsible for parsing a group of message chunks into a serialized 4 | # hash appropriate to generate or update a device record. 5 | class Parser 6 | BLEEE_PACKET_TYPES = { 7 | '0b': 'watch_c', 8 | '0c': 'handoff', 9 | '0d': 'wifi_set', 10 | '0e': 'hotspot', 11 | '0f': 'wifi_join', 12 | '10': 'nearby', 13 | '07': 'airpods', 14 | '05': 'airdrop' 15 | }.freeze 16 | 17 | attr_accessor :attributes 18 | 19 | # initializer which takes an Array of chunks to be parsed 20 | # 21 | # == Parameters : 22 | # chunks :: 23 | # Array of message chunks which are arrays of lines of btmon output 24 | def initialize(chunks=[]) 25 | @chunks = chunks 26 | @attributes = {} 27 | 28 | # the first chunk will determine the mode (le/classic) these message 29 | # fall into. This mode will be used to differentiate between setting 30 | # le or classic attributes during the parsing of this batch of chunks 31 | if @chunks[0] && @chunks[0][1] 32 | @bt_mode = @chunks[0][1] =~ /^\s+LE/ ? "le" : "classic" 33 | end 34 | end 35 | 36 | # this ithe main work method which processes the @chunks Array 37 | # and populates the @attributes 38 | def parse 39 | @chunks.each do |chunk| 40 | 41 | # the first line is no longer useful as we have extracted the mode and 42 | # timestamp at other points in the pipeline. Time to discard it 43 | chunk.shift 44 | 45 | # the last message will always be a timestamp from the chunker, this 46 | # value is used throughout during this parsing process but should 47 | # also be set to last_seen 48 | timestamp = chunk.pop 49 | set_attr(:last_seen, timestamp.split(': ')[1].to_i) 50 | 51 | if @bt_mode == "le" 52 | set_attr(:le_mode, true) 53 | elsif @bt_mode == "classic" 54 | set_attr(:classic_mode, true) 55 | end 56 | 57 | # group the chunk of lines into nested / related groups of data 58 | # containing 1 or more lines 59 | grouped_chunk = group_by_depth(chunk) 60 | 61 | # handle each chunk of grouped data individually 62 | handle_grouped_chunk(grouped_chunk, @bt_mode, timestamp) 63 | end 64 | end 65 | 66 | # The main parser case statement to handle grouped message data from a 67 | # given chunk 68 | # 69 | # == Parameters 70 | # grouped_chunk :: 71 | # Array of lines to be processed 72 | # bt_mode :: 73 | # String of "le" or "classic" 74 | # timestamp :: 75 | # Unix timestamp for when this message data was created 76 | def handle_grouped_chunk(grouped_chunk, bt_mode, timestamp) 77 | tx_power = nil 78 | grouped_chunk.each do |grp| 79 | 80 | # when we only have a single line in a group we can handle simply 81 | if grp.count == 1 82 | line = grp[0] 83 | 84 | # next line was not nested, treat as single line 85 | parse_single_line(line, bt_mode, timestamp, tx_power) 86 | 87 | # if we have multiple lines in our group of lines determine how to 88 | # process and set 89 | else 90 | case 91 | 92 | # these special messags had effectively duplicate header lines 93 | # which is be shifted off and then re-grouped 94 | when grp[0] =~ /^\s+(LE|ATT|L2CAP)/ 95 | grp.shift 96 | grp = group_by_depth(grp) 97 | grp.each do |entry| 98 | if entry.count == 1 99 | line = entry[0] 100 | parse_single_line(line, bt_mode, timestamp, tx_power) 101 | else 102 | handle_grouped_chunk(grp, bt_mode, timestamp) 103 | end 104 | end 105 | 106 | # Attribute type: Primary Service (0x2800) 107 | # UUID: Unknown (7905f431-b5ce-4e99-a40f-4b1e122d00d0) 108 | when grp[0] =~ /^\s+Attribute type: Primary Service/ 109 | vals = grp.map(&:strip) 110 | uuid = vals.select{|x| x =~ /^UUID/}[0] 111 | set_attr("#{bt_mode}_service_uuids".to_sym, uuid.split(': ')[1]) 112 | 113 | when grp[0] =~ /^\s+Flags:/ 114 | grp.shift 115 | vals = grp.map(&:strip) 116 | set_attr("#{bt_mode}_flags".to_sym, vals.join(", ")) 117 | 118 | 119 | # Page: 1/1 120 | # Features: 0x07 0x00 0x00 0x00 0x00 0x00 0x00 0x00 121 | # Secure Simple Pairing (Host Support) 122 | # LE Supported (Host) 123 | # Simultaneous LE and BR/EDR (Host) 124 | when grp[0] =~ /^\s+Page/ 125 | page = grp.shift.split(':')[1].strip.split('/')[0] 126 | bitmap = grp.shift.split(':')[1].strip 127 | vals = grp.map(&:strip) 128 | set_attr("#{bt_mode}_features_bitmap".to_sym, [page, bitmap]) 129 | set_attr("#{bt_mode}_features".to_sym, vals.join(", ")) 130 | 131 | # Features: 0x07 0x00 0x00 0x00 0x00 0x00 0x00 0x00 132 | # Secure Simple Pairing (Host Support) 133 | # LE Supported (Host) 134 | # Simultaneous LE and BR/EDR (Host) 135 | when grp[0] =~ /^\s+Features/ 136 | bitmap = grp.shift.split(':')[1].strip 137 | vals = grp.map(&:strip) 138 | 139 | # default page value is here set to '0' 140 | set_attr("#{bt_mode}_features_bitmap".to_sym, ['0',bitmap]) 141 | set_attr("#{bt_mode}_features".to_sym, vals.join(", ")) 142 | 143 | when grp[0] =~ /^\s+Channels/ 144 | header = grp.shift.split(':')[1].strip 145 | vals = grp.map(&:strip) 146 | vals.unshift(header) 147 | set_attr("#{bt_mode}_channels".to_sym, vals.join(", ")) 148 | 149 | # not in spec fixtures... 150 | # " 128-bit Service UUIDs (complete): 2 entries\r\n", 151 | # " 00000000-deca-fade-deca-deafdecacafe\r\n", 152 | # " 2d8d2466-e14d-451c-88bc-7301abea291a\r\n", 153 | when grp[0] =~ /128-bit Service UUIDs \((complete|partial)\):/ 154 | grp.shift # header line 155 | vals = grp.map(&:strip) 156 | vals.each do |uuid| 157 | set_attr("#{bt_mode}_service_uuids".to_sym, uuid) 158 | end 159 | 160 | # Company: Apple, Inc. (76) 161 | # Type: iBeacon (2) 162 | # UUID: 7988f2b6-dc41-1291-8746-ecf83cc7a06c 163 | # Version: 15104.61591 164 | # TX power: -56 dB 165 | # Data: 01adddd439aed386c76574e9ab9e11958e25c1f70ae203 166 | 167 | when grp[0] =~ /Company:/ 168 | vals = grp.map(&:strip) 169 | 170 | #hack because datamapper doesn't respect varchar255 setting 171 | company_tmp = vals.shift.split(': ')[1] 172 | company_hex = company_tmp.scan(/\(([^)]+)\)/).flatten[0].to_i.to_s(16) 173 | if company_tmp.length > 49 && company_tmp.scan(/\(/).count == 2 174 | company_tmp = company_tmp.split('(') 175 | company_tmp.delete_at(1) 176 | company_tmp = company_tmp.join('(') 177 | if company_tmp.length > 49 178 | BlueHydra.logger.warn("Attempted to handle long company and still too long:") 179 | BlueHydra.logger.warn("company_tmp: #{company_tmp}") 180 | BlueHydra.logger.warn("Truncating company...") 181 | company_tmp = company_tmp[0,49] 182 | end 183 | end 184 | if company_tmp.length > 49 185 | BlueHydra.logger.warn("Did not attempt to handle long company and still too long:") 186 | BlueHydra.logger.warn("company_tmp: #{company_tmp}") 187 | BlueHydra.logger.warn("Truncating company...") 188 | company_tmp = company_tmp[0,49] 189 | end 190 | 191 | set_attr(:company, company_tmp) 192 | 193 | # Company can also contain multiple types.... 194 | # so we need to reset the parsing on every Type line 195 | 196 | # Company: Apple, Inc. (76) 197 | # Type: Unknown (12) 198 | # Data: 00188218be794011f7678726540b 199 | # Type: Unknown (16) 200 | # Data: 1b1ca2bea2 201 | 202 | company_type = nil 203 | company_type_last_set = nil 204 | vals.each do |company_line| 205 | case 206 | when company_line =~ /^Type:/ 207 | company_type = company_line.split(': ')[1] 208 | company_type_hex = company_type.scan(/\(([^)]+)\)/).flatten[0].to_i.to_s(16) 209 | company_type_last_set = timestamp.split(': ')[1].to_f 210 | set_attr(:company_type, company_type) 211 | flipped_prox_uuid = nil 212 | major = nil 213 | minor = nil 214 | when company_line =~ /^UUID:/ 215 | if company_type && company_type =~ /\(2\)/ && company_type_last_set && company_type_last_set == timestamp.split(': ')[1].to_f 216 | flipped_prox_uuid = company_line.split(': ')[1].gsub('-','').scan(/.{2}/).reverse.join.scan(/(.{8})(.{4})(.{4})(.*)/).join('-') 217 | set_attr("#{bt_mode}_proximity_uuid".to_sym, flipped_prox_uuid) 218 | else 219 | set_attr("#{bt_mode}_company_uuid".to_sym, company_line.split(': ')[1]) 220 | end 221 | when company_line =~/^Version:/ 222 | if company_type && company_type =~ /\(2\)/ && company_type_last_set && company_type_last_set == timestamp.split(': ')[1].to_f 223 | #bluez decodes this as little endian but it's actually big so we have to reverse it 224 | major = company_line.split(': ')[1].split('.')[0].to_i.to_s(16).rjust(4, '0').scan(/.{2}/).map { |i| i.to_i(16).chr }.join.unpack('S<*').first 225 | minor = company_line.split(': ')[1].split('.')[1].to_i.to_s(16).rjust(4, '0').scan(/.{2}/).map { |i| i.to_i(16).chr }.join.unpack('S<*').first 226 | set_attr("#{bt_mode}_major_num".to_sym, major) 227 | set_attr("#{bt_mode}_minor_num".to_sym, minor) 228 | else 229 | set_attr("#{bt_mode}_company_version".to_sym, company_line.split(': ')[1]) 230 | end 231 | when company_line =~ /^TX power:/ 232 | tx_power = company_line.split(': ')[1] 233 | set_attr("#{bt_mode}_tx_power".to_sym, tx_power) 234 | when company_line =~ /^Data:/ 235 | set_attr("#{bt_mode}_company_data".to_sym, company_line.split(': ')[1]) 236 | end 237 | end 238 | 239 | # not in spec fixtures... 240 | # " 16-bit Service UUIDs (complete): 7 entries\r\n", 241 | # " PnP Information (0x1200)\r\n", 242 | # " Handsfree Audio Gateway (0x111f)\r\n", 243 | # " Phonebook Access Server (0x112f)\r\n", 244 | # " Audio Source (0x110a)\r\n", 245 | # " A/V Remote Control Target (0x110c)\r\n", 246 | # " NAP (0x1116)\r\n", 247 | # " Message Access Server (0x1132)\r\n", 248 | when grp[0] =~ /16-bit Service UUIDs \(complete\):/ 249 | grp.shift # header line 250 | vals = grp.map(&:strip) 251 | vals.each do |uuid| 252 | set_attr("#{bt_mode}_uuids".to_sym, uuid) 253 | end 254 | 255 | # not in spec fixtures... 256 | # " Class: 0x7a020c\r\n", 257 | # " Major class: Phone (cellular, cordless, payphone, modem)\r\n", 258 | # " Minor class: Smart phone\r\n", 259 | # " Networking (LAN, Ad hoc)\r\n", 260 | # " Capturing (Scanner, Microphone)\r\n", 261 | # " Object Transfer (v-Inbox, v-Folder)\r\n", 262 | # " Audio (Speaker, Microphone, Headset)\r\n", 263 | # " Telephony (Cordless telephony, Modem, Headset)\r\n", 264 | when grp[0] =~ /Class:/ 265 | grp = grp.map(&:strip) 266 | vals = [] 267 | 268 | grp.each do |line| 269 | case 270 | when line =~ /^Class:/ 271 | vals << line.split(':')[1].strip 272 | when line =~ /^Major class:/ 273 | set_attr("#{bt_mode}_major_class".to_sym, line.split(':')[1].strip) 274 | when line =~ /^Minor class:/ 275 | set_attr("#{bt_mode}_minor_class".to_sym, line.split(':')[1].strip) 276 | else 277 | vals << line 278 | end 279 | end 280 | 281 | set_attr("#{bt_mode}_class".to_sym, vals) unless vals.empty? 282 | 283 | when grp[0] =~ /^\s+Manufacturer/ 284 | grp.map do |line| 285 | parse_single_line(line, bt_mode, timestamp) 286 | end 287 | 288 | else 289 | set_attr("#{bt_mode}_unknown".to_sym, grp.inspect) 290 | end 291 | end 292 | end 293 | end 294 | 295 | # Determine the depth of the whitespace characters in a line 296 | # 297 | # == Parameters 298 | # line :: 299 | # the line to test] 300 | # == Returns 301 | # Integer value for number of whitespace chars 302 | def line_depth(line) 303 | whitespace = line.scan(/^([\s]+)/).flatten.first 304 | if whitespace 305 | whitespace.length 306 | else 307 | 0 308 | end 309 | end 310 | 311 | def parse_single_line(line, bt_mode, timestamp, tx_power=nil) 312 | line = line.strip 313 | case 314 | 315 | # TODO make use of handle 316 | when line =~ /^Handle:/ 317 | set_attr("#{bt_mode}_handle".to_sym, line.split(': ')[1]) 318 | 319 | when line =~ /^Address:/ || line =~ /^Peer address:/ || line =~ /^LE Address:/ 320 | addr, *addr_type = line.split(': ')[1].split(" ") 321 | set_attr("address".to_sym, addr) 322 | 323 | if bt_mode == "le" 324 | set_attr("le_random_address_type".to_sym, addr_type.join(' ')) 325 | end 326 | 327 | when line =~ /^LMP version:/ 328 | set_attr("lmp_version".to_sym, line.split(': ')[1]) 329 | 330 | when line =~ /^Manufacturer:/ 331 | set_attr("manufacturer".to_sym, line.split(': ')[1]) 332 | 333 | when line =~ /^UUID:/ 334 | set_attr("#{bt_mode}_service_uuids".to_sym, line.split(': ')[1]) 335 | 336 | when line =~ /^Address type:/ 337 | set_attr("#{bt_mode}_address_type".to_sym, line.split(': ')[1]) 338 | 339 | when line =~ /^TX power:/ 340 | set_attr("#{bt_mode}_tx_power".to_sym, line.split(': ')[1]) 341 | 342 | when line =~ /^Name \(short\):/ 343 | set_attr("short_name".to_sym, line.split(': ')[1]) 344 | 345 | when line =~ /^Name:/ || line =~ /^Name \(complete\):/ 346 | set_attr("name".to_sym, line.split(': ')[1]) 347 | 348 | when line =~ /^Firmware:/ 349 | set_attr(:firmware, line.split(': ')[1]) 350 | 351 | when line =~ /^Service Data \(/ 352 | #this has a lot of data, data that can change, data we don't really care about 353 | #(UUID 0xfe9f): 0000000000000000000000000000000000000000 354 | full_service_data = line.split('Service Data ')[1] 355 | extracted_service_uuid = full_service_data.scan(/\(([^)]+)\)/).flatten[0] 356 | #"UUID 0xfe9f" 357 | just_uuid = extracted_service_uuid.split('UUID ')[1] 358 | #0xfe9f 359 | # We are throwing multiple very different values into this field. To normalize the output we *should* 360 | # do a lookup on this uuid and reformat to be "Information (0xefef)" to match the other sources 361 | # This gets wrapped as "Unknown (0xfe9f)" by device model, but we should do a lookup, probably in device 362 | # model, to read who registered this if possible. 363 | set_attr("#{bt_mode}_service_uuids".to_sym, just_uuid) 364 | 365 | # "Appearance: Watch (0x00c0)" 366 | when line =~ /^Appearance:/ 367 | set_attr(:appearance, line.split(': ')[1]) 368 | 369 | when line =~ /^RSSI:/ 370 | rssi = line.split(': ')[1].split(' ')[0,2].join(' ') 371 | set_attr("#{bt_mode}_rssi".to_sym, { 372 | t: timestamp.split(': ')[1].to_i, 373 | rssi: rssi 374 | }) 375 | if tx_power 376 | ratio_db = tx_power.to_i - rssi.to_i 377 | ratio_linear = 10 ** ( ratio_db.to_f / 10 ) 378 | ibeacon_range = Math.sqrt(ratio_linear).round(2) 379 | set_attr(:ibeacon_range, ibeacon_range) 380 | end 381 | 382 | 383 | else 384 | # we only might need to see this in debug mode, no need to take up the 385 | # memory 386 | if BlueHydra.config["log_level"] == 'debug' 387 | set_attr("#{bt_mode}_unknown".to_sym, line) 388 | end 389 | #BlueHydra.logger.warn("Unhandled line: #{line}") 390 | end 391 | end 392 | 393 | # group the lines of an array of lines in a chunk together by there depth 394 | # 395 | # == Parameters: 396 | # arr :: 397 | # Array of lines 398 | # == Returns: 399 | # Array of arrays of grouped lines 400 | def group_by_depth(arr) 401 | output = [] 402 | 403 | nested = false 404 | arr.each do |x| 405 | 406 | if output.last 407 | 408 | last_line = output.last[-1] 409 | 410 | if line_depth(last_line) == line_depth(x) 411 | 412 | if x =~ /Features:/ && last_line =~ /Page: \d/ 413 | nested = true 414 | end 415 | 416 | if nested 417 | output.last << x 418 | else 419 | output << [x] 420 | end 421 | 422 | elsif line_depth(last_line) > line_depth(x) 423 | # we are outdenting 424 | nested = false 425 | output << [x] 426 | 427 | elsif line_depth(last_line) < line_depth(x) 428 | # we are indenting further 429 | nested = true 430 | output.last << x 431 | end 432 | else 433 | output << [x] 434 | end 435 | end 436 | 437 | output 438 | end 439 | 440 | # set an attribute key with a value in the @attributes hash 441 | # 442 | # This defaults the values in the @attributes to be an array of (ideally 1) 443 | # value so that we can test for mismatched messages 444 | # 445 | # == Parameters: 446 | # key :: 447 | # key to set 448 | # val :: 449 | # value to inject into the key in @attributes 450 | def set_attr(key, val) 451 | @attributes[key] ||= [] 452 | @attributes[key] << val 453 | end 454 | end 455 | end 456 | -------------------------------------------------------------------------------- /lib/blue_hydra/pulse.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | module Pulse 3 | def send_event(key,hash) 4 | if BlueHydra.pulse 5 | # TODO: the open source version never implemented this but maybe it should 6 | #for open source users who don't have send event (because they aren't sensors) 7 | return false 8 | end 9 | end 10 | 11 | def reset 12 | if BlueHydra.pulse || BlueHydra.pulse_debug 13 | 14 | BlueHydra.logger.info("Sending db reset to pulse") 15 | 16 | json_msg = JSON.generate({ 17 | type: "reset", 18 | source: "blue-hydra", 19 | version: BlueHydra::VERSION, 20 | sync_version: BlueHydra::SYNC_VERSION, 21 | }) 22 | 23 | BlueHydra::Pulse.do_send(json_msg) 24 | end 25 | end 26 | 27 | def hard_reset 28 | if BlueHydra.pulse || BlueHydra.pulse_debug 29 | 30 | BlueHydra.logger.info("Sending db hard reset to pulse") 31 | 32 | json_msg = JSON.generate({ 33 | type: "reset", 34 | source: "blue-hydra", 35 | version: BlueHydra::VERSION, 36 | sync_version: "ANYTHINGBUTTHISVERSION", 37 | }) 38 | 39 | BlueHydra::Pulse.do_send(json_msg) 40 | end 41 | end 42 | 43 | def do_send(json) 44 | BlueHydra::Pulse.do_debug(json) if BlueHydra.pulse_debug 45 | return unless BlueHydra.pulse 46 | begin 47 | # write json data to result socket 48 | TCPSocket.open('127.0.0.1', 8244) do |sock| 49 | sock.write(json) 50 | sock.write("\n") 51 | sock.flush 52 | end 53 | rescue => e 54 | BlueHydra.logger.warn "Unable to connect to Hermes (#{e.message}), unable to send to pulse" 55 | end 56 | end 57 | 58 | def do_debug(json) 59 | File.open("pulse_debug.log", 'a') { |file| file.puts(json) } 60 | end 61 | 62 | module_function :do_debug, :do_send, :send_event, :reset, :hard_reset 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/blue_hydra/runner.rb: -------------------------------------------------------------------------------- 1 | module BlueHydra 2 | 3 | # This class is a wrapper for all the core functionality of Blue Hydra. It 4 | # is responsible for managing all the threads for device interaction, data 5 | # processing and, when not in daemon mode, the CLI UI thread and tracker. 6 | class Runner 7 | 8 | attr_accessor :command, 9 | :raw_queue, 10 | :chunk_queue, 11 | :result_queue, 12 | :btmon_thread, 13 | :discovery_thread, 14 | :ubertooth_thread, 15 | :chunker_thread, 16 | :parser_thread, 17 | :signal_spitter_thread, 18 | :empty_spittoon_thread, 19 | :cui_status, 20 | :cui_thread, 21 | :api_thread, 22 | :info_scan_queue, 23 | :query_history, 24 | :scanner_status, 25 | :l2ping_queue, 26 | :result_thread, 27 | :stunned, 28 | :processing_speed 29 | 30 | # if we have been passed the 'file' option in the config we should try to 31 | # read out the file as our data source. This allows for btmon captures to 32 | # be replayed and post-processed. 33 | # 34 | # Supported filetypes are .xz, .gz or plaintext 35 | if BlueHydra.config["file"] 36 | if BlueHydra.config["file"] =~ /\.xz$/ 37 | @@command = "xzcat #{BlueHydra.config["file"]}" 38 | elsif BlueHydra.config["file"] =~ /\.gz$/ 39 | @@command = "zcat #{BlueHydra.config["file"]}" 40 | else 41 | @@command = "cat #{BlueHydra.config["file"]}" 42 | end 43 | else 44 | # Why is --columns here? Because Bluez 5.72 crashes without it 45 | @@command = "btmon --columns 170 -T -i #{BlueHydra.config["bt_device"]}" 46 | end 47 | if ! ::File.executable?(`command -v #{@@command.split[0]} 2> /dev/null`.chomp) 48 | BlueHydra.logger.fatal("Failed to find: '#{@@command.split[0]}' which is needed for the current setting...") 49 | exit 1 50 | end 51 | 52 | # Start the runner after being initialized 53 | # 54 | # == Parameters 55 | # command :: 56 | # the command to run, typically btmon -T -i hci0 but will be different 57 | # if running in file mode 58 | def start(command=@@command) 59 | @stopping = false 60 | begin 61 | BlueHydra.logger.debug("Runner starting with command: '#{command}' ...") 62 | 63 | # Check if we have any devices 64 | if !BlueHydra::Device.first.nil? 65 | #If we have devices, make sure to clean up their states and sync it all 66 | 67 | # Since it is unknown how long it has been since the system run last 68 | # we should look at the DB and mark timed out devices as offline before 69 | # starting anything else 70 | BlueHydra.logger.info("Marking older devices as 'offline'...") 71 | BlueHydra::Device.mark_old_devices_offline(true) 72 | 73 | # Sync everything to pwnpulse if the system is connected to the Pwnie 74 | # Express cloud 75 | BlueHydra.logger.info("Syncing all hosts to Pulse...") if BlueHydra.pulse 76 | BlueHydra::Device.sync_all_to_pulse 77 | else 78 | BlueHydra.logger.info("No devices found in DB, starting clean.") 79 | end 80 | BlueHydra::Pulse.reset 81 | 82 | # Query History is used to track what addresses have been pinged 83 | self.query_history = {} 84 | 85 | # Stunned 86 | self.stunned = false 87 | 88 | # the command used to capture data 89 | self.command = command 90 | 91 | # various queues used for thread intercommunication, could be replaced 92 | # by true IPC sockets at some point but these work prety damn well 93 | self.raw_queue = Queue.new # btmon thread -> chunker thread 94 | self.chunk_queue = Queue.new # chunker thread -> parser thread 95 | self.result_queue = Queue.new # parser thread -> result thread 96 | self.info_scan_queue = Queue.new # result thread -> discovery thread 97 | self.l2ping_queue = Queue.new # result thread -> discovery thread 98 | 99 | # start the result processing thread 100 | start_result_thread 101 | 102 | # RSSI API 103 | if BlueHydra.signal_spitter 104 | @rssi_data_mutex = Mutex.new #this is used by parser thread too 105 | start_signal_spitter_thread 106 | start_empty_spittoon_thread 107 | end 108 | 109 | # start the thread responsible for parsing the chunks into little data 110 | # blobs to be sorted in the db 111 | start_parser_thread 112 | 113 | # start the thread responsible for breaking the filtered btmon output 114 | # into chunks by device, basically a pre-parser 115 | start_chunker_thread 116 | 117 | # start the thread which runs the command, typically btmon so this is 118 | # the btmon thread but this thread will also run the xzcat, zcat or cat 119 | # commands for files 120 | start_btmon_thread 121 | 122 | # helper hashes for tracking status of the scanners and also the in 123 | # memory copy of data for the CUI 124 | self.scanner_status = {} 125 | self.cui_status = {} 126 | 127 | # another thread which operates the actual device discovery, not needed 128 | # if reading from a file since btmon will just be getting replayed 129 | unless ENV["BLUE_HYDRA"] == "test" 130 | start_discovery_thread unless BlueHydra.config["file"] 131 | end 132 | 133 | # start the thread responsible for printing the CUI to screen unless 134 | # we are in daemon mode 135 | start_cui_thread unless BlueHydra.daemon_mode 136 | 137 | # start the thread responsible for printing the file api if requested 138 | start_api_thread if BlueHydra.file_api 139 | 140 | # unless we are reading from a file we need to determine if we have an 141 | # ubertooth available and then initialize a thread to manage that 142 | # device as needed 143 | unless BlueHydra.config["file"] 144 | #I am hoping this sleep randomly fixing the display issue in cui 145 | sleep 1 146 | # Handle ubertooth 147 | # add in additional hardware detection using `lsusb -d 1d50:6002` exit code 148 | if ::File.executable?(`command -v lsusb 2> /dev/null`.chomp) 149 | self.scanner_status[:ubertooth] = "lsusb available" 150 | lsusb = BlueHydra::Command.execute3("lsusb -d '1d50:6002'") 151 | if lsusb[:exit_code] == 0 152 | self.scanner_status[:ubertooth] = "Hardware found, checking for ubertooth-util" 153 | if ::File.executable?(`command -v ubertooth-util 2> /dev/null`.chomp) 154 | self.scanner_status[:ubertooth] = "Detecting" 155 | ubertooth_util_v = BlueHydra::Command.execute3("ubertooth-util -v -U #{BlueHydra.config["ubertooth_index"]}") 156 | if ubertooth_util_v[:exit_code] == 0 157 | self.scanner_status[:ubertooth] = "Found hardware" 158 | BlueHydra.logger.debug("Found ubertooth hardware") 159 | sleep 1 160 | ubertooth_util_r = BlueHydra::Command.execute3("ubertooth-util -r -U #{BlueHydra.config["ubertooth_index"]}") 161 | if ubertooth_util_r[:exit_code] == 0 162 | self.scanner_status[:ubertooth] = "hardware responsive" 163 | BlueHydra.logger.debug("hardware is responsive") 164 | sleep 1 165 | if system("ubertooth-rx -h 2>&1 | grep -q Survey") 166 | BlueHydra.logger.debug("Found working ubertooth-rx -z") 167 | self.scanner_status[:ubertooth] = "ubertooth-rx" 168 | ubertooth_rx_firmware = BlueHydra::Command.execute3("ubertooth-rx -z -t 1 -U #{BlueHydra.config["ubertooth_index"]}") 169 | ubertooth_firmware_check(ubertooth_rx_firmware[:stderr]) 170 | if ubertooth_rx_firmware[:exit_code] == 0 171 | @ubertooth_command = "ubertooth-rx -z -t 40 -U #{BlueHydra.config["ubertooth_index"]}" 172 | end 173 | end 174 | unless @ubertooth_command 175 | sleep 1 176 | ubertooth_scan_firmware = BlueHydra::Command.execute3("ubertooth-scan -t 1 -U #{BlueHydra.config["ubertooth_index"]}") 177 | ubertooth_firmware_check(ubertooth_scan_firmware[:stderr]) 178 | if ubertooth_scan_firmware[:exit_code] == 0 179 | BlueHydra.logger.debug("Found working ubertooth-scan") 180 | self.scanner_status[:ubertooth] = "ubertooth-scan" 181 | @ubertooth_command = "ubertooth-scan -t 40 -U #{BlueHydra.config["ubertooth_index"]}" 182 | else 183 | if self.scanner_status[:ubertooth] != 'Disabled, firmware upgrade required' 184 | BlueHydra.logger.error("Unable to find ubertooth-scan or ubertooth-rx -z, ubertooth disabled.") 185 | self.scanner_status[:ubertooth] = "Unable to find ubertooth-scan or ubertooth-rx -z" 186 | end 187 | end 188 | end 189 | else 190 | self.scanner_status[:ubertooth] = "hardware unresponsive" 191 | if !ubertooth_util_r[:stdout].nil? && ubertooth_util_r[:stdout] != "" 192 | ubertooth_util_r[:stdout].split("\n").each do |ln| 193 | BlueHydra.logger.debug(ln) 194 | end 195 | end 196 | if !ubertooth_util_r[:stderr].nil? && ubertooth_util_r[:stderr] != "" 197 | ubertooth_util_r[:stderr].split("\n").each do |ln| 198 | BlueHydra.logger.debug(ln) 199 | end 200 | end 201 | BlueHydra.logger.error("hardware is present but ubertooth-util -r fails") 202 | end 203 | start_ubertooth_thread if @ubertooth_command 204 | else 205 | self.scanner_status[:ubertooth] = "No hardware detected" 206 | if !ubertooth_util_v[:stdout].nil? && ubertooth_util_v[:stdout] != "" 207 | ubertooth_util_v[:stdout].split("\n").each do |ln| 208 | BlueHydra.logger.debug(ln) 209 | end 210 | end 211 | if !ubertooth_util_v[:stderr].nil? && ubertooth_util_v[:stderr] != "" 212 | ubertooth_util_v[:stderr].split("\n").each do |ln| 213 | BlueHydra.logger.debug(ln) 214 | end 215 | end 216 | BlueHydra.logger.info("No ubertooth hardware detected") 217 | end 218 | else 219 | self.scanner_status[:ubertooth] = "ubertooth-util missing" 220 | Blue_Hydra.logger.info("Unable to use ubertooth without ubertooth-util installed") 221 | end 222 | else 223 | self.scanner_status[:ubertooth] = "No hardware detected" 224 | end 225 | else 226 | self.scanner_status[:ubertooth] = "Please install lsusb" 227 | Blue_hydra.logger.info("Unable to detect ubertooth without lsusb installed") 228 | end 229 | end 230 | 231 | rescue => e 232 | BlueHydra.logger.error("Runner master thread: #{e.message}") 233 | e.backtrace.each do |x| 234 | BlueHydra.logger.error("#{x}") 235 | end 236 | BlueHydra::Pulse.send_event('blue_hydra', 237 | {key:'blue_hydra_master_thread_error', 238 | title:'Blue Hydras Master Thread Encountered An Error', 239 | message:"Runner master thread: #{e.message}", 240 | severity:'ERROR' 241 | }) 242 | end 243 | end 244 | 245 | # this is a helper method which resports status of queue depth and thread 246 | # health. Mainly used from bin/blue_hydra work loop to make sure everything 247 | # is alive or to exit gracefully 248 | def status 249 | x = { 250 | raw_queue: self.raw_queue.length, 251 | chunk_queue: self.chunk_queue.length, 252 | result_queue: self.result_queue.length, 253 | info_scan_queue: self.info_scan_queue.length, 254 | l2ping_queue: self.l2ping_queue.length, 255 | btmon_thread: self.btmon_thread.status, 256 | chunker_thread: self.chunker_thread.status, 257 | parser_thread: self.parser_thread.status, 258 | result_thread: self.result_thread.status, 259 | stopping: @stopping 260 | } 261 | 262 | unless BlueHydra.config["file"] 263 | x[:discovery_thread] = self.discovery_thread.status 264 | x[:ubertooth_thread] = self.ubertooth_thread.status if self.ubertooth_thread 265 | end 266 | 267 | if BlueHydra.signal_spitter 268 | x[:signal_spitter_thread] = self.signal_spitter_thread.status 269 | x[:empty_spittoon_thread] = self.empty_spittoon_thread.status 270 | end 271 | 272 | x[:cui_thread] = self.cui_thread.status unless BlueHydra.daemon_mode 273 | x[:api_thread] = self.api_thread.status if BlueHydra.file_api 274 | 275 | x 276 | end 277 | 278 | # stop method this stops the threads but attempts to allow the result queue 279 | # to drain before fully exiting to prevent data loss 280 | def stop 281 | return if @stopping 282 | @stopping = true 283 | BlueHydra.logger.info("Runner stopped. Exiting after clearing queue...") 284 | self.btmon_thread.kill if self.btmon_thread # stop this first thread so data stops flowing ... 285 | unless BlueHydra.config["file"] #then stop doing anything if we are doing anything 286 | self.discovery_thread.kill if self.discovery_thread 287 | self.ubertooth_thread.kill if self.ubertooth_thread 288 | end 289 | 290 | stop_condition = Proc.new do 291 | [nil, false].include?(result_thread.status) || 292 | [nil, false].include?(parser_thread.status) || 293 | self.result_queue.empty? 294 | end 295 | 296 | # clear queue... 297 | until stop_condition.call 298 | unless self.cui_thread 299 | BlueHydra.logger.info("Remaining queue depth: #{self.result_queue.length}") 300 | sleep 5 301 | else 302 | sleep 1 303 | end 304 | end 305 | 306 | if BlueHydra.no_db 307 | # when we know we are storing no database it makes no sense to leave the devices online 308 | # tell pulse in advance that we are clearing this database so things do not get confused 309 | # when bringing an older database back online 310 | # this is our protection against running "blue_hydra; blue_hydra --no-db; blue_hydra" 311 | BlueHydra.logger.info("Queue clear! Resetting Pulse then exiting.") 312 | BlueHydra::Pulse.reset 313 | BlueHydra.logger.info("Pulse reset! Exiting.") 314 | else 315 | BlueHydra.logger.info("Queue clear! Exiting.") 316 | end 317 | 318 | self.chunker_thread.kill if self.chunker_thread 319 | self.parser_thread.kill if self.parser_thread 320 | self.result_thread.kill if self.result_thread 321 | self.api_thread.kill if self.api_thread 322 | self.cui_thread.kill if self.cui_thread 323 | self.signal_spitter_thread.kill if self.signal_spitter_thread 324 | self.empty_spittoon_thread.kill if self.empty_spittoon_thread 325 | 326 | self.raw_queue = nil 327 | self.chunk_queue = nil 328 | self.result_queue = nil 329 | self.info_scan_queue = nil 330 | self.l2ping_queue = nil 331 | end 332 | 333 | # Start the thread which runs the specified command 334 | def start_btmon_thread 335 | BlueHydra.logger.info("Btmon thread starting") 336 | self.btmon_thread = Thread.new do 337 | begin 338 | # spawn the handler for btmon and pass in the shared raw queue as a 339 | # param so that it can feed data back into the runner threads 340 | spawner = BlueHydra::BtmonHandler.new( 341 | self.command, 342 | self.raw_queue 343 | ) 344 | rescue BtmonExitedError 345 | BlueHydra.logger.error("Btmon thread exiting...") 346 | BlueHydra::Pulse.send_event('blue_hydra', 347 | {key:'blue_hydra_btmon_exited', 348 | title:'Blue Hydras Btmon Thread Exited', 349 | message:"Btmon Thread exited...", 350 | severity:'ERROR' 351 | }) 352 | rescue => e 353 | BlueHydra.logger.error("Btmon thread #{e.message}") 354 | e.backtrace.each do |x| 355 | BlueHydra.logger.error("#{x}") 356 | end 357 | BlueHydra::Pulse.send_event('blue_hydra', 358 | {key:'blue_hydra_btmon_thread_error', 359 | title:'Blue Hydras BTmon Thread Encountered An Error', 360 | message:"Btmon thread #{e.message}", 361 | severity:'ERROR' 362 | }) 363 | end 364 | end 365 | end 366 | 367 | def ubertooth_firmware_check(ubertooth_stderr) 368 | if ubertooth_stderr =~ /Please upgrade to latest released firmware/ 369 | self.scanner_status[:ubertooth] = 'Disabled, firmware upgrade required' 370 | BlueHydra.logger.error("Ubertooth disabled, firmware upgrade required to match host software") 371 | ubertooth_stderr.split("\n").each do |ln| 372 | BlueHydra.logger.error(ln) 373 | end 374 | return false 375 | end 376 | return true 377 | end 378 | 379 | def bluetoothdDbusError(bluetoothd_errors) 380 | BlueHydra.logger.info("Bluetoothd errors, attempting to recover...") 381 | bluetoothd_errors += 1 382 | begin 383 | if bluetoothd_errors == 1 384 | if ::File.executable?(`which rc-service 2> /dev/null`.chomp) 385 | service = "rc-service" 386 | elsif ::File.executable?(`which service 2> /dev/null`.chomp) 387 | service = "service" 388 | else 389 | service = false 390 | end 391 | 392 | # Is bluetoothd running? 393 | bluetoothd_pid = `pgrep bluetoothd`.chomp 394 | unless bluetoothd_pid == "" 395 | # Does init own bluetoothd? 396 | if `ps -o ppid= #{bluetoothd_pid}`.chomp =~ /\s1/ 397 | if service 398 | BlueHydra.logger.info("Restarting bluetoothd...") 399 | bluetoothd_restart = BlueHydra::Command.execute3("#{service} bluetooth restart") 400 | else 401 | bluetoothd_restart ||= {} 402 | bluetoothd_restart[:exit_code] == 127 403 | end 404 | sleep 3 405 | else 406 | # not controled by init, bail 407 | unless BlueHydra.daemon_mode 408 | self.cui_thread.kill if self.cui_thread 409 | end 410 | BlueHydra.logger.fatal("Bluetoothd is running but not controlled by init or functioning, please restart it manually.") 411 | BlueHydra::Pulse.send_event('blue_hydra', 412 | {key:'blue_hydra_bluetoothd_error', 413 | title:'Blue Hydra Encounterd Unrecoverable bluetoothd Error', 414 | message:"bluetoothd is running but not controlled by init or functioning", 415 | severity:'FATAL' 416 | }) 417 | exit 1 418 | end 419 | else 420 | # bluetoothd isn't running at all, attempt to restart through init 421 | if service 422 | BlueHydra.logger.info("Starting bluetoothd...") 423 | bluetoothd_restart = BlueHydra::Command.execute3("#{service} bluetooth restart") 424 | else 425 | bluetoothd_restart ||= {} 426 | bluetoothd_restart[:exit_code] == 127 427 | end 428 | sleep 3 429 | end 430 | unless bluetoothd_restart[:exit_code] == 0 431 | bluetoothd_errors += 1 432 | end 433 | end 434 | if bluetoothd_errors > 1 435 | unless BlueHydra.daemon_mode 436 | self.cui_thread.kill if self.cui_thread 437 | end 438 | if bluetoothd_restart[:stderr] 439 | BlueHydra.logger.error("Failed to restart bluetoothd: #{bluetoothd_restart[:stderr]}") 440 | BlueHydra::Pulse.send_event('blue_hydra', 441 | {key:'blue_hydra_bluetoothd_restart_failed', 442 | title:'Blue Hydra Failed To Restart bluetoothd', 443 | message:"Failed to restart bluetoothd: #{bluetoothd_restart[:stderr]}", 444 | severity:'ERROR' 445 | }) 446 | end 447 | BlueHydra.logger.fatal("Bluetoothd is not functioning as expected and we failed to automatically recover.") 448 | BlueHydra::Pulse.send_event('blue_hydra', 449 | {key:'blue_hydra_bluetoothd_jank', 450 | title:'Blue Hydra Unable To Recover From Bluetoothd Error', 451 | message:"Bluetoothd is not functioning as expected and we failed to automatically recover.", 452 | severity:'FATAL' 453 | }) 454 | exit 1 455 | end 456 | rescue Errno::ENOMEM, NoMemoryError 457 | BlueHydra.logger.fatal("System couldn't allocate enough memory to run an external command.") 458 | BlueHydra::Pulse.send_event('blue_hydra', 459 | { 460 | key: "bluehydra_oom", 461 | title: "BlueHydra couldnt allocate enough memory to run external command. Sensor OOM.", 462 | message: "BlueHydra couldnt allocate enough memory to run external command. Sensor OOM.", 463 | severity: "FATAL" 464 | }) 465 | exit 1 466 | end 467 | return bluetoothd_errors 468 | end 469 | 470 | # helper method to reset the interface as needed 471 | def hci_reset(bluetoothd_errors) 472 | # interface reset 473 | interface_reset = BlueHydra::Command.execute3("hciconfig #{BlueHydra.config["bt_device"]} reset")[:stderr] 474 | if interface_reset 475 | if interface_reset =~ /Connection timed out/i || interface_reset =~ /Operation not possible due to RF-kill/i 476 | ## TODO: check error number not description 477 | ## TODO: check for interface name "Can't init device hci0: Connection timed out (110)" 478 | ## TODO: check for interface name "Can't init device hci0: Operation not possible due to RF-kill (132)" 479 | raise BluezNotReadyError 480 | else 481 | BlueHydra.logger.error("Error with hciconfig #{BlueHydra.config["bt_device"]} reset..") 482 | interface_reset.split("\n").each do |ln| 483 | BlueHydra.logger.error(ln) 484 | end 485 | end 486 | end 487 | # Bluez 5.64 seems to have a bug in reset where the device shows powered but fails as not ready 488 | sleep 1 489 | interface_powerup = BlueHydra::Command.execute3("printf \"select #{BlueHydra::LOCAL_ADAPTER_ADDRESS.split}\npower on\n\" | timeout 2 bluetoothctl") 490 | if interface_powerup[:exit_code] == 124 491 | if interface_powerup[:stdout] =~ /Waiting to connect to bluetoothd.../i 492 | BlueHydra.logger.info("bluetoothctl unable to connect to bluetoothd") 493 | bluetoothdDbusError(bluetoothd_errors) 494 | else 495 | BlueHydra.logger.warn("Timeout occurred while powering on bluetooth adapter #{BlueHydra.config["bt_device"]}") 496 | interface_powerup[:stdout].split("\n").each do |ln| 497 | BlueHydra.logger.error(ln) 498 | end 499 | end 500 | end 501 | if interface_powerup[:stderr] 502 | BlueHydra.logger.error("Error with bluetoothctl power on...") 503 | interface_powerup[:stderr].split("\n").each do |ln| 504 | BlueHydra.logger.error(ln) 505 | end 506 | end 507 | sleep 1 508 | end 509 | 510 | # thread responsible for sending interesting commands to the hci device so 511 | # that interesting things show up in the btmon ouput 512 | def start_discovery_thread 513 | BlueHydra.logger.info("Discovery thread starting") 514 | self.discovery_thread = Thread.new do 515 | begin 516 | 517 | if BlueHydra.info_scan 518 | discovery_time = 30 519 | discovery_timeout = 45 520 | else 521 | discovery_time = 180 522 | discovery_timeout = 195 523 | end 524 | discovery_command = "#{File.expand_path('../../../bin/test-discovery', __FILE__)} --timeout #{discovery_time} -i #{BlueHydra.config["bt_device"]}" 525 | 526 | loop do 527 | begin 528 | 529 | # set once here so if it fails on the first loop we don't get nil 530 | bluez_errors ||= 0 531 | bluetoothd_errors ||= 0 532 | 533 | # clear the queues 534 | until info_scan_queue.empty? && l2ping_queue.empty? 535 | # clear out entire info scan queue first 536 | until info_scan_queue.empty? 537 | 538 | # reset interface first to get to a good base state 539 | hci_reset(bluetoothd_errors) 540 | 541 | BlueHydra.logger.debug("Popping off info scan queue. Depth: #{ info_scan_queue.length}") 542 | 543 | # grab a command out of the queue to run 544 | command = info_scan_queue.pop 545 | case command[:command] 546 | when :info # classic mode devices 547 | # run hcitool info against the specified address, capture 548 | # errors, no need to capture stdout because the interesting 549 | # stuff is gonna be in btmon anyway 550 | info_errors = BlueHydra::Command.execute3("hcitool -i #{BlueHydra.config["bt_device"]} info #{command[:address]}",3)[:stderr] 551 | 552 | when :leinfo # low energy devices 553 | # run hcitool leinfo, capture errors 554 | info_errors = BlueHydra::Command.execute3("hcitool -i #{BlueHydra.config["bt_device"]} leinfo --random #{command[:address]}",3)[:stderr] 555 | 556 | # if we have errors fro le info scan attempt some 557 | # additional trickery to grab the data in a few other ways 558 | if info_errors == "Could not create connection: Input/output error" 559 | info_errors = nil 560 | BlueHydra.logger.debug("Random leinfo failed against #{command[:address]}") 561 | hci_reset(bluetoothd_errors) 562 | info2_errors = BlueHydra::Command.execute3("hcitool -i #{BlueHydra.config["bt_device"]} leinfo --static #{command[:address]}",3)[:stderr] 563 | if info2_errors == "Could not create connection: Input/output error" 564 | BlueHydra.logger.debug("Static leinfo failed against #{command[:address]}") 565 | hci_reset(bluetoothd_errors) 566 | info3_errors = BlueHydra::Command.execute3("hcitool -i #{BlueHydra.config["bt_device"]} leinfo #{command[:address]}",3)[:stderr] 567 | if info3_errors == "Could not create connection: Input/output error" 568 | BlueHydra.logger.debug("Default leinfo failed against #{command[:address]}") 569 | BlueHydra.logger.debug("Default leinfo failed against #{command[:address]}") 570 | BlueHydra.logger.debug("Default leinfo failed against #{command[:address]}") 571 | end 572 | end 573 | end 574 | else 575 | BlueHydra.logger.error("Invalid command detected... #{command.inspect}") 576 | info_errors = nil 577 | end 578 | 579 | # handle and log error output as needed 580 | if info_errors 581 | if info_errors.chomp =~ /connect: No route to host/i 582 | # We could handle this as negative feedback if we want 583 | elsif info_errors.chomp =~ /create connection: Input\/output error/i 584 | # We failed to connect, not sure why, not sure we care 585 | else 586 | BlueHydra.logger.error("Error with info command... #{command.inspect}") 587 | info_errors.split("\n").each do |ln| 588 | BlueHydra.logger.error(ln) 589 | end 590 | end 591 | end 592 | end 593 | 594 | # run 1 l2ping a time while still checking if info scan queue 595 | # is empty 596 | unless l2ping_queue.empty? 597 | hci_reset(bluetoothd_errors) 598 | BlueHydra.logger.debug("Popping off l2ping queue. Depth: #{ l2ping_queue.length}") 599 | command = l2ping_queue.pop 600 | l2ping_errors = BlueHydra::Command.execute3("l2ping -c 3 -i #{BlueHydra.config["bt_device"]} #{command[:address]}",5)[:stderr] 601 | if l2ping_errors 602 | if l2ping_errors.chomp =~ /connect: No route to host/i 603 | # We could handle this as negative feedback if we want 604 | elsif l2ping_errors.chomp =~ /connect: Host is down/i 605 | # Same as above 606 | elsif l2ping_errors.chomp =~ /create connection: Input\/output error/i 607 | # We failed to connect, not sure why, not sure we care 608 | elsif l2ping_errors.chomp =~ /connect: Connection refused/i 609 | #maybe we do care about this one? if it refused, it was there 610 | elsif l2ping_errors.chomp =~ /connect: Permission denied/i 611 | #this appears when we aren't root, but it also gets sent back from the remote host sometimes 612 | elsif l2ping_errors.chomp =~ /connect: Function not implemented/i 613 | # this isn't in the bluez code at all so it must be coming back from the remote host 614 | else 615 | BlueHydra.logger.error("Error with l2ping command... #{command.inspect}") 616 | l2ping_errors.split("\n").each do |ln| 617 | BlueHydra.logger.error(ln) 618 | end 619 | end 620 | end 621 | end 622 | end 623 | 624 | # another reset before going back to discovery 625 | hci_reset(bluetoothd_errors) 626 | 627 | # hot loop avoidance, but run right before discovery to avoid any delay between discovery and info scan 628 | sleep 1 629 | 630 | # run test-discovery 631 | # do a discovery 632 | self.scanner_status[:test_discovery] = Time.now.to_i unless BlueHydra.daemon_mode 633 | discovery_errors = BlueHydra::Command.execute3(discovery_command,discovery_timeout)[:stderr] 634 | if discovery_errors 635 | if discovery_errors =~ /org.bluez.Error.NotReady/ 636 | raise BluezNotReadyError 637 | elsif discovery_errors =~ /dbus.exceptions.DBusException/i 638 | # This happens when bluetoothd isn't running or otherwise broken off the dbus 639 | # systemd 640 | # dbus.exceptions.DBusException: org.freedesktop.systemd1.NoSuchUnit: Unit dbus-org.bluez.service not found. 641 | # dbus.exceptions.DBusException: org.freedesktop.DBus.Error.ServiceUnknown: The name :1.[0-9]{5} was not provided by any .service files 642 | # gentoo (not systemd) 643 | # dbus.exceptions.DBusException: org.freedesktop.DBus.Error.ServiceUnknown: The name org.bluez was not provided by any .service files 644 | # dbus.exceptions.DBusException: org.freedesktop.DBus.Error.ServiceUnknown: The name :1.[0-9]{3} was not provided by any .service files 645 | # dbus.exceptions.DBusException: org.freedesktop.DBus.Error.NameHasNoOwner: Could not get owner of name 'org.bluez': no such name 646 | bluetoothd_errors = bluetoothdDbusError(bluetoothd_errors) 647 | elsif discovery_errors =~ /KeyboardInterrupt/ 648 | # Sometimes the interrupt gets passed to test-discovery so assume it was meant for us 649 | BlueHydra.logger.info("BlueHydra Killed! Exiting... SIGINT") 650 | exit 651 | else 652 | BlueHydra.logger.error("Error with test-discovery script..") 653 | discovery_errors.split("\n").each do |ln| 654 | BlueHydra.logger.error(ln) 655 | end 656 | end 657 | end 658 | 659 | bluez_errors = 0 660 | 661 | rescue BluezNotReadyError 662 | BlueHydra.logger.info("Bluez reports not ready, attempting to recover...") 663 | bluez_errors += 1 664 | if bluez_errors == 1 665 | BlueHydra.logger.error("Bluez reported #{BlueHydra.config["bt_device"]} not ready, attempting to reset with rfkill") 666 | rfkillreset_command = "#{File.expand_path('../../../bin/rfkill-reset', __FILE__)} #{BlueHydra.config["bt_device"]}" 667 | rfkillreset_errors = BlueHydra::Command.execute3(rfkillreset_command,45)[:stdout] #no output means no errors, all output to stdout 668 | if rfkillreset_errors 669 | bluez_errors += 1 670 | end 671 | end 672 | if bluez_errors > 1 673 | unless BlueHydra.daemon_mode 674 | self.cui_thread.kill if self.cui_thread 675 | puts "Bluez reported #{BlueHydra.config["bt_device"]} not ready and failed to auto-reset with rfkill" 676 | puts "Try removing and replugging the card, or toggling rfkill on and off" 677 | end 678 | BlueHydra.logger.fatal("Bluez reported #{BlueHydra.config["bt_device"]} not ready and failed to reset with rfkill") 679 | BlueHydra::Pulse.send_event('blue_hydra', 680 | {key:'blue_hydra_bluez_error', 681 | title:'Blue Hydra Encountered Bluez Error', 682 | message:"Bluez reported #{BlueHydra.config["bt_device"]} not ready and failed to reset with rfkill", 683 | severity:'FATAL' 684 | }) 685 | exit 1 686 | end 687 | rescue => e 688 | BlueHydra.logger.error("Discovery loop crashed: #{e.message}") 689 | e.backtrace.each do |x| 690 | BlueHydra.logger.error("#{x}") 691 | end 692 | BlueHydra::Pulse.send_event('blue_hydra', 693 | {key:'blue_hydra_discovery_loop_error', 694 | title:'Blue Hydras Discovery Loop Encountered An Error', 695 | message:"Discovery loop crashed: #{e.message}", 696 | severity:'ERROR' 697 | }) 698 | BlueHydra.logger.error("Sleeping 20s...") 699 | sleep 20 700 | end 701 | end 702 | 703 | rescue => e 704 | BlueHydra.logger.error("Discovery thread #{e.message}") 705 | e.backtrace.each do |x| 706 | BlueHydra.logger.error("#{x}") 707 | end 708 | BlueHydra::Pulse.send_event('blue_hydra', 709 | {key:'blue_hydra_discovery_thread_error', 710 | title:'Blue Hydras Discovery Thread Encountered An Error', 711 | message:"Discovery thread error: #{e.message}", 712 | severity:'ERROR' 713 | }) 714 | end 715 | end 716 | end 717 | 718 | # thread to manage the ubertooth device where available 719 | def start_ubertooth_thread 720 | BlueHydra.logger.info("Ubertooth thread starting") 721 | self.ubertooth_thread = Thread.new do 722 | begin 723 | loop do 724 | begin 725 | # Do a scan with ubertooth 726 | ubertooth_reset = BlueHydra::Command.execute3("ubertooth-util -U #{BlueHydra.config["ubertooth_index"]} -r") 727 | if ubertooth_reset[:stderr] 728 | BlueHydra.logger.error("Error with ubertooth-util -r...") 729 | ubertooth_reset[:stderr].split("\n").each do |ln| 730 | BlueHydra.logger.error(ln) 731 | end 732 | end 733 | 734 | self.scanner_status[:ubertooth] = Time.now.to_i unless BlueHydra.daemon_mode 735 | ubertooth_output = BlueHydra::Command.execute3(@ubertooth_command,60) 736 | if ubertooth_output[:stderr] 737 | BlueHydra.logger.error("Error with ubertooth-{scan,rx}..") 738 | ubertooth_output[:stderr].split("\n").each do |ln| 739 | BlueHydra.logger.error(ln) 740 | end 741 | else 742 | ubertooth_output[:stdout].each_line do |line| 743 | if line =~ /^[\?:]{6}[0-9a-f:]{11}/i 744 | address = line.scan(/^((\?\?:){2}([0-9a-f:]*))/i).flatten.first.gsub('?', '0') 745 | 746 | # note that things here are being manually [array] wrapped 747 | # so that they follow the data patterns set by the parser 748 | result_queue.push({ 749 | address: [address], 750 | last_seen: [Time.now.to_i], 751 | classic_mode: [true] 752 | }) 753 | 754 | push_to_queue(:classic, address) 755 | end 756 | end 757 | end 758 | 759 | # scan with ubertooth for 40 seconds, sleep for 1, reset, repeat 760 | sleep 1 761 | end 762 | end 763 | end 764 | end 765 | end 766 | 767 | # thread to manage the CUI output where availalbe 768 | def start_cui_thread 769 | BlueHydra.logger.info("Command Line UI thread starting") 770 | self.cui_thread = Thread.new do 771 | cui = BlueHydra::CliUserInterface.new(self) 772 | cui.help_message 773 | cui.cui_loop 774 | end 775 | end 776 | 777 | # thread to manage the CUI output where availalbe 778 | def start_api_thread 779 | BlueHydra.logger.info("API thread starting") 780 | self.api_thread = Thread.new do 781 | api = BlueHydra::CliUserInterface.new(self) 782 | api.api_loop 783 | end 784 | end 785 | 786 | # helper method to push addresses intothe scan queues with a little 787 | # pre-processing 788 | def push_to_queue(mode, address) 789 | case mode 790 | when :classic 791 | command = :info 792 | # use uap_lap for tracking classic devices 793 | track_addr = address.split(":")[2,4].join(":") 794 | 795 | # do not send local adapter to be scanned y(>_<)y 796 | return if track_addr == BlueHydra::LOCAL_ADAPTER_ADDRESS.split(":")[2,4].join(":") 797 | when :le 798 | command = :leinfo 799 | track_addr = address 800 | 801 | # do not send local adapter to be scanned y(>_<)y 802 | return if address == BlueHydra::LOCAL_ADAPTER_ADDRESS 803 | end 804 | 805 | # only scan if the info scan rate timeframe has elapsed 806 | self.query_history[track_addr] ||= {} 807 | last_info = self.query_history[track_addr][mode].to_i 808 | if BlueHydra.info_scan && (BlueHydra.config["info_scan_rate"].to_i > 0) 809 | if (Time.now.to_i - (BlueHydra.config["info_scan_rate"].to_i * 60)) >= last_info 810 | info_scan_queue.push({command: command, address: address}) 811 | self.query_history[track_addr][mode] = Time.now.to_i 812 | end 813 | end 814 | end 815 | 816 | # thread responsible for chunking up btmon output to be parsed 817 | def start_chunker_thread 818 | BlueHydra.logger.info("Chunker thread starting") 819 | self.chunker_thread = Thread.new do 820 | loop do 821 | begin 822 | # handler, pass in chunk queue for data to be fed back out 823 | chunker = BlueHydra::Chunker.new( 824 | self.raw_queue, 825 | self.chunk_queue 826 | ) 827 | chunker.chunk_it_up 828 | rescue => e 829 | BlueHydra.logger.error("Chunker thread #{e.message}") 830 | e.backtrace.each do |x| 831 | BlueHydra.logger.error("#{x}") 832 | end 833 | BlueHydra.logger.warn("Restarting Chunker...") 834 | BlueHydra::Pulse.send_event('blue_hydra', 835 | {key:'blue_hydra_chunker_error', 836 | title:'Blue Hydras Chunker Thread Encountered An Error', 837 | message:"Chunker thread error: #{e.message}", 838 | severity:'ERROR' 839 | }) 840 | end 841 | sleep 1 842 | end 843 | end 844 | end 845 | 846 | # thread responsible for parsed chunked up btmon output 847 | def start_parser_thread 848 | BlueHydra.logger.info("Parser thread starting") 849 | self.parser_thread = Thread.new do 850 | begin 851 | 852 | scan_results = {} 853 | @rssi_data ||= {} if BlueHydra.signal_spitter 854 | 855 | # get the chunks and parse them, track history, update CUI and push 856 | # to data processing thread 857 | while chunk = chunk_queue.pop do 858 | p = BlueHydra::Parser.new(chunk.dup) 859 | p.parse 860 | 861 | attrs = p.attributes.dup 862 | 863 | address = (attrs[:address]||[]).uniq.first 864 | 865 | if address 866 | 867 | if !BlueHydra.daemon_mode || BlueHydra.file_api 868 | tracker = CliUserInterfaceTracker.new(self, chunk, attrs, address) 869 | tracker.update_cui_status 870 | end 871 | 872 | if scan_results[address] 873 | needs_push = false 874 | 875 | attrs.each do |k,v| 876 | 877 | unless [:last_seen, :le_rssi, :classic_rssi].include? k 878 | unless attrs[k] == scan_results[address][k] 879 | scan_results[address][k] = v 880 | needs_push = true 881 | end 882 | else 883 | case 884 | when k == :last_seen 885 | current_time = attrs[k].sort.last 886 | last_seen = scan_results[address][k].sort.last 887 | 888 | # update this value no more than 1 x / minute to avoid 889 | # flooding pulse with too much noise. 890 | if (current_time - last_seen) > 60 891 | attrs[k] = [current_time] 892 | scan_results[address][k] = attrs[k] 893 | needs_push = true 894 | else 895 | attrs[k] = [last_seen] 896 | end 897 | 898 | when [:le_rssi, :classic_rssi].include?(k) 899 | current_time = attrs[k][0][:t] 900 | last_seen_time = (scan_results[address][k][0][:t] rescue 0) 901 | 902 | # if log_rssi is set log all values 903 | if BlueHydra.config["rssi_log"] || BlueHydra.signal_spitter 904 | attrs[k].each do |x| 905 | # unix timestamp from btmon 906 | ts = x[:t] 907 | 908 | # '-90 dBm' -> -90 909 | rssi = x[:rssi].split(' ')[0].to_i 910 | 911 | if BlueHydra.config["rssi_log"] 912 | # LE / CL for classic mode 913 | type = k.to_s.gsub('_rssi', '').upcase[0,2] 914 | 915 | msg = [ts, type, address, rssi].join(' ') 916 | BlueHydra.rssi_logger.info(msg) 917 | end 918 | if BlueHydra.signal_spitter 919 | @rssi_data_mutex.synchronize { 920 | @rssi_data[address] ||= [] 921 | @rssi_data[address] << {ts: ts, dbm: rssi} 922 | } 923 | end 924 | end 925 | end 926 | 927 | # if aggressive_rssi is set send all rssis to pulse 928 | # this should not be set where avoidable 929 | # signal_spitter *should* make this irrelevant, remove? 930 | if BlueHydra.config["aggressive_rssi"] && ( BlueHydra.pulse || BlueHydra.pulse_debug ) 931 | attrs[k].each do |x| 932 | send_data = { 933 | type: "bluetooth-aggressive-rssi", 934 | source: "blue-hydra", 935 | version: BlueHydra::VERSION, 936 | data: {} 937 | } 938 | send_data[:data][:status] = "online" 939 | send_data[:data][:address] = address 940 | send_data[:data][:sync_version] = BlueHydra::SYNC_VERSION 941 | send_data[:data][k] = [x] 942 | 943 | # create the json 944 | json_msg = JSON.generate(send_data) 945 | #send the json 946 | BlueHydra::Pulse.do_send(json_msg) 947 | end 948 | end 949 | 950 | # update this value no more than 1 x / minute to avoid 951 | # flooding pulse with too much noise. 952 | if (current_time - last_seen_time) > 60 953 | scan_results[address][k] = attrs[k] 954 | needs_push = true 955 | else 956 | attrs.delete(k) 957 | end 958 | end 959 | end 960 | end 961 | 962 | if needs_push 963 | result_queue.push(attrs) unless self.stunned 964 | end 965 | else 966 | scan_results[address] = attrs 967 | result_queue.push(attrs) unless self.stunned 968 | end 969 | 970 | end 971 | 972 | end 973 | rescue => e 974 | BlueHydra.logger.error("Parser thread #{e.message}") 975 | e.backtrace.each do |x| 976 | BlueHydra.logger.error("#{x}") 977 | end 978 | BlueHydra::Pulse.send_event('blue_hydra', 979 | {key:'blue_hydra_parser_thread_error', 980 | title:'Blue Hydras Parser Thread Encountered An Error', 981 | message:"Parser thread error: #{e.message}", 982 | severity:'ERROR' 983 | }) 984 | end 985 | end 986 | end 987 | 988 | def start_signal_spitter_thread 989 | BlueHydra.logger.debug("RSSI API starting") 990 | self.signal_spitter_thread = Thread.new do 991 | begin 992 | loop do 993 | server = TCPServer.new("127.0.0.1", 1124) 994 | loop do 995 | Thread.start(server.accept) do |client| 996 | begin 997 | magic_word = Timeout::timeout(1) do 998 | client.gets.chomp 999 | end 1000 | rescue Timeout::Error 1001 | client.puts "ah ah ah, you didn't say the magic word" 1002 | client.close 1003 | return 1004 | end 1005 | if magic_word == 'bluetooth' 1006 | if @rssi_data 1007 | @rssi_data_mutex.synchronize { 1008 | client.puts JSON.generate(@rssi_data) 1009 | } 1010 | end 1011 | end 1012 | client.close 1013 | end 1014 | end 1015 | end 1016 | rescue => e 1017 | BlueHydra.logger.error("RSSI API thread #{e.message}") 1018 | e.backtrace.each do |x| 1019 | BlueHydra.logger.error("#{x}") 1020 | end 1021 | BlueHydra::Pulse.send_event('blue_hydra', 1022 | {key:'blue_hydra_rssi_api_thread_error', 1023 | title:'Blue Hydras RSSI API Thread Encountered An Error', 1024 | message:"RSSI API thread error: #{e.message}", 1025 | severity:'ERROR' 1026 | }) 1027 | end 1028 | end 1029 | end 1030 | 1031 | def start_empty_spittoon_thread 1032 | self.empty_spittoon_thread = Thread.new do 1033 | BlueHydra.logger.debug("RSSI cleanup starting") 1034 | begin 1035 | signal_timeout = 120 1036 | sleep signal_timeout #no point in cleaning until there is stuff to clean 1037 | loop do 1038 | sleep 1 #this is pretty agressive but it seems fine 1039 | @rssi_data_mutex.synchronize { 1040 | @rssi_data.each do |address, address_meta| 1041 | @rssi_data[address].select{|d| d[:ts] > Time.now.to_i - signal_timeout} if @rssi_data[address] 1042 | end 1043 | } 1044 | end 1045 | rescue => e 1046 | BlueHydra.logger.error("RSSI cleanup thread #{e.message}") 1047 | e.backtrace.each do |x| 1048 | BlueHydra.logger.error("#{x}") 1049 | end 1050 | BlueHydra::Pulse.send_event('blue_hydra', 1051 | {key:'blue_hydra_rssi_cleanup_thread_error', 1052 | title:'Blue Hydras RSSI Cleanup Thread Encountered An Error', 1053 | message:"RSSI CLEANUP thread error: #{e.message}", 1054 | severity:'ERROR' 1055 | }) 1056 | end 1057 | end 1058 | end 1059 | 1060 | def start_result_thread 1061 | BlueHydra.logger.info("Result thread starting") 1062 | self.result_thread = Thread.new do 1063 | begin 1064 | 1065 | #debugging 1066 | maxdepth = 0 1067 | self.processing_speed = 0 1068 | processing_tracker = 0 1069 | processing_timer = 0 1070 | 1071 | last_sync = Time.now 1072 | 1073 | loop do 1074 | # 1 day in seconds == 24 * 60 * 60 == 86400 1075 | # daily sync 1076 | if Time.now.to_i - 86400 >= last_sync.to_i 1077 | BlueHydra::Device.sync_all_to_pulse(last_sync) 1078 | last_sync = Time.now 1079 | end 1080 | 1081 | unless BlueHydra.config["file"] 1082 | # if their last_seen value is > 7 minutes ago and not > 15 minutes ago 1083 | # l2ping them : "l2ping -c 3 result[:address]" 1084 | BlueHydra::Device.all(classic_mode: true).select{|x| 1085 | x.last_seen < (Time.now.to_i - (60 * 7)) && x.last_seen > (Time.now.to_i - (60*15)) 1086 | }.each do |device| 1087 | self.query_history[device.address] ||= {} 1088 | if (Time.now.to_i - (60 * 7)) >= self.query_history[device.address][:l2ping].to_i 1089 | 1090 | l2ping_queue.push({ 1091 | command: :l2ping, 1092 | address: device.address 1093 | }) 1094 | 1095 | self.query_history[device.address][:l2ping] = Time.now.to_i 1096 | end 1097 | end 1098 | end 1099 | 1100 | until result_queue.empty? 1101 | if BlueHydra.daemon_mode 1102 | queue_depth = result_queue.length 1103 | if queue_depth > 250 1104 | if (maxdepth < queue_depth) 1105 | maxdepth = result_queue.length 1106 | BlueHydra.logger.warn("Popping off result queue. Max Depth: #{maxdepth} and rising") 1107 | else 1108 | BlueHydra.logger.warn("Popping off result queue. Max Depth: #{maxdepth} Currently: #{queue_depth}") 1109 | end 1110 | end 1111 | end 1112 | 1113 | result = result_queue.pop 1114 | 1115 | #this seems like the most expensive possible way to calculate speed, but I'm sure it's not 1116 | if Time.now.to_i >= processing_timer + 10 1117 | if processing_tracker == 0 1118 | self.processing_speed = 0 1119 | else 1120 | self.processing_speed = processing_tracker.to_f/10 1121 | end 1122 | processing_tracker = 0 1123 | processing_timer = Time.now.to_i 1124 | end 1125 | 1126 | processing_tracker += 1 1127 | 1128 | unless BlueHydra.config["file"] 1129 | # arbitrary low end cut off on slow processing to avoid stunning too often 1130 | if self.processing_speed > 3 && result_queue.length >= self.processing_speed * 10 1131 | self.stunned = true 1132 | elsif result_queue.length > 200 1133 | self.stunned = true 1134 | end 1135 | end 1136 | 1137 | if result[:address] 1138 | device = BlueHydra::Device.update_or_create_from_result(result) 1139 | 1140 | unless BlueHydra.config["file"] 1141 | if device.le_mode 1142 | #do not info scan beacon type devices, they do not respond while in advertising mode 1143 | if device.company_type !~ /iBeacon/i && device.company !~ /Gimbal/i 1144 | push_to_queue(:le, device.address) 1145 | end 1146 | end 1147 | 1148 | if device.classic_mode 1149 | push_to_queue(:classic, device.address) 1150 | end 1151 | end 1152 | 1153 | else 1154 | BlueHydra.logger.warn("Device without address #{JSON.generate(result)}") 1155 | end 1156 | end 1157 | 1158 | BlueHydra::Device.mark_old_devices_offline 1159 | 1160 | self.stunned = false 1161 | 1162 | # only sleep if we still have nothing to do, seconds count 1163 | sleep 1 if result_queue.empty? 1164 | end 1165 | 1166 | rescue => e 1167 | BlueHydra.logger.error("Result thread #{e.message}") 1168 | e.backtrace.each do |x| 1169 | BlueHydra.logger.error("#{x}") 1170 | end 1171 | BlueHydra::Pulse.send_event('blue_hydra', 1172 | {key:'blue_hydra_result_thread_error', 1173 | title:'Blue Hydras Result Thread Encountered An Error', 1174 | message:"Result thread #{e.message}", 1175 | severity:'ERROR' 1176 | }) 1177 | end 1178 | end 1179 | 1180 | end 1181 | end 1182 | end 1183 | -------------------------------------------------------------------------------- /lib/blue_hydra/sync_version.rb: -------------------------------------------------------------------------------- 1 | # this is the bluetooth Device model stored in the DB 2 | class BlueHydra::SyncVersion 3 | # this is a DataMapper model... 4 | include DataMapper::Resource 5 | 6 | property :id, Serial 7 | property :version, String 8 | 9 | before :save, :generate_version 10 | 11 | def generate_version 12 | self.version = SecureRandom.uuid 13 | end 14 | end 15 | 16 | 17 | -------------------------------------------------------------------------------- /packaging/openrc/blue_hydra.confd: -------------------------------------------------------------------------------- 1 | # /etc/conf.d/blue_hydra - configuration file for /etc/init.d/blue_hydra 2 | 3 | # Options to pass to blue_hydra, see `blue_hydra --help` 4 | # --daemonize is passed unconditionally 5 | BLUE_HYDRA_OPTIONS="" 6 | -------------------------------------------------------------------------------- /packaging/openrc/blue_hydra.initd: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | # Copyright 1999-2019 Gentoo Authors 3 | # Distributed under the terms of the GNU General Public License v2 4 | 5 | name="blue_hydra" 6 | command="/usr/sbin/blue_hydra" 7 | command_args="--daemonize ${BLUE_HYDRA_OPTIONS}" 8 | supervisor="supervise-daemon" 9 | output_log="/var/log/blue_hydra_service.log" 10 | error_log="${output_log}" 11 | pidfile="/run/blue_hydra_service.pid" 12 | -------------------------------------------------------------------------------- /packaging/systemd/blue_hydra.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=BlueHydra 3 | After=bluetooth.target 4 | Requires=bluetooth.service 5 | 6 | [Service] 7 | WorkingDirectory=/usr/lib64/blue_hydra 8 | #Environment="BUNDLE_GEMFILE=/usr/lib64/blue_hydra/Gemfile" 9 | #ExecStart=/usr/bin/bundle exec ruby /usr/lib64/blue_hydra/bin/blue_hydra --daemon --mohawk-api 10 | ExecStart=/usr/lib64/blue_hydra/bin/blue_hydra --daemon --mohawk-api 11 | Restart=on-failure 12 | RestartSec=60s 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /spec/btmon_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra::BtmonHandler do 4 | it "a class" do 5 | expect(BlueHydra::BtmonHandler.class).to eq(Class) 6 | end 7 | 8 | it "will break the output of bluetooth data into chunks to be parsed" do 9 | filepath = File.expand_path('../fixtures/btmon.stdout', __FILE__) 10 | command = "cat #{filepath} && sleep 1" 11 | queue = Queue.new 12 | 13 | begin 14 | handler = BlueHydra::BtmonHandler.new(command, queue) 15 | rescue BtmonExitedError 16 | # will be raised in file mode 17 | end 18 | 19 | expect(queue.empty?).to eq(false) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/chunker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra::Chunker do 4 | it "can determine if a message indicates a new host" do 5 | yep1 = ["> HCI Event: Connect Complete (0x03) plen 11 2015-12-10 11:30:24.387882\r\n", 6 | " Status: Page Timeout (0x04)\r\n", 7 | " Handle: 65535\r\n", 8 | " Address: D6:87:40:44:B1:4F (OUI D6-87-40)\r\n", 9 | " Link type: ACL (0x01)\r\n", 10 | " Encryption: Disabled (0x00)\r\n"] 11 | yep2 = ["> HCI Event: Role Change (0x12) plen 8 2015-12-10 11:31:08.667931\r\n", 12 | " Status: Success (0x00)\r\n", 13 | " Address: 8C:2D:AA:7F:58:8C (Apple)\r\n", 14 | " Role: Slave (0x01)\r\n"] 15 | yep3 = ["> HCI Event: LE Meta Event (0x3e) plen 19 2015-12-10 11:30:58.880870\r\n", 16 | " LE Connection Complete (0x01)\r\n", 17 | " Status: Success (0x00)\r\n", 18 | " Handle: 3585\r\n", 19 | " Role: Master (0x00)\r\n", 20 | " Peer address type: Public (0x00)\r\n", 21 | " Peer address: 80:EA:CA:68:02:C1 (Dialog Semiconductor Hellas SA)\r\n", 22 | " Connection interval: 18.75 msec (0x000f)\r\n", 23 | " Connection latency: 0.00 msec (0x0000)\r\n", 24 | " Supervision timeout: 32000 msec (0x0c80)\r\n", 25 | " Master clock accuracy: 0x00\r\n"] 26 | 27 | nope1 = ["Bluetooth monitor ver 5.35\r\n"] 28 | nope2 = ["= New Index: 5C:C5:D4:11:33:79 (BR/EDR,USB,hci1) 2015-12-10 11:29:46.064195\r\n"] 29 | nope3 = ["> HCI Event: Disconnect Complete (0x05) plen 4 2015-12-10 11:30:58.970878\r\n", 30 | " Status: Success (0x00)\r\n", 31 | " Handle: 3585\r\n", 32 | " Reason: Connection Terminated By Local Host (0x16)\r\n"] 33 | 34 | q1 = Queue.new 35 | q2 = Queue.new 36 | chunker = BlueHydra::Chunker.new(q1, q2) 37 | expect(chunker.starting_chunk?(yep1)).to eq(true) 38 | expect(chunker.starting_chunk?(yep2)).to eq(true) 39 | expect(chunker.starting_chunk?(yep3)).to eq(true) 40 | 41 | expect(chunker.starting_chunk?(nope1)).to eq(false) 42 | expect(chunker.starting_chunk?(nope2)).to eq(false) 43 | expect(chunker.starting_chunk?(nope3)).to eq(false) 44 | end 45 | 46 | it "can chunk up a queue of message blocks" do 47 | filepath = File.expand_path('../fixtures/btmon.stdout', __FILE__) 48 | command = "cat #{filepath} && sleep 1" 49 | queue1 = Queue.new 50 | queue2 = Queue.new 51 | 52 | begin 53 | handler = BlueHydra::BtmonHandler.new(command, queue1) 54 | rescue BtmonExitedError 55 | # will be raised in file mode 56 | end 57 | 58 | chunker = BlueHydra::Chunker.new(queue1, queue2) 59 | 60 | t = Thread.new do 61 | chunker.chunk_it_up 62 | end 63 | 64 | expect(chunker.starting_chunk?(queue2.pop.first)).to eq(true) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra::Command do 4 | it 'executes a shell command and returns a hash of output' do 5 | result = BlueHydra::Command.execute3("echo 'hello world'") 6 | expect(result[:exit_code]).to eq(0) 7 | expect(result[:stdout]).to eq("hello world") 8 | expect(result[:stderr]).to eq(nil) 9 | end 10 | end 11 | 12 | describe "hciconfig output parsing" do 13 | it "returns a single mac" do 14 | begin 15 | expect(BlueHydra::EnumLocalAddr.call.count).to eq(1) 16 | rescue NoMethodError => e 17 | if e.message == "undefined method `scan' for nil:NilClass" 18 | #during testing we allow this to pass if there is no adapter 19 | expect(1).to eq(1) 20 | else 21 | raise e 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/device_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # the actual bluetooth devices dawg 4 | describe BlueHydra::Device do 5 | it "has useful attributes" do 6 | device = BlueHydra::Device.new 7 | 8 | %w{ 9 | id 10 | name 11 | status 12 | address 13 | uap_lap 14 | vendor 15 | appearance 16 | company 17 | company_type 18 | lmp_version 19 | manufacturer 20 | firmware 21 | classic_mode 22 | classic_service_uuids 23 | classic_channels 24 | classic_major_class 25 | classic_minor_class 26 | classic_class 27 | classic_rssi 28 | classic_tx_power 29 | classic_features 30 | classic_features_bitmap 31 | le_mode 32 | le_service_uuids 33 | le_address_type 34 | le_random_address_type 35 | le_flags 36 | le_rssi 37 | le_tx_power 38 | le_features 39 | le_features_bitmap 40 | created_at 41 | updated_at 42 | last_seen 43 | uuid 44 | }.each do |attr| 45 | expect(device.respond_to?(attr)).to eq(true) 46 | end 47 | end 48 | 49 | it "generates a uuid when saving" do 50 | d = BlueHydra::Device.new 51 | d.address = "DE:AD:BE:EF:CA:FE" 52 | expect(d.uuid).to eq(nil) 53 | d.save 54 | expect(d.uuid.class).to eq(String) 55 | uuid_regex = /^[0-9a-z]{8}-([0-9a-z]{4}-){3}[0-9a-z]{12}$/ 56 | expect(d.uuid =~ uuid_regex).to eq(0) 57 | end 58 | 59 | it "sets a uap_lap from an address" do 60 | address = "D5:AD:B5:5F:CA:F5" 61 | device = BlueHydra::Device.new 62 | device.address = address 63 | device.save 64 | expect(device.uap_lap).to eq("B5:5F:CA:F5") 65 | 66 | device2 = BlueHydra::Device.find_by_uap_lap("FF:00:B5:5F:CA:F5") 67 | expect(device2).to eq(device) 68 | end 69 | 70 | it "serializes some attributes" do 71 | device = BlueHydra::Device.new 72 | classic_class = [ 73 | [ 74 | "0x7a020c", 75 | "Networking (LAN, Ad hoc)", 76 | "Capturing (Scanner, Microphone)", 77 | "Object Transfer (v-Inbox, v-Folder)", 78 | "Audio (Speaker, Microphone, Headset)", 79 | "Telephony (Cordless telephony, Modem, Headset)" 80 | ] 81 | ] 82 | 83 | classic_uuids = [ 84 | "PnP Information (0x1200)", 85 | "Handsfree Audio Gateway (0x111f)", 86 | "Phonebook Access Server (0x112f)", 87 | "Audio Source (0x110a)", 88 | "A/V Remote Control Target (0x110c)", 89 | "NAP (0x1116)", 90 | "Message Access Server (0x1132)" 91 | ] 92 | 93 | le_uuids = ["Unknown (0xfeed)"] 94 | 95 | device.classic_class = classic_class 96 | device.classic_service_uuids = classic_uuids 97 | device.le_service_uuids = le_uuids 98 | 99 | expect(JSON.parse(device.classic_class).first).to eq("Networking (LAN, Ad hoc)") 100 | expect(JSON.parse(device.classic_service_uuids).first).to eq("PnP Information (0x1200)") 101 | expect(JSON.parse(device.le_service_uuids).first).to eq("Unknown (0xfeed)") 102 | end 103 | 104 | it "create or updates from a hash" do 105 | raw = { 106 | classic_num_responses: ["1"], 107 | address: ["00:00:00:00:00:00"], 108 | classic_page_scan_repetition_mode: ["R1 (0x01)"], 109 | classic_page_period_mode: ["P2 (0x02)"], 110 | classic_major_class: ["Phone (cellular, cordless, payphone, modem)"], 111 | classic_minor_class: ["Smart phone"], 112 | classic_class: [[ 113 | "0x7a020c", 114 | "Networking (LAN, Ad hoc)", 115 | "Capturing (Scanner, Microphone)", 116 | "Object Transfer (v-Inbox, v-Folder)", 117 | "Audio (Speaker, Microphone, Headset)", 118 | "Telephony (Cordless telephony, Modem, Headset)" 119 | ]], 120 | classic_clock_offset: ["0x54a2"], 121 | classic_rssi: ["-36 dBm (0xdc)"], 122 | name: ["iPhone"], 123 | classic_service_uuids: [ 124 | "PnP Information (0x1200)", 125 | "Handsfree Audio Gateway (0x111f)", 126 | "Phonebook Access Server (0x112f)", 127 | "Audio Source (0x110a)", 128 | "A/V Remote Control Target (0x110c)", 129 | "NAP (0x1116)", 130 | "Message Access Server (0x1132)", 131 | "00000000-deca-fade-deca-deafdecacafe", 132 | "2d8d2466-e14d-451c-88bc-7301abea291a" 133 | ], 134 | classic_unknown: [ 135 | "[\" Company: not assigned (19456)\\r\\n\", \" Type: iBeacon (2)\\r\\n\"]", 136 | "02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................", 137 | "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................", 138 | "00 00 .." 139 | ] 140 | } 141 | 142 | device = BlueHydra::Device.update_or_create_from_result(raw) 143 | 144 | expect(device.address).to eq("00:00:00:00:00:00") 145 | expect(device.name).to eq("iPhone") 146 | expect( 147 | JSON.parse(device.classic_class).first).to eq("Networking (LAN, Ad hoc)") 148 | expect( 149 | JSON.parse(device.classic_service_uuids).first).to eq("PnP Information (0x1200)") 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/hex_spec.rb: -------------------------------------------------------------------------------- 1 | unless ENV["PN"] = "blue_hydra" 2 | `mkdir /usr/src/bluez` 3 | `chmod 777 /usr/src/bluez` 4 | `cd /usr/src/bluez; apt-get source bluez` 5 | VERSION=`apt-cache policy bluez | grep Installed | awk '{print $2}' | awk -F'-' '{print $1}'`.chomp.freeze 6 | MAIN_C = File.read("/usr/src/bluez/bluez-#{VERSION}/monitor/main.c").freeze 7 | PACKET_C = File.read("/usr/src/bluez/bluez-#{VERSION}/monitor/packet.c").freeze 8 | MAIN_C_SCAN = [ 'Bluetooth monitor ver' ].freeze 9 | PACKET_C_SCAN_BTMON = [ 10 | '{ 0x0f, "Command Status",', 11 | '{ 0x13, "Number of Completed Packets",', 12 | '{ 0x0e, "Command Complete",', 13 | '"New Index", label, details);', 14 | '"Delete Index", label, NULL);', 15 | '"Open Index", label, NULL);', 16 | '"Index Info", label, details);', 17 | '"Note", message, NULL);', 18 | '{ 0x03, "Connect Complete",', 19 | '{ 0x07, "Remote Name Req Complete",' 20 | ].freeze 21 | PACKET_C_SCAN_CHUNKER = [ 22 | '{ 0x03, "Connect Complete",', 23 | '{ 0x12, "Role Change"', 24 | '{ 0x2f, "Extended Inquiry Result"', 25 | '{ 0x22, "Inquiry Result with RSSI",', 26 | '{ 0x07, "Remote Name Req Complete"', 27 | '{ 0x3d, "Remote Host Supported Features",', 28 | '{ 0x04, "Connect Request",', 29 | '{ 0x0e, "Command Complete",', 30 | '{ 0x3e, "LE Meta Event",', 31 | '{ 0, "LE Connection Complete" },', 32 | '{ 1, "LE Advertising Report" },' 33 | ].freeze 34 | 35 | describe "Hex needed for" do 36 | describe "btmon handler" do 37 | MAIN_C_SCAN.each do |phrase| 38 | it "including #{phrase}" do 39 | expect(MAIN_C.scan(phrase).size).to eq(1) 40 | end 41 | end 42 | PACKET_C_SCAN_BTMON.each do |phrase| 43 | it "including #{phrase}" do 44 | expect(PACKET_C.scan(phrase).size).to eq(1) 45 | end 46 | end 47 | end 48 | 49 | describe "chunker" do 50 | PACKET_C_SCAN_CHUNKER.each do |phrase| 51 | it "including #{phrase}" do 52 | expect(PACKET_C.scan(phrase).size).to eq(1) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra::Parser do 4 | 5 | it "can calculate the indentation of a given line" do 6 | p = BlueHydra::Parser.new 7 | 8 | lines = [ 9 | 'test line', 10 | ' test line', 11 | ' test line', 12 | ' test line', 13 | ' test line', 14 | ' test line' 15 | ] 16 | 17 | lines.each_with_index do |ln, i| 18 | expect(p.line_depth(ln)).to eq(i) 19 | end 20 | end 21 | 22 | it "groups arrays of strings by whitespace depth" do 23 | p = BlueHydra::Parser.new 24 | x, y, z = "x", " y", " z" 25 | 26 | a = [ x, x ] 27 | b = [ x, y ] 28 | c = [ x, x, y, y, z, x, y] 29 | 30 | ra = p.group_by_depth(a) 31 | rb = p.group_by_depth(b) 32 | rc = p.group_by_depth(c) 33 | expect(ra).to eq([[x],[x]]) 34 | expect(rb).to eq([[x,y]]) 35 | expect(rc).to eq([[x], [x, y, y, z], [x, y]]) 36 | end 37 | 38 | 39 | it "converts a chunk of info about a device into a hash of attributes" do 40 | filepath = File.expand_path('../fixtures/btmon.stdout', __FILE__) 41 | command = "cat #{filepath} && sleep 1" 42 | queue1 = Queue.new 43 | queue2 = Queue.new 44 | 45 | begin 46 | handler = BlueHydra::BtmonHandler.new(command, queue1) 47 | rescue BtmonExitedError 48 | # will be raised in file mode 49 | end 50 | 51 | chunker = BlueHydra::Chunker.new(queue1, queue2) 52 | 53 | t = Thread.new do 54 | chunker.chunk_it_up 55 | end 56 | 57 | chunks = [] 58 | 59 | sleep 2 # let the chunker chunk 60 | 61 | until queue2.empty? 62 | chunks << queue2.pop 63 | end 64 | 65 | parsers = chunks.map do |c| 66 | p = BlueHydra::Parser.new(c) 67 | p.parse 68 | p 69 | end 70 | 71 | addrs = parsers.map do |p| 72 | p.attributes[:address] 73 | end.reject{|x| x == nil } 74 | 75 | addrs_per_device = addrs.map(&:uniq).map(&:count).uniq 76 | expect(addrs_per_device).to eq([1]) # 1 addr per device :) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/px_realtime_bluetooth_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra do 4 | it "is a module" do 5 | expect(BlueHydra.class).to eq(Module) 6 | end 7 | 8 | it "has a version" do 9 | expect(BlueHydra::VERSION =~ /\d\.\d\.\d/i).to eq(0) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/runner_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe BlueHydra::Runner do 4 | it "takes a command" do 5 | filepath = File.expand_path('../fixtures/btmon.stdout', __FILE__) 6 | command = "cat #{filepath} && sleep 1" 7 | runner = BlueHydra::Runner.new 8 | runner.start(command) 9 | sleep 5 10 | 11 | created_device = BlueHydra::Device.all(address: 'B3:3F:CA:F3:DE:AD').first 12 | 13 | expect(created_device.lmp_version).to eq("Bluetooth 4.1 (0x07) - Subversion 16653 (0x410d)") 14 | expect(JSON.parse(created_device.classic_features).first).to eq("3 slot packets") 15 | expect(created_device.last_seen.class).to eq(Integer) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'rubygems' 3 | require 'rspec' 4 | require 'simplecov' 5 | SimpleCov.start 6 | 7 | $:.unshift(File.dirname(File.expand_path('../../lib/blue_hydra.rb',__FILE__))) 8 | 9 | ENV["BLUE_HYDRA"] = "test" 10 | 11 | require 'blue_hydra' 12 | 13 | BlueHydra.daemon_mode = true 14 | BlueHydra.pulse = false 15 | 16 | RSpec.configure do |config| 17 | config.expect_with :rspec do |c| 18 | c.syntax = [:should, :expect] 19 | end 20 | end 21 | 22 | --------------------------------------------------------------------------------