mayu 0.1.0.beta1

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,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