#!/usr/bin/env ruby

class KisAnalysis
  VERSION = '0.11'
  #Copyright (C) 2010  Rory McCune
  #This program is free software; you can redistribute it and/or
  #modify it under the terms of the GNU General Public License
  #as published by the Free Software Foundation; either version 2
  #of the License, or (at your option) any later version.
  #
  #This program is distributed in the hope that it will be useful,
  #but WITHOUT ANY WARRANTY; without even the implied warranty of
  #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  #GNU General Public License for more details.
  #
  #You should have received a copy of the GNU General Public License
  #along with this program; if not, write to the Free Software
  #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  
  def initialize(arguments)
    begin
      require 'rubygems'
      require 'nokogiri'
      require 'logger'
      require 'optparse'
      require 'ostruct'
    rescue LoadError
      abort("FATALITY: kis_analysis required the nokogiri gem to work.  Try 'gem install nokogiri'\n for linux installs 'apt-get install libxslt libxml2 libxml2-dev' is needed before installing the gem")
    end

    @log = Logger.new("kis-analysis.log")
    @log.level = Logger::DEBUG
    @log.debug("Log Created at " + Time.now.to_s)

    @options = OpenStruct.new

    #Name for the .netxml file to be parsed
    @options.input_file_name = nil
    #Directory name that contains the .netxml files to be parsed
    @options.input_dir_name = nil
    #Name for the report file.  If not specified a default is assigned
    @options.report_file = nil
    #Array of file names that we either assign the name above to or that we punt all the files from the specified directory into if -d has been used
    @options.file_names = Array.new
    #Once we've opened the files we use this array for the contents
    @options.files = Array.new
    #This is a flag for whether the analyse_gps method is run
    @options.gps = false
    #This contains the gps data if required. the hash key is the mac address of the network the contents are a hash of the lat/long position
    @options.gps_data = Hash.new
    #This is a reporting option for whether we want the report with a single HTML table or one per SSID
    @options.single_table = false
    #This options defines whether we want a google map of the networks. For it to work the gps option is needed
    @options.create_map = false
    

    opts = OptionParser.new do |opts|
      opts.banner = "Ruby Kismet Log File Analyzer"

      opts.on("-fFILE","--file FILE", "kismet netxml file to analyze") do |file|
        @log.debug("Trying to open Input File")
        @options.input_file_name = file
      end

      opts.on("-dDIR","--dir DIR","Directory with kismet netxml files to analze") do |dir|
        @log.debug("Setting Directory")
        @options.input_dir_name = dir
        #TODO: Need to create a check for openable directory here
      end

      opts.on("-rREPORT","--report REPORT","report file") do |rep|
        @log.debug("setting report file")
        @options.report_file = rep
      end

      opts.on("-g","--gps","Enable GPS analysis") do |gps|
        @options.gps = true
      end

      opts.on("-s","--single","Report with a single HTML table for all networks") do |single|
        @options.single_table = true
      end

      opts.on("-m","--map","Create a Google map") do |map|
        @options.create_map = true
      end

      opts.on("-h","--help","-?","--?", "Get Help") do |help|
        puts opts
        exit
      end

      opts.on("-v","--version", "Get Version") do |ver|
        puts "Ruby Kismet Log parser #{VERSION}"
        exit
      end

    end

    opts.parse!(arguments)
    
    unless @options.input_file_name || @options.input_dir_name
      puts "ERROR: Either a file name or directory name required to work"
      puts opts
      exit
    end

    if @options.input_file_name && @options.input_dir_name
      puts "can't specify files and directorys"
      puts "one or the other..."
      exit
    end

    

    if @options.input_file_name
      begin
        file = File.open(@options.input_file_name).read
        @options.files << file
        @options.file_names << @options.input_file_name
      rescue Errno::ENOENT
        @log.fatal("Input File #{file} could not be opened")
        puts "couldn't open the input file, sure it's there?"
        exit
      end
      @log.info("opened #{@options.input_file_name} successfully")
    elsif @options.input_dir_name
      begin
        Dir.chdir(@options.input_dir_name) unless Dir.pwd == @options.input_dir_name
      rescue Errno::ENOENT
        @log.fatal("can't change to #{@options.input_dir_name} sure it's there?")
        abort("Can't change to #{@options.input_dir_name} sure it's there?")
      end

      pot_files = Dir.entries(@options.input_dir_name)
      pot_files.each do |pot_file|
        if pot_file =~ /netxml$/
          begin
            tfile = File.open(pot_file).read
            @options.files << tfile
            @options.file_names << pot_file
          rescue Errno::ENOENT
            @log.fatal("Input File #{tfile} could not be opened")
            puts "couldn't open the input file, sure it's there?"
            exit
          end

        end

      end

    else
      @log.fatal("no idea how we got here!")
      puts "that was weird"
      exit
    end
   
    

  end

  def analyse
    @num_servers = 0
    @num_clients = 0
    @num_by_cipher = Hash.new
    @infrastructure_networks = Hash.new
    @probes = Hash.new
    @adhoc_networks = Hash.new
    @nets_by_bssid = Hash.new
  #if @options.gps
   # analyse_gps
  #end
    @options.files.each do |file|
      @doc = Nokogiri::XML(file)
      if @options.gps
        analyse_gps
      end
      puts 'starting'
      @num_servers = @num_servers + @doc.search('wireless-network').length
      @num_clients = @num_clients + @doc.search('wireless-client').length

      @doc.search('wireless-network').each do |net|
        if net.attribute('type').value == 'infrastructure'
          print '.'
          analyse_net(net,'inf')
        elsif net.attribute('type').value == 'probe'
          analyse_probe(net)
        elsif net.attribute('type').value == 'ad-hoc'
          analyse_net(net,'adhoc')
        end
      end
    end
  end

  
  def html_report
    require 'ruport'
    unless @options.report_file
      @options.report_file = 'Kismet-Wireless-Report-' + Time.now.to_s + '.html'
    end
    @report = File.new(@options.report_file,'w+')
    html_report_header
    html_report_stats
    
    if @options.create_map
      @report << '<hr /><br /><br />'
      html_report_map_body
    end
    @report << '<hr /><br /><br />'
    html_report_inf
    @report << '<hr /><br /><br />'
    html_report_adhoc

    
    
    @report << "</body>"
    @report << "</html>"
  end

  def html_report_header
    #Sets up the HTML report header
    @report << '
    <html>
      <head>
       <title> Kismet Wireless Report</title>
       <style>
        body {
	        font: normal 11px auto "Trebuchet MS", Verdana, Arial, Helvetica, sans-serif;
	        color: #4f6b72;
	        background: #E6EAE9;
        }
        #report-header {
          font-weight: bold;
          font-size: 24px;
          font-family: "Trebuchet MS", Verdana, Arial, Helvetica, sans-serif;
          color: #4f6b72;

        }

        #sub-header {
          font-weight: italic;
          font-size: 10px;
          font-family: "Trebuchet MS", Verdana, Arial, Helvetica, sans-serif;
          color: #4f6b72;

        }

        #title {
          font-weight: bold;
          font-size: 16px;
          font-family: "Trebuchet MS", Verdana, Arial, Helvetica, sans-serif;
          color: #4f6b72;
        }

         th {
	       font: bold 11px "Trebuchet MS", Verdana, Arial, Helvetica, sans-serif;
	       color: #4f6b72;
	       border-right: 1px solid #C1DAD7;
	       border-bottom: 1px solid #C1DAD7;
	       border-top: 1px solid #C1DAD7;
	       letter-spacing: 2px;
	       text-transform: uppercase;
	       text-align: left;
	       padding: 6px 6px 6px 12px;
         }

      td {
	      border-right: 1px solid #C1DAD7;
	      border-bottom: 1px solid #C1DAD7;
	      background: #fff;
	      padding: 6px 6px 6px 12px;
	      color: #4f6b72;
      }


      td.alt {
	      background: #F5FAFA;
	      color: #797268;
      }



    </style>
    '
    if @options.create_map
      @report << %Q!
       <script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
       <script type="text/javascript">
       function initialize() {
         var latlng = new google.maps.LatLng(#{@map_centre['lat']}, #{@map_centre['long']});
         var myOptions = {
           zoom: 14,
           center: latlng,
           mapTypeId: google.maps.MapTypeId.ROADMAP
         };
        var map = new google.maps.Map(document.getElementById("map_canvas"), myOptions);
     !

     #Yugh this is a hack
     @options.gps_data.each do |bssid,point|
        netname = bssid.gsub(':','')

        if @nets_by_bssid[bssid]
          #Next line is present to strip any single quotes from SSID's before putting them into the marker as that causes problems :)
          content_ssid = @nets_by_bssid[bssid]['ssid'].gsub(/['<>]/,'')
          @log.debug("About to add " + content_ssid) if content_ssid
          @report << %Q!
            var contentString#{netname} = '<b>SSID: </b> #{content_ssid} <br />' +
                                          '<b>BSSID: </b> #{bssid}<br />' +
                                          '<b>Channel: </b> #{@nets_by_bssid[bssid]['channel']} <br />' +
                                          '<b>Ciphers: </b> #{@nets_by_bssid[bssid]['cipher']} <br />' +
                                          '<b>Cloaked?: </b> #{@nets_by_bssid[bssid]['cloaked']} <br />';
            var infowindow#{netname} = new google.maps.InfoWindow({
              content: contentString#{netname}
            });
           !
        end
         @report << %Q!
            var latlng#{netname} = new google.maps.LatLng(#{point['lat']}, #{point['lon']});

            var marker#{netname} = new google.maps.Marker({
              position: latlng#{netname},
              map: map
            });
       !
       if @nets_by_bssid[bssid]
         @report << %Q!
            google.maps.event.addListener(marker#{netname}, 'click', function() {
              infowindow#{netname}.open(map,marker#{netname});
            });
         !
       end
     end

      @report << %Q!
      }
     </script>

    !
    end
    @report << '</head>'
    if @options.create_map
      @report << '<body onload="initialize()">'
    else
      @report << '<body>'
    end
    @report << '<div id="report-header">Kismet Wireless Report</div> <br /> <div id="sub-header"> Report Generated at ' + Time.now.to_s + '<br />'
    @report << 'Files analysed ' + @options.file_names.join(',<br />') + '<br /> <br /></div>'
  end

  def html_report_stats
    @report << '<div id="title"> General Statistics</div>'
    stat_tab = Ruport::Data::Table(%w[Stat Value])
    stat_tab << ['Number of servers Seen', @num_servers]
    stat_tab << ['Number of clients Seen', @num_clients]
    @num_by_cipher.each do |cipher, num|
      stat_tab << ['Encryption: ' + cipher, num]
    end
    @report << stat_tab.to_html
    @report << '<br /><br />'
  end

  def html_report_inf
    @report << '<div id="title">Infrastructure Networks</div><br /><br />'
    if @options.single_table
      tab = Ruport::Data::Table(%w[ssid bssid num_clients channel cipher cloaked?])
      @infrastructure_networks.each do |ssid,bssid|
        ssid = " Hidden or Blank" if ssid.length < 1
        bssid.each do |net,info|
          if @options.gps_data[net]
            point = net
            @log.debug("attempting to add link")
            link_info = '+(' + ssid + ' | Ciphers: ' + info['cipher'] + ' | Channel: ' + info['channel'] + ')'
            url = 'http://maps.google.co.uk/maps?q=' + @options.gps_data[point]['lat'].to_s + ',' + @options.gps_data[point]['lon'].to_s + link_info
            net = '<a href="' + url + '">' + point + '</a>'
          end
          tab << [ssid,net, info['clients'].length.to_s, info['channel'], info['cipher'], info['cloaked']]
        end
      end
      tab.sort_rows_by!('ssid')
      @report << tab.to_html

    else
      @infrastructure_networks.each do |ssid,bssid|
        tab = Ruport::Data::Table(%w[bssid num_clients channel cipher cloaked?])
        ssid = "Hidden or Blank" if ssid.length < 1
        @report << '<div id="title">SSID: ' + ssid + ' </div>'
        bssid.each do |net,info|
          if @options.gps_data[net]
            point = net
            @log.debug("attempting to add link")
            link_info = '+(' + ssid + ' | Ciphers: ' + info['cipher'] + ' | Channel: ' + info['channel'] + ')'
            url = 'http://maps.google.co.uk/maps?q=' + @options.gps_data[point]['lat'].to_s + ',' + @options.gps_data[point]['lon'].to_s + link_info
            net = '<a href="' + url + '">' + point + '</a>'
          end
          tab << [net, info['clients'].length.to_s, info['channel'], info['cipher'], info['cloaked']]
        end
        @report << tab.to_html
        @report << "<br /> <br />"
      end
    end
  end

  def html_report_adhoc
    @report << '<div id="title">Adhoc Networks</div><br /><br />'
    @adhoc_networks.each do |ssid,bssid|
      tab = Ruport::Data::Table(%w[bssid channel cipher cloaked?])
      ssid = "Hidden or Blank" if ssid.length < 1
      @report << '<div id="title">SSID: ' + ssid + ' </div>'
      bssid.each do |net,info|
          if @options.gps_data[net]
            point = net
            @log.debug("attempting to add link")
            link_info = '+(' + ssid + ' | Ciphers: ' + info['cipher'] + ' | Channel: ' + info['channel'] + ')'
            url = 'http://maps.google.co.uk/maps?q=' + @options.gps_data[point]['lat'].to_s + ',' + @options.gps_data[point]['lon'].to_s + link_info
            net = '<a href="' + url + '">' + point + '</a>'
          end
          tab << [net, info['channel'], info['cipher'], info['cloaked']]
      end
      @report << tab.to_html
      @report << "<br /> <br />"
    end
  end

  def html_report_map_body
    @report << '<div id="map_canvas" style="width:50%; height:50%"></div> '
  end

  
  def analyse_net(net,type)
    
    begin
      bssid = net.search('BSSID')[0].text
      essid = net.search('essid')[0].text
    rescue NoMethodError
      @log.warn("Can't find the key data for this network skipping")
      return
    end
    encryption_cipher = net.search('encryption')[0].text
    if @num_by_cipher[encryption_cipher]
      @num_by_cipher[encryption_cipher] = @num_by_cipher[encryption_cipher] + 1
    else
      @num_by_cipher[encryption_cipher] = 1
    end
    channel = net.search('channel')[0].text
    cloaked = net.search('essid')[0].attribute('cloaked').text

    #Need this hash set up for the google maps stuff
    @nets_by_bssid[bssid] = Hash.new
    @nets_by_bssid[bssid]['ssid'] = essid
    @nets_by_bssid[bssid]['channel'] = channel
    @nets_by_bssid[bssid]['cipher'] = encryption_cipher
    @nets_by_bssid[bssid]['cloaked'] = cloaked

    if type == 'inf'
      clients = Array.new

      net.search('wireless-client').each do |client|
        clients << client.search('client-mac').text
      end
    end

    if type == 'inf'
      unless @infrastructure_networks[essid]
        @infrastructure_networks[essid] = Hash.new
      end

      unless @infrastructure_networks[essid][bssid]
        @infrastructure_networks[essid][bssid] = Hash.new
      end

      @infrastructure_networks[essid][bssid]['channel'] = channel
      @infrastructure_networks[essid][bssid]['cipher'] = encryption_cipher
      @infrastructure_networks[essid][bssid]['cloaked'] = cloaked
      @infrastructure_networks[essid][bssid]['clients'] = clients
    elsif type == 'adhoc'
      unless @adhoc_networks[essid]
        @adhoc_networks[essid] = Hash.new
      end

      unless @adhoc_networks[essid][bssid]
        @adhoc_networks[essid][bssid] = Hash.new
      end

      @adhoc_networks[essid][bssid]['channel'] = channel
      @adhoc_networks[essid][bssid]['cipher'] = encryption_cipher
      @adhoc_networks[essid][bssid]['cloaked'] = cloaked
    end
  end

  def analyse_gps
    puts 'gpsin'
    
    @doc.search('wireless-network').each do |net|
      bssid = net.search('BSSID').text
      @options.gps_data[bssid] = Hash.new

      if net.search('avg-lat').length > 0
        @options.gps_data[bssid]['lat'] = net.search('avg-lat')[0].text.to_f
        @log.debug("just wrote a value of " + net.search('avg-lat')[0].text + " for " + bssid)
      else
        #Don't want a GPS point for something we've got no measurements for...
        @options.gps_data.delete(bssid)
        next
      end
      if net.search('avg-lon').length > 0
        @options.gps_data[bssid]['lon'] = net.search('avg-lon')[0].text.to_f
        @log.debug("just wrote a value of " + net.search('avg-lon')[0].text + "for " + bssid )
      end

    end
      
    
    #We need the centre point for the map if it's enabled
    if @options.create_map
      base_lat = 0.00
      base_long = 0.00
      point_count = 0
      @options.gps_data.each do |point,data|
        @log.debug("about to write data for " + point)
        base_lat = base_lat + data['lat']
        base_long = base_long + data['lon']
        point_count = point_count + 1
      end
      @map_centre = Hash.new
      @map_centre['lat'] = base_lat / point_count
      @map_centre['long'] = base_long / point_count
    end
  end
  

  def analyse_probe(probe)

  end

  
end

if __FILE__ == $0
  analysis = KisAnalysis.new(ARGV)
  analysis.analyse
  analysis.html_report
  
end
