wifidiag 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ require 'bundler/setup'
2
+ require 'wifidiag'
3
+
4
+ config = Wifidiag::Config.new(
5
+ adapter: Wifidiag::Adapters::CiscoWlc.new(
6
+ host: ENV.fetch('WLC_HOST'),
7
+ community: ENV.fetch('WLC_COMMUNITY'),
8
+ ),
9
+ reporters: [
10
+ ENV['WIFIDIAG_SLACK_WEBHOOK_URL'] ? Wifidiag::Reporters::Slack.new(
11
+ webhook_url: ENV['WIFIDIAG_SLACK_WEBHOOK_URL'],
12
+ ) : nil,
13
+ ].compact,
14
+ dummy_ip: ENV['WIFIDIAG_DUMMY_IP'],
15
+ )
16
+
17
+ config.collector.start_periodic_update(60)
18
+
19
+ run Wifidiag.app(config)
@@ -0,0 +1,9 @@
1
+ require 'wifidiag/version'
2
+
3
+ require 'wifidiag/config'
4
+
5
+ require 'wifidiag/adapters/cisco_wlc'
6
+ require 'wifidiag/reporters/slack'
7
+
8
+ require 'wifidiag/app'
9
+
@@ -0,0 +1,12 @@
1
+ module Wifidiag
2
+ module Adapters
3
+ class Base
4
+ def initialize()
5
+ end
6
+
7
+ def collect
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ require 'wlc_snmp'
2
+
3
+ require 'wifidiag/adapters/base'
4
+
5
+ require 'wifidiag/ap_data'
6
+ require 'wifidiag/client_data'
7
+
8
+ module Wifidiag
9
+ module Adapters
10
+ class CiscoWlc
11
+ def initialize(host:, port: 161, community:)
12
+ @host = host
13
+ @port = port
14
+ @community = community
15
+ end
16
+
17
+ def collect
18
+ aps = {}
19
+ wlc.clients.map do |client|
20
+ if client.ap
21
+ ap = aps.fetch(client.ap_mac) do
22
+ aps[client.ap_mac] = ApData.new(
23
+ name: client.ap.name,
24
+ mac_address: client.ap.mac_address,
25
+ location: client.ap.location,
26
+ model: client.ap.model,
27
+ )
28
+ end
29
+ end
30
+ ClientData.new(
31
+ mac_address: client.mac_address,
32
+ ip_address: client.ip_address,
33
+ ap: ap,
34
+ wlan_profile: client.wlan_profile,
35
+ protocol: client.protocol,
36
+ ap_mac: client.ap_mac,
37
+ uptime: client.uptime,
38
+ current_rate: client.current_rate,
39
+ supported_data_rates: client.supported_data_rates,
40
+ user: client.user,
41
+ ssid: client.ssid,
42
+ )
43
+ end
44
+ end
45
+
46
+ def wlc
47
+ @snmp ||= WlcSnmp::Client.new(host: @host, port: @port, community: @community)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ module Wifidiag
2
+ class ApData
3
+ def initialize(name: , mac_address: nil, **kwargs)
4
+ @name = name
5
+ @mac_address = mac_address
6
+ @additional_data = kwargs
7
+ end
8
+
9
+ attr_reader :name, :mac_address
10
+ attr_reader :additional_data
11
+
12
+
13
+ def to_h
14
+ {
15
+ name: name,
16
+ mac_address: mac_address,
17
+ additional_data: additional_data,
18
+ }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,123 @@
1
+ require 'sinatra/base'
2
+ require 'json'
3
+
4
+ require 'wifidiag/report'
5
+
6
+ module Wifidiag
7
+ def self.app(*args)
8
+ App.rack(*args)
9
+ end
10
+
11
+ class App < Sinatra::Base
12
+ class Boom < StandardError; end
13
+
14
+ CONTEXT_RACK_ENV_NAME = 'wifidiag.ctx'
15
+
16
+ set :root, File.expand_path(File.join(__dir__, '..', '..', 'app'))
17
+
18
+ def self.initialize_context(config)
19
+ {
20
+ config: config,
21
+ revision: self.revision(),
22
+ }
23
+ end
24
+
25
+ def self.revision
26
+ path = File.join(__dir__, '..', '..', 'REVISION')
27
+ if File.exist?(path)
28
+ File.read(path).chomp
29
+ else
30
+ nil
31
+ end
32
+ end
33
+
34
+ def self.rack(config={})
35
+ klass = App
36
+
37
+ context = initialize_context(config)
38
+ app = lambda { |env|
39
+ env[CONTEXT_RACK_ENV_NAME] = context
40
+ klass.call(env)
41
+ }
42
+ end
43
+
44
+ helpers do
45
+ def context
46
+ request.env[CONTEXT_RACK_ENV_NAME]
47
+ end
48
+
49
+ def conf
50
+ context[:config]
51
+ end
52
+
53
+ def collector
54
+ conf.collector
55
+ end
56
+
57
+ def revision
58
+ context[:revision]
59
+ end
60
+
61
+ TRUSTED_IPS = /\A127\.0\.0\.1\Z|\A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|\A::1\Z|\Afd[0-9a-f]{2}:.+|\Alocalhost\Z|\Aunix\Z|\Aunix:/i
62
+ def client_ip
63
+ return conf[:dummy_ip] if conf[:dummy_ip]
64
+ @client_ip ||= begin
65
+ remote_addrs = request.get_header('REMOTE_ADDR')&.split(/,\s*/)
66
+ filtered_remote_addrs = remote_addrs.grep_v(TRUSTED_IPS)
67
+
68
+ if filtered_remote_addrs.empty? && request.get_header('HTTP_X_FORWARDED_FOR')
69
+ forwarded_ips = request.get_header('HTTP_X_FORWARDED_FOR')&.split(/,\s*/)
70
+ filtered_forwarded_ips = forwarded_ips.grep_v(TRUSTED_IPS)
71
+
72
+ filtered_forwarded_ips.empty? ? forwarded_ips.first : remote_addrs.first
73
+ else
74
+ filtered_remote_addrs.first || remote_addrs.first
75
+ end
76
+ end
77
+ end
78
+
79
+ def data
80
+ begin
81
+ @data = JSON.parse(request.body.tap(&:rewind).read)
82
+ rescue JSON::ParserError
83
+ halt 400, '{"error": "invalid_payload"}'
84
+ end
85
+ end
86
+
87
+ end
88
+
89
+ configure do
90
+ enable :logging
91
+ end
92
+
93
+ get '/' do
94
+ @client = conf.collector.client_data_for_ip_address(client_ip)
95
+ erb :index
96
+ end
97
+
98
+ get '/api/self' do
99
+ content_type :json
100
+ data = conf.collector.client_data_for_ip_address(client_ip)
101
+ if data
102
+ data.to_h.to_json
103
+ else
104
+ halt 404, {error: :not_found, ip: client_ip}.to_json
105
+ end
106
+ end
107
+
108
+ post '/api/report' do
109
+ content_type :json
110
+ report = Report.new(
111
+ client_ip,
112
+ conf.collector.client_data_for_ip_address(client_ip),
113
+ data['data'] || {},
114
+ )
115
+
116
+ conf[:reporters].each do |x|
117
+ x.report! report
118
+ end
119
+
120
+ '{"status": "ok"}'
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,24 @@
1
+ module Wifidiag
2
+ class ClientData
3
+ def initialize(ip_address: , mac_address: nil, ssid: nil, ap: nil, **kwargs)
4
+ @ip_address = ip_address
5
+ @mac_address = mac_address
6
+ @ssid = ssid
7
+ @additional_data = kwargs
8
+ @ap = ap
9
+ end
10
+
11
+ attr_reader :ip_address, :mac_address, :ssid, :ap
12
+ attr_reader :additional_data
13
+
14
+ def to_h
15
+ {
16
+ ip_address: ip_address,
17
+ mac_address: mac_address,
18
+ ssid: ssid,
19
+ ap: ap.to_h,
20
+ additional_data: additional_data,
21
+ }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ require 'thread'
2
+
3
+ module Wifidiag
4
+ # Collect client and AP information from +an adapter+
5
+ class Collector
6
+ def initialize(adapter)
7
+ @lock = Mutex.new
8
+
9
+ @adapter = adapter
10
+
11
+ @clients = nil
12
+ @clients_by_ip_address = nil
13
+ @clients_by_mac_address = nil
14
+ @last_update = nil
15
+ end
16
+
17
+ attr_reader :adapter, :last_update
18
+
19
+ def start_periodic_update(interval) # XXX:
20
+ self.collect
21
+
22
+ Thread.new do
23
+ loop do
24
+ begin
25
+ self.collect
26
+ sleep interval
27
+ rescue Exception => e
28
+ $stderr.puts "Periodic update error: #{e.inspect}"
29
+ e.backtrace.each do |x|
30
+ $stderr.puts "\t#{x}"
31
+ end
32
+ sleep interval
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def collect
39
+ @lock.synchronize do
40
+ clients = adapter.collect()
41
+ clients_by_ip_address = clients.map { |_| [_.ip_address, _] }.to_h
42
+ clients_by_mac_address = clients.map { |_| [_.mac_address, _] }.to_h
43
+ @clients = clients
44
+ @clients_by_ip_address = clients_by_ip_address
45
+ @clients_by_mac_address = clients_by_mac_address
46
+ @last_update = Time.now
47
+ end
48
+ end
49
+
50
+ def client_data_for_ip_address(address)
51
+ @clients_by_ip_address[address]
52
+ end
53
+
54
+ def client_data_for_mac_address(address)
55
+ @clients_by_mac_address[address]
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ require 'wifidiag/collector'
2
+
3
+ module Wifidiag
4
+ class Config
5
+ def initialize(hash)
6
+ @hash = hash
7
+ end
8
+
9
+ def [](k)
10
+ @hash[k]
11
+ end
12
+
13
+ def fetch(*args)
14
+ @hash.fetch(*args)
15
+ end
16
+
17
+ def dig(*args)
18
+ @hash.dig(*args)
19
+ end
20
+
21
+ def adapter
22
+ @hash.fetch(:adapter)
23
+ end
24
+
25
+ def collector
26
+ @collector ||= Collector.new(adapter)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ module Wifidiag
2
+ class Report
3
+ def initialize(client_ip, client_data, advanced_data)
4
+ @client_ip = client_ip
5
+ @client_data = client_data
6
+ @advanced_data = advanced_data
7
+ end
8
+
9
+ attr_reader :client_ip, :client_data, :advanced_data
10
+
11
+ def to_h
12
+ {
13
+ client_ip: client_ip,
14
+ client_data: client_data.to_h,
15
+ advanced_data: advanced_data.to_h,
16
+ }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ module Wifidiag
2
+ module Reporters
3
+ class Base
4
+ def report!(report)
5
+ raise NotImplementedError
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,68 @@
1
+ require 'uri'
2
+ require 'net/http'
3
+ require 'net/https'
4
+ require 'wifidiag/reporters/base'
5
+
6
+ module Wifidiag
7
+ module Reporters
8
+ class Slack < Base
9
+ def initialize(webhook_url:)
10
+ @webhook_url = URI.parse(webhook_url)
11
+ end
12
+
13
+ def report!(report)
14
+ Net::HTTP.post_form(
15
+ @webhook_url,
16
+ payload: {
17
+ text: "Wi-Fi diagnostic received!" + (report.advanced_data.dig('client', 'q') ? " (#{report.advanced_data['client']['q'].inspect})" : ''),
18
+ mrkdwn: true,
19
+ attachments: [
20
+ {
21
+ fallback: report.to_h.to_json,
22
+ text: "Expand to see raw JSON:\n\n\n\n\n\n```\n#{JSON.pretty_generate(report.to_h)}\n```",
23
+ mrkdwn_in: ['text'],
24
+ fields: [
25
+ report.client_data&.ssid ? {
26
+ title: 'SSID',
27
+ value: report.client_data.ssid,
28
+ short: true,
29
+ } : nil,
30
+ report.client_data&.ap ? {
31
+ title: 'AP',
32
+ value: report.client_data.ap&.name || report.client_data.ap&.mac_address || 'n/a',
33
+ short: true,
34
+ } : nil,
35
+ {
36
+ title: 'IP address',
37
+ value: report.client_ip,
38
+ short: true,
39
+ },
40
+ report.advanced_data['bandwidth']&.dig('mbps') ? {
41
+ title: 'Bandwidth',
42
+ value: "%.4f Mbps" % report.advanced_data['bandwidth']&.dig('mbps'),
43
+ short: true,
44
+ } : nil,
45
+ report.advanced_data['latency'] ? {
46
+ title: 'Latency',
47
+ value: "pkt: ok=%d ng=%d rate=%.1f%%\nrtt: min=%.2f avg=%.2f max=%.2f mdev=%.2f time=%.2f ms" \
48
+ % report.advanced_data['latency']&.values_at(
49
+ 'ok', 'fail', 'rate',
50
+ 'min', 'avg', 'max', 'mdev',
51
+ 'time',
52
+ ).map{ |_| _ == nil ? -1 : _ },
53
+ short: false,
54
+ } : nil,
55
+ report.advanced_data['client']['ua'] ? {
56
+ title: 'User agent',
57
+ value: report.advanced_data['client']['ua'],
58
+ short: false,
59
+ } : nil,
60
+ ].compact,
61
+ }
62
+ ]
63
+ }.to_json
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end