mayu 0.1.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require 'mayu/version'
2
+ require 'mayu/cisco_wlc_collector'
3
+ require 'mayu/periodic_loader'
4
+ require 'mayu/loader'
5
+ require 'mayu/stores/file'
6
+ require 'mayu/stores/s3'
7
+ require 'mayu/stores/concat'
8
+ require 'mayu/app'
@@ -0,0 +1,43 @@
1
+ require 'mayu/relation'
2
+
3
+ module Mayu
4
+ Ap = Struct.new(:key, :name, :description, :map_key, :map_x, :map_y, keyword_init: true) do
5
+ include Mayu::Relation
6
+
7
+ def self.load(obj)
8
+ new(**obj)
9
+ end
10
+
11
+ relates :map
12
+
13
+ relates :associations
14
+
15
+ def devices
16
+ @devices ||= associations.map(&:device).compact
17
+ end
18
+ def users
19
+ @users ||= devices.uniq(&:user_key).map(&:user).compact
20
+ end
21
+
22
+ def associations_count
23
+ associations.size
24
+ end
25
+ def devices_count
26
+ devices.size
27
+ end
28
+ def users_count
29
+ users.size
30
+ end
31
+
32
+ def as_json
33
+ {
34
+ key: key,
35
+ name: name,
36
+ description: description,
37
+ map_key: map_key,
38
+ map_x: map_x,
39
+ map_y: map_y,
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,246 @@
1
+ require 'sinatra/base'
2
+ require 'mayu/renderer'
3
+
4
+ module Mayu
5
+ def self.app(*args)
6
+ App.rack(*args)
7
+ end
8
+
9
+ class App < Sinatra::Base
10
+ CONTEXT_RACK_ENV_NAME = 'mayu.ctx'
11
+ set :root, File.expand_path(File.join(__dir__, '..', '..', 'app'))
12
+
13
+ def self.initialize_context(config)
14
+ {
15
+ loader: PeriodicLoader.new(store: config.fetch(:store), interval: config.fetch(:interval, 60).to_i),
16
+ slack_slash_command_token: config[:slack_slash_command_token],
17
+ }
18
+ end
19
+
20
+ def self.rack(config={})
21
+ klass = App
22
+
23
+ context = initialize_context(config)
24
+ lambda { |env|
25
+ env[CONTEXT_RACK_ENV_NAME] = context
26
+ klass.call(env)
27
+ }
28
+ end
29
+
30
+ configure do
31
+ enable :logging
32
+ end
33
+
34
+ helpers do
35
+ def context
36
+ request.env[CONTEXT_RACK_ENV_NAME]
37
+ end
38
+
39
+ def periodic_loader
40
+ context.fetch(:loader)
41
+ end
42
+
43
+ def loader
44
+ periodic_loader.loader
45
+ end
46
+
47
+ def dummy_ip
48
+ context[:dummy_ip]
49
+ end
50
+
51
+ def slack_slash_command_token
52
+ context[:slack_slash_command_token]
53
+ end
54
+
55
+ 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
56
+ def client_ip
57
+ return dummy_ip if dummy_ip
58
+ @client_ip ||= begin
59
+ remote_addrs = request.get_header('REMOTE_ADDR')&.split(/,\s*/)
60
+ filtered_remote_addrs = remote_addrs.grep_v(TRUSTED_IPS)
61
+
62
+ if filtered_remote_addrs.empty? && request.get_header('HTTP_X_FORWARDED_FOR')
63
+ forwarded_ips = request.get_header('HTTP_X_FORWARDED_FOR')&.split(/,\s*/)
64
+ filtered_forwarded_ips = forwarded_ips.grep_v(TRUSTED_IPS)
65
+
66
+ filtered_forwarded_ips.empty? ? forwarded_ips.first : remote_addrs.first
67
+ else
68
+ filtered_remote_addrs.first || remote_addrs.first
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ before do
75
+ periodic_loader.start
76
+ end
77
+
78
+ get '/' do
79
+ render :index
80
+ end
81
+
82
+ get '/api/search' do
83
+ content_type :json
84
+ if params[:q].nil? || params[:q].to_s.empty?
85
+ halt 400, '{"error": "missing_params"}'
86
+ end
87
+
88
+ Renderer.new(
89
+ users: [
90
+ :associated_device_kinds,
91
+ ],
92
+ ).render(
93
+ users: loader.suggest_users(params[:q]),
94
+ ).to_json
95
+ end
96
+
97
+ get '/api/self' do
98
+ content_type :json
99
+ assoc = loader.find_association_by_ip(client_ip)
100
+ if assoc
101
+ Renderer.new(
102
+ client_ip: client_ip,
103
+ association: [
104
+ :mac,
105
+ :ip,
106
+ user: [devices: [:mac]],
107
+ device: [:mac],
108
+ ap: :map,
109
+ ],
110
+ ).render(
111
+ association: assoc,
112
+ ).to_json
113
+ else
114
+ {
115
+ client_ip: client_ip,
116
+ }.to_json
117
+ end
118
+ end
119
+
120
+ get '/api/maps' do
121
+ content_type :json
122
+ Renderer.new(
123
+ maps: [
124
+ :associations_count,
125
+ :devices_count,
126
+ :users_count,
127
+ ],
128
+ ).render(
129
+ maps: loader.maps.values,
130
+ ).to_json
131
+ end
132
+
133
+ get '/api/maps/:key' do
134
+ content_type :json
135
+ map = loader.find_map(params[:key])
136
+ unless map
137
+ halt 404, '{"error": "not_found"}'
138
+ end
139
+ Renderer.new(
140
+ map: [
141
+ :associations_count,
142
+ :devices_count,
143
+ :users_count,
144
+ aps: [
145
+ :associations_count,
146
+ :devices_count,
147
+ :users_count,
148
+ ],
149
+ devices: [
150
+ :association,
151
+ :user,
152
+ ],
153
+ ],
154
+ ).render(
155
+ map: map,
156
+ ).to_json
157
+ end
158
+
159
+ get '/api/users/:key' do
160
+ content_type :json
161
+ user = loader.find_user(params[:key])
162
+ unless user
163
+ halt 404, '{"error": "not_found"}'
164
+ end
165
+ Renderer.new(
166
+ user: [
167
+ :associated_device_kinds,
168
+ devices: [
169
+ association: [
170
+ ap: :map,
171
+ ]
172
+ ],
173
+ ],
174
+ ).render(
175
+ user: user,
176
+ ).to_json
177
+ end
178
+
179
+ get '/api/aps/:key' do
180
+ content_type :json
181
+ ap = loader.find_ap(params[:key])
182
+ unless ap
183
+ halt 404, '{"error": "not_found"}'
184
+ end
185
+ Renderer.new(
186
+ ap: [
187
+ :associations_count,
188
+ :devices_count,
189
+ :users_count,
190
+ devices: [
191
+ :association,
192
+ :user,
193
+ ],
194
+ ],
195
+ ).render(
196
+ ap: ap,
197
+ ).to_json
198
+ end
199
+
200
+ post '/api/slack' do
201
+ content_type :json
202
+ if slack_slash_command_token
203
+ if params[:token] != slack_slash_command_token
204
+ halt 401, '{"error": "invalid_token"}'
205
+ end
206
+ end
207
+ if params[:text].empty?
208
+ halt(200, {"text": ":question: Who should I locate?"}.to_json)
209
+ end
210
+
211
+ text = params[:text]
212
+ me = false
213
+ if text.match?(/\Ame\s+/)
214
+ text = text.sub(/\Ame\s+/, '')
215
+ me = true
216
+ end
217
+ text = text.sub(/\A@/,'')
218
+
219
+ users = loader.suggest_users(text)
220
+ if users.empty?
221
+ halt(200, {"text": ":ghost: Couldn't find any users named #{params[:text].inspect}."}.to_json)
222
+ end
223
+
224
+ emojimap = proc { |_|
225
+ {'phone' => ':phone:', 'pc' => ':computer:'}[_.to_s] || _.to_s
226
+ }
227
+
228
+ text = []
229
+ users.first(3).each do |user|
230
+ if user.associations.empty?
231
+ text << "*#{user.name} (#{user.aliases.first})* _not available_"
232
+ else
233
+ associated_emoji = user.associated_device_kinds.map(&emojimap).join(' ')
234
+ text << "*#{user.name} (#{user.aliases.first})* #{associated_emoji}"
235
+ user.associations.each do |x|
236
+ time = x.updated_at.strftime('%m/%d %H:%M')
237
+ descr = (x.ap.description && !x.ap.description.empty?) ? "_#{x.ap.description}_" : ""
238
+ text << "- #{emojimap[x.device.kind]} #{x.ap.name} #{descr} (#{time}-)"
239
+ end
240
+ end
241
+ text << ''
242
+ end
243
+ {response_type: me ? 'ephemeral' : 'in_channel', text: text.join("\n")}.to_json
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,34 @@
1
+ require 'mayu/relation'
2
+
3
+ module Mayu
4
+ Association = Struct.new(:mac, :ip, :user_key, :ap_key, :updated_at, :appeared_at, :disappeared_at, keyword_init: true) do
5
+ include Mayu::Relation
6
+
7
+ def self.load(obj)
8
+ new(**obj)
9
+ end
10
+
11
+ def device_key
12
+ mac
13
+ end
14
+
15
+ relates :ap
16
+ relates :user
17
+ relates :device
18
+
19
+ alias found_user user
20
+ def user
21
+ user_key ? found_user : device.user
22
+ end
23
+
24
+ def as_json
25
+ {
26
+ user_key: user&.key,
27
+ ap_key: ap_key,
28
+ updated_at: updated_at,
29
+ appeared_at: appeared_at,
30
+ disappeared_at: disappeared_at,
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,102 @@
1
+ require 'wlc_snmp'
2
+ require 'mayu/association'
3
+
4
+ module Mayu
5
+ class CiscoWlcCollector
6
+ def initialize(host:, community: 'public', store:, ap_mac_for_key: false, use_wlc_user: false, ttl: 300, last_associations: nil)
7
+ @host = host
8
+ @community = community
9
+ @store = store
10
+ @ap_mac_for_key = ap_mac_for_key
11
+ @use_wlc_user = use_wlc_user
12
+ @last_associations = last_associations
13
+ @ttl = ttl
14
+ end
15
+
16
+ attr_reader :host, :community, :store
17
+ attr_reader :ttl
18
+
19
+ def ap_mac_for_key?
20
+ @ap_mac_for_key
21
+ end
22
+
23
+ def use_wlc_user?
24
+ @use_wlc_user
25
+ end
26
+
27
+ def perform!
28
+ store.put(associations: associations.map(&:to_h))
29
+ end
30
+
31
+ def time
32
+ @time ||= Time.now
33
+ end
34
+
35
+ def associations
36
+ @associations = []
37
+
38
+ new_mac_addresses = current_associations_by_mac.keys - last_associations_by_mac.keys
39
+ left_mac_addresses = last_associations_by_mac.keys - current_associations_by_mac.keys
40
+ kept_mac_addresses = last_associations_by_mac.keys & current_associations_by_mac.keys
41
+
42
+ left_mac_addresses.each do |mac|
43
+ assoc = last_associations_by_mac.fetch(mac)
44
+ unless assoc.disappeared_at
45
+ assoc = assoc.dup
46
+ assoc.disappeared_at = time
47
+ assoc.updated_at = time
48
+ end
49
+ @associations << assoc
50
+ end
51
+ new_mac_addresses.each do |mac|
52
+ @associations << current_associations_by_mac.fetch(mac)
53
+ end
54
+
55
+ kept_mac_addresses.each do |mac|
56
+ last = last_associations_by_mac.fetch(mac)
57
+ current = current_associations_by_mac.fetch(mac)
58
+ if last.ap_key != current.ap_key || last.ip != current.ip
59
+ @associations << current
60
+ else
61
+ @associations << last
62
+ end
63
+ end
64
+
65
+ @associations.reject! do |assoc|
66
+ assoc.disappeared_at && (time - assoc.disappeared_at) >= ttl
67
+ end
68
+
69
+ @associations.sort_by! do |assoc|
70
+ assoc.mac
71
+ end
72
+
73
+ @associations
74
+ end
75
+
76
+ def last_associations_by_mac
77
+ @last_associations ||= begin
78
+ assocs = store.get&.yield_self { |data|
79
+ data.fetch(:associations).map { |_| Association.load(_) }
80
+ } || []
81
+ assocs.map{ |_| [_.mac, _] }.to_h
82
+ end
83
+ end
84
+
85
+ def current_associations_by_mac
86
+ @current_associations ||= wlc.clients.map do |client|
87
+ Association.new(
88
+ mac: client.mac_address,
89
+ ip: client.ip_address,
90
+ ap_key: ap_mac_for_key? ? client.ap.mac_address : client.ap.name,
91
+ user_key: use_wlc_user? ? (client.user != 'NA' ? client.user : nil) : nil,
92
+ appeared_at: time - client.uptime,
93
+ updated_at: time,
94
+ )
95
+ end.map{ |_| [_.mac, _] }.to_h
96
+ end
97
+
98
+ def wlc
99
+ @wlc ||= WlcSnmp::Client.new(host: host, community: community)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,23 @@
1
+ require 'mayu/relation'
2
+
3
+ module Mayu
4
+ Device = Struct.new(:key, :user_key, :mac, :kind, :note, keyword_init: true) do
5
+ include Mayu::Relation
6
+
7
+ def self.load(obj)
8
+ new(**obj)
9
+ end
10
+
11
+ relates :user
12
+
13
+ relates :association
14
+
15
+ def as_json
16
+ {
17
+ key: key,
18
+ user_key: user_key,
19
+ kind: kind,
20
+ }
21
+ end
22
+ end
23
+ end