rack_attack_admin 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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b25176b34b9df57026a574d6d1906baa6f38c807ba0390106df0753e57c16f2e
4
+ data.tar.gz: 1524e2a57ea8c775e6a8085bc10a29c6ed50b98aff069e0d3e0cb2bca6cb7359
5
+ SHA512:
6
+ metadata.gz: 984d71bfb9849fb22794beb7614a92c27466f5ace13700bc5e608afe4b5a0f913239766345dcda4b98cfbcb3f23587be3617e23eae798199549245d9c059affe
7
+ data.tar.gz: b8e4996db88b3eb01808f85ed1fa4e6407694fc3488ca9fb7ea1cb475531ca201398089426a64599f82e578f982938e18e7bf4250bff9aaf272a97c1c6f64f79
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.4.5
7
+ before_install: gem install bundler -v 2.0.1
File without changes
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in rack_attack_admin.gemspec
4
+ gemspec
data/License ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Tyler Rick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,36 @@
1
+ # Rack::Attack Admin Dashboard
2
+
3
+ Inspired by: https://www.backerkit.com/blog/building-a-rackattack-dashboard/
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'rack_attack_admin'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+
18
+ Add this line to your application's `config/routes.rb`:
19
+
20
+ ```ruby
21
+ mount RackAttackAdmin::Engine, at: '/admin/rack_attack'
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ TODO: Write usage instructions here
27
+
28
+ ## Development
29
+
30
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
31
+
32
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
33
+
34
+ ## Contributing
35
+
36
+ Bug reports and pull requests are welcome on GitHub at https://github.com/TylerRick/rack_attack_admin.
@@ -0,0 +1,31 @@
1
+ module RackAttackAdmin
2
+ class ApplicationController < ::ApplicationController
3
+ skip_authorization_check if respond_to? :skip_authorization_check
4
+ before_action :require_admin, except: [:current_request] if method_defined?(:require_admin)
5
+ before_action :toggle_flags
6
+
7
+ #═════════════════════════════════════════════════════════════════════════════════════════════════
8
+ # Helpers
9
+
10
+ def toggle_flags
11
+ cookies[:skip_safelist] = params[:skip_safelist] if params[:skip_safelist]
12
+ end
13
+
14
+ helper_method \
15
+ def is_redis?
16
+ Rack::Attack.cache.respond.store.respond_to? :ttl
17
+ end
18
+
19
+ helper_method \
20
+ def current_request_rack_attack_stats
21
+ req = Rack::Attack::Request.new(request.env)
22
+ {
23
+ blocklisted?: Rack::Attack.blocklisted?(req),
24
+ throttled?: Rack::Attack.throttled?(req),
25
+ safelisted?: Rack::Attack.safelisted?(req),
26
+ is_tracked?: Rack::Attack.is_tracked?(req),
27
+ skip_safelist?: req.skip_safelist?,
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # Inspired by: https://www.backerkit.com/blog/building-a-rackattack-dashboard/
2
+
3
+ module RackAttackAdmin
4
+ class BannedIpsController < KeysController
5
+ def create
6
+ ban = Rack::Attack::BannedIp.new(
7
+ params.require(Rack::Attack::BannedIp.model_name.param_key).
8
+ permit(:ip, :bantime)
9
+ )
10
+ case ban.bantime
11
+ when /m$/
12
+ ban.bantime = ban.bantime.to_i * ActiveSupport::Duration::SECONDS_PER_MINUTE
13
+ when /h$/
14
+ ban.bantime = ban.bantime.to_i * ActiveSupport::Duration::SECONDS_PER_HOUR
15
+ when /d$/
16
+ ban.bantime = ban.bantime.to_i * ActiveSupport::Duration::SECONDS_PER_DAY
17
+ else
18
+ ban.bantime = ban.bantime.to_i
19
+ end
20
+ if ban.valid?
21
+ Rack::Attack::BannedIps.ban! ban.ip, ban.bantime
22
+ redirect_to root_path, success: "Added: #{ban.ip}"
23
+ else
24
+ redirect_to root_path, alert: "Failed to add: #{ban.errors.full_messages}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+ module RackAttackAdmin
2
+ class KeysController < RackAttackAdmin::ApplicationController
3
+ def destroy
4
+ orig_key = params[:id]
5
+ unprefixed_key = Rack::Attack.unprefix_key(orig_key)
6
+ Rack::Attack.cache.delete unprefixed_key
7
+ redirect_to root_path, success: "Deleted: #{unprefixed_key}"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,18 @@
1
+ load 'rack/attack_extensions.rb' if Rails.env.development?
2
+
3
+ module RackAttackAdmin
4
+ class RackAttackController < RackAttackAdmin::ApplicationController
5
+ # Web version of lib/tasks/rack_attack_admin_tasks.rake
6
+ def index
7
+ @default_banned_ip = Rack::Attack::BannedIp.new(bantime: '60 m')
8
+ @banned_ip_keys = Rack::Attack::Fail2Ban.banned_ip_keys
9
+ @counters_h = Rack::Attack.counters_h.
10
+ without(*Rack::Attack::BannedIps.keys)
11
+ render
12
+ end
13
+
14
+ def current_request
15
+ render json: current_request_rack_attack_stats
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ %tr
2
+ %td= key
3
+
4
+ - ip = Rack::Attack.ip_from_key(key)
5
+ %td
6
+ = ip
7
+ (#{link_to 'Look up', "http://ip-lookup.net/?ip=#{ip}", target:'_blank'})
8
+
9
+ - if is_redis?
10
+ %td
11
+ - interval = Rack::Attack.cache.store.ttl("#{Rack::Attack.cache.prefix}:#{key}")
12
+ - if interval
13
+ =# distance_of_time_in_words(interval)
14
+ in #{ActiveSupport::Duration.build(interval)&.human_to_s}
15
+
16
+ %td= link_to 'Delete', rack_attack_admin.banned_ip_path(key), method: :delete, class: 'btn'
@@ -0,0 +1,114 @@
1
+ %h1 Manually Banned IP Addresses
2
+ %table.table.table-striped
3
+ %thead
4
+ %tr
5
+ %th Key
6
+ %th Banned IP
7
+ - if is_redis?
8
+ %th Expires
9
+ %th Actions
10
+
11
+ = render partial: 'rack_attack_admin/banned_ips/banned_ip',
12
+ collection: Rack::Attack::BannedIps.keys, as: 'key',
13
+ locals: { _:nil,
14
+ full_key_prefix: Rack::Attack::BannedIps.full_key_prefix,
15
+ }
16
+
17
+ = form_for @default_banned_ip, url: [rack_attack_admin, :banned_ips] do |f|
18
+ %tr
19
+ %td Add new:
20
+ %td= f.text_field :ip
21
+ %td= f.text_field :bantime
22
+ %td= f.submit 'Ban', class: 'btn btn-danger'
23
+
24
+ %h1 Fail2Ban/Allow2Ban Bans
25
+ %table.table.table-striped
26
+ %thead
27
+ %tr
28
+ %th Key
29
+ %th Banned IP
30
+ - if is_redis?
31
+ %th Expires
32
+ %th Actions
33
+
34
+ -#
35
+ = render partial: 'admin/rack_attack/banned_ips/banned_ip',
36
+ collection: @banned_ip_keys, as: 'key',
37
+ locals: { _:nil,
38
+ full_key_prefix: Rack::Attack::Fail2Ban.full_key_prefix,
39
+ }
40
+
41
+ %h1 Throttle/Fail2Ban Count Keys
42
+ %table.table.table-striped.mb-0
43
+ %thead
44
+ %tr
45
+ %th Type:Name
46
+ %th Discriminator
47
+ %th
48
+ Expires<br/>
49
+ (Time bucket)
50
+ %th
51
+ Value<br/>
52
+ \/Limit
53
+ %th Actions
54
+ - @counters_h.each do |key, value|
55
+ %tr
56
+ :ruby
57
+ parsed = Rack::Attack.parse_key(key)
58
+ value = value.to_i
59
+ name = Rack::Attack.humanize_key(key).sub(":#{parsed[:discriminator]}", '')
60
+ %td
61
+ = name
62
+ -# if parsed and rule = parsed[:rule]
63
+ %code= rule.inspect_with_options
64
+
65
+ %td= parsed[:discriminator]
66
+ %td
67
+ -# We can get expires_in from redis or directly from the mached throttle rule
68
+ :ruby
69
+ interval =
70
+ if is_redis?
71
+ Rack::Attack.cache.store.ttl("#{Rack::Attack.cache.prefix}:#{key}")
72
+ elsif parsed and time_range = parsed[:time_range]
73
+ (time_range.end - Time.now)
74
+ end
75
+ - if interval
76
+ in #{ActiveSupport::Duration.build(interval)&.human_to_s}<br/>
77
+
78
+ - if parsed and time_range = parsed[:time_range]
79
+ %small
80
+ (#{ time_range.begin.to_s(:time_with_s)}
81
+ %span><
82
+ \-
83
+ #{time_range.end .to_s(:time_with_s)}
84
+ \= #{time_range.duration&.human_to_s})
85
+
86
+ - limit = parsed && (rule = parsed[:rule]) && rule.limit.to_i
87
+ - over_limit = value >= limit
88
+ %td{class: ('over_limit' if over_limit), style: ('color: red' if over_limit)}
89
+ = value
90
+ - if limit
91
+ = "/#{limit}"
92
+ %td= link_to 'Delete', rack_attack_admin.key_path(key), method: :delete, class: 'btn'
93
+ .current_time.mb-2 (Current time: #{Time.now.to_s(:time_with_s)})
94
+
95
+ %h1 Flags
96
+ %table.table.table-striped
97
+ %thead
98
+ %tr
99
+ %th Key
100
+ %th Value
101
+ %tr
102
+ %td cookies[:skip_safelist]
103
+ %td= cookies[:skip_safelist]
104
+
105
+ %h1 Current Request
106
+ %table.table.table-striped
107
+ %thead
108
+ %tr
109
+ %th Key
110
+ %th Value
111
+ - current_request_rack_attack_stats.each do |key, value|
112
+ %tr
113
+ %td= key
114
+ %td= value
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "rack_attack_admin"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,6 @@
1
+ RackAttackAdmin::Engine.routes.draw do
2
+ root to: 'rack_attack#index'
3
+ get :current_request, to: 'rack_attack#current_request'
4
+ resources :banned_ips, only: [:create, :destroy], id: /.*/
5
+ resources :keys, only: [:destroy], id: /.*/
6
+ end
@@ -0,0 +1,5 @@
1
+ module RackAttackAdmin
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RackAttackAdmin
4
+ end
5
+ end
@@ -0,0 +1,355 @@
1
+ require 'memoist'
2
+ require 'active_model'
3
+
4
+ ActiveSupport::Duration.class_eval do
5
+ # Returns a concise and human-readable string, like '3 h' or '3 h, 5 m, 7 s'
6
+ # This is unlike #to_s, which is concise but not very human-readable (gives time in seconds even for large durations),
7
+ # This is unlike #to_s, which is concise but not very human-readable ("P3Y6M4DT12H30M5S").
8
+ def human_to_s
9
+ iso8601.
10
+ sub('P', '').
11
+ sub('T', '').
12
+ downcase.
13
+ gsub(/
14
+ \D # Not a digit
15
+ (?!$) # Not at end
16
+ /x) { |m| " #{m}, " }
17
+ end
18
+ end
19
+
20
+ Rack::Attack
21
+ class Rack::Attack
22
+ class << self
23
+ extend Memoist
24
+
25
+ def prefixed_keys
26
+ cache.store.keys.grep(/^rack::attack:/)
27
+ end
28
+
29
+ # AKA unprefixed_keys
30
+ def keys
31
+ prefixed_keys.map { |key|
32
+ unprefix_key(key)
33
+ }
34
+ end
35
+
36
+ def unprefix_key(key)
37
+ key.sub "#{cache.prefix}:", ''
38
+ end
39
+
40
+ def to_h
41
+ keys.each_with_object({}) do |k, h|
42
+ h[k] = cache.store.read(k)
43
+ end
44
+ end
45
+
46
+ def counters_h
47
+ (keys - Fail2Ban.banned_ip_keys).each_with_object({}) do |unprefixed_key, h|
48
+ h[unprefixed_key] = cache.read(unprefixed_key)
49
+ end
50
+ end
51
+
52
+ def ip_from_key(key)
53
+ key.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/)&.to_s
54
+ end
55
+
56
+ def humanize_h(h)
57
+ h.transform_keys do |key|
58
+ humanize_key(key)
59
+ end
60
+ end
61
+
62
+ # Reverse Cache#key_and_expiry:
63
+ # … "#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}" …
64
+ def _parse_key(unprefixed_key)
65
+ unprefixed_key.match(
66
+ /\A
67
+ (?<time_bucket>\d+) # 1 or more digits
68
+ # In the case of 'fail2ban:count:local_name', want name to onlybe 'local_name'
69
+ (?::(?:fail|allow)2ban:count)?:(?<name>.+)
70
+ :(?<discriminator>[^:]+)
71
+ \Z/x
72
+ )
73
+ end
74
+
75
+ class ParsedKey
76
+ end
77
+
78
+ # Reverse Cache#key_and_expiry:
79
+ # … "#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}" …
80
+ # @return [Hash]
81
+ # :time_bucket [Number]: The raw time bucket (Time.now / period), like 5180595
82
+ # :name [String]: The name of the rule, as passed to `throttle`, `def_fail2ban`, etc.
83
+ # :discriminator [String]: A discriminator such as a specific IP address.
84
+ # :time_range [Range]:
85
+ # (If we have enough information to calculate) A Range, like Time('12:35')..Time('12:40').
86
+ # This Range has an extra duration method that returns a ActiveSupport::Duration representing
87
+ # the duration of the period.
88
+ def parse_key(unprefixed_key)
89
+ match = _parse_key(unprefixed_key)
90
+ return unless match
91
+ match.named_captures.with_indifferent_access.tap do |hash|
92
+ hash[:rule] = rule = find_rule(hash[:name])
93
+ if (
94
+ hash[:time_bucket] and
95
+ rule and
96
+ rule.respond_to?(:period)
97
+ )
98
+ hash[:time_range] = rule.time_range(hash[:time_bucket])
99
+ end
100
+ end
101
+ end
102
+
103
+ def time_bucket_from_key(unprefixed_key)
104
+ _parse_key(unprefixed_key)&.[](:time_bucket)
105
+ end
106
+
107
+ def name_from_key(unprefixed_key)
108
+ _parse_key(unprefixed_key)&.[](:name)
109
+ end
110
+
111
+ def discriminator_from_key(unprefixed_key)
112
+ _parse_key(unprefixed_key)&.[](:discriminator)
113
+ end
114
+
115
+ def time_range(unprefixed_key)
116
+ parse_key(unprefixed_key)&.[](:time_range)
117
+ end
118
+
119
+ def find_rule(name)
120
+ throttles[name] ||
121
+ blocklists[name] ||
122
+ fail2bans[name]
123
+ end
124
+
125
+ # Transform
126
+ # rack::attack:5179628:req/ip:127.0.0.1
127
+ # into something like
128
+ # throttle('req/ip'):127.0.0.1
129
+ # so you can see which period it was for and what the limit for that period was.
130
+ # Would have to look up the rules stored in Rack::Attack.
131
+ def humanize_key(key)
132
+ key = unprefix_key(key)
133
+ match = parse_key(key)
134
+ return key unless match
135
+
136
+ name = match[:name]
137
+ rule = find_rule(name)
138
+ rule_type = rule.type if rule
139
+ "#{rule_type}('#{name}'):#{match[:discriminator]}"
140
+ end
141
+
142
+ # Unlike the provided #tracked?, this returns a boolean which is only true if one of the tracks
143
+ # matches. (The provided tracked? just returns the array of `tracks`.)
144
+ def is_tracked?(request)
145
+ tracks.any? do |_name, track|
146
+ track.matched_by?(request)
147
+ end
148
+ end
149
+ end # class << self
150
+
151
+ module PeriodIntrospection
152
+ # time_bucket is epoch_time / period
153
+ def time_range(time_bucket)
154
+ time_bucket = time_bucket.to_i
155
+ start_time = Time.at(time_bucket * period)
156
+ end_time = Time.at(start_time + period)
157
+ duration = ActiveSupport::Duration.build(end_time - start_time)
158
+
159
+ (start_time .. end_time).tap do |time_range|
160
+ # @return [ActiveSupport::Duration]
161
+ time_range.define_singleton_method :duration do
162
+ duration
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ module InspectWithOptions
169
+ def options
170
+ {}
171
+ end
172
+
173
+ # throttle('req/ip', limit: 300, period: 1.minute)
174
+ def inspect_with_options
175
+ "#{type}('#{name}', #{options.inspect})"
176
+ end
177
+ end
178
+
179
+ class Throttle
180
+ include PeriodIntrospection
181
+ include InspectWithOptions
182
+
183
+ def options
184
+ {
185
+ period: period,
186
+ limit: limit,
187
+ }
188
+ end
189
+ end
190
+
191
+
192
+ class << self
193
+ def fail2bans; @fail2bans ||= {}; end
194
+
195
+ def def_fail2ban(name, options)
196
+ self.fail2bans[name] = Fail2Ban.new( name, options.merge(type: :fail2ban))
197
+ end
198
+ def def_allow2ban(name, options)
199
+ self.fail2bans[name] = Allow2Ban.new(name, options.merge(type: :allow2ban))
200
+ end
201
+
202
+ def fail2ban(name, discriminator, klass: Fail2Ban, &block)
203
+ instance = fail2bans[name] or raise "could not find a fail2ban rule named '#{name}'; make sure you define with def_fail2ban/def_allow2ban first"
204
+ klass.filter(
205
+ "#{name}:#{discriminator}",
206
+ findtime: instance.period,
207
+ maxretry: instance.limit,
208
+ bantime: instance.bantime,
209
+ &block
210
+ )
211
+ end
212
+
213
+ def allow2ban(name, discriminator, &block)
214
+ fail2ban(name, discriminator, klass: Allow2Ban, &block)
215
+ end
216
+ end
217
+
218
+ # Make it instantiable like Throttle so we can introspect it
219
+ module InstantiableFail2Ban
220
+ MANDATORY_OPTIONS = [:limit, :period, :type].freeze
221
+
222
+ attr_reader :name, :limit, :period, :bantime, :type
223
+ def initialize(name, options)
224
+ @name = name
225
+ MANDATORY_OPTIONS.each do |opt|
226
+ raise ArgumentError.new("Must pass #{opt.inspect} option") unless options[opt]
227
+ end
228
+ @limit = options[:limit]
229
+ @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i
230
+ @bantime = options[:bantime] or raise ArgumentError, "Must pass bantime option"
231
+ @type = options[:type]
232
+ end
233
+
234
+ include PeriodIntrospection
235
+ include InspectWithOptions
236
+
237
+ def options
238
+ {
239
+ period: period,
240
+ limit: limit,
241
+ }
242
+ end
243
+ end
244
+
245
+ class Fail2Ban
246
+ include InstantiableFail2Ban
247
+
248
+ class << self
249
+ def prefixed_keys
250
+ cache.store.keys.grep(/^#{cache.prefix}:(allow|fail)2ban:/)
251
+ end
252
+
253
+ # AKA unprefixed_keys
254
+ # Removes the Rack::Attack.cache.prefix, but not 'allow2ban'
255
+ def keys
256
+ prefixed_keys.map { |key|
257
+ Rack::Attack.unprefix_key(key)
258
+ }
259
+ end
260
+
261
+ def to_h
262
+ keys.each_with_object({}) do |k, h|
263
+ h[k] = cache.store.read(k)
264
+ end
265
+ end
266
+
267
+ def banned_ip_keys
268
+ keys.grep(/(allow|fail)2ban:ban:/)
269
+ end
270
+
271
+ def full_key_prefix
272
+ "#{cache.prefix}:#{key_prefix}"
273
+ end
274
+ end
275
+ end
276
+
277
+ class BannedIp
278
+ include ActiveModel::Model
279
+ include ActiveModel::Validations
280
+ #include ActiveModel::Attributes
281
+
282
+ attr_accessor :ip
283
+ attr_accessor :bantime
284
+ #attribute :bantime, :number
285
+
286
+ validates :ip, :bantime, presence: true
287
+ end
288
+
289
+ class BannedIps
290
+ class << self
291
+ def prefixed_keys
292
+ cache.store.keys.grep(/^#{full_key_prefix}:/)
293
+ end
294
+
295
+ # Removes only the Rack::Attack.cache.prefix
296
+ def keys
297
+ prefixed_keys.map { |key|
298
+ Rack::Attack.unprefix_key(key)
299
+ }
300
+ end
301
+
302
+ def ips
303
+ prefixed_keys.map { |key|
304
+ ip_from_key(key)
305
+ }
306
+ end
307
+
308
+ def ban!(ip, bantime)
309
+ cache.write("#{key_prefix}:#{ip}", 1, bantime)
310
+ end
311
+
312
+ def banned?(ip)
313
+ cache.read("#{key_prefix}:#{ip}") ? true : false
314
+ end
315
+
316
+ def ip_from_key(key)
317
+ key = Rack::Attack.unprefix_key(key)
318
+ key.sub "#{key_prefix}:", ''
319
+ end
320
+
321
+ def full_key_prefix
322
+ "#{cache.prefix}:#{key_prefix}"
323
+ end
324
+
325
+ protected
326
+
327
+ def key_prefix
328
+ 'banned_ips'
329
+ end
330
+
331
+ private
332
+
333
+ def cache
334
+ Rack::Attack.cache
335
+ end
336
+ end
337
+ end
338
+
339
+ class Request
340
+ extend Memoist
341
+
342
+ memoize \
343
+ def headers
344
+ env.
345
+ select { |k,v| k.start_with? 'HTTP_'}.
346
+ transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }.
347
+ sort.to_h.
348
+ tap do |headers|
349
+ headers.define_singleton_method :[] do |k|
350
+ super(k.split(/[-_]/).map(&:capitalize).join('-'))
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,8 @@
1
+ require 'rails'
2
+ require 'rack/attack'
3
+
4
+ # TODO: Figure out why renaming it to be this (consistent) path causes it to not load
5
+ # config/routes.rb and to not add routes even if we force it to be loaded.
6
+ #require "rack_attack_admin/engine"
7
+ require "attack_admin/engine"
8
+ require_relative 'rack/attack_extensions'
@@ -0,0 +1,5 @@
1
+ module RackAttackAdmin
2
+ def self.version
3
+ "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,76 @@
1
+ begin
2
+ require 'terminal-table'
3
+ rescue LoadError
4
+ puts "You must `gem install terminal-table` in order to use the rake tasks in #{__FILE__}"
5
+ end
6
+
7
+ namespace :rack_attack_admin do
8
+ def clear
9
+ puts "\e[H\e[2J"
10
+ end
11
+
12
+ desc "Watch the internal state of Rack::Attack. Similar to /admin/rack_attack but auto-refreshes, and shows previous value if there was a change. (Mostly useful in dev where there aren't many keys and they don't change very often.)"
13
+ task :watch do
14
+ interval = ENV['interval']&.to_i || 1
15
+
16
+ curr_h = {}
17
+ prev_h = {}
18
+ prev_h_s_ago = 0
19
+
20
+ curr_banned = []
21
+ prev_banned = []
22
+ prev_banned_s_ago = 0
23
+
24
+ loop do
25
+ clear
26
+
27
+ if curr_banned != Rack::Attack::Fail2Ban.banned_ip_keys
28
+ prev_banned = curr_banned
29
+ prev_banned_s_ago = 0
30
+ curr_banned = Rack::Attack::Fail2Ban.banned_ip_keys
31
+ end
32
+
33
+ if curr_h != Rack::Attack.counters_h
34
+ prev_h = curr_h
35
+ prev_h_s_ago = 0
36
+ curr_h = Rack::Attack.counters_h
37
+ end
38
+
39
+ puts Terminal::Table.new(
40
+ headings: ['Banned IP', "Previous (#{prev_h_s_ago} s ago)"],
41
+ rows: [].tap { |rows|
42
+ while (
43
+ row = [
44
+ curr_banned.shift,
45
+ prev_banned.shift
46
+ ]
47
+ row.any?
48
+ ) do
49
+ row = row.map {|key| key && Rack::Attack.humanize_key(key) }
50
+ rows << row
51
+ end
52
+ }
53
+ )
54
+
55
+ keys = (
56
+ curr_h.keys |
57
+ prev_h.keys
58
+ )
59
+ rows = keys.map do |key|
60
+ [
61
+ "%-80s" % Rack::Attack.humanize_key(key),
62
+ curr_h[key],
63
+ prev_h[key],
64
+ ]
65
+ end
66
+ puts Terminal::Table.new(
67
+ headings: ['Key', 'Current Count', "Previous (#{prev_h_s_ago} s ago)"],
68
+ rows: rows
69
+ )
70
+
71
+ sleep interval
72
+ prev_h_s_ago += interval
73
+ prev_banned_s_ago += interval
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,40 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "rack_attack_admin/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "rack_attack_admin"
8
+ spec.version = RackAttackAdmin.version
9
+ spec.authors = ["Tyler Rick"]
10
+ spec.email = ["tyler@tylerrick.com"]
11
+ spec.license = "MIT"
12
+
13
+ spec.summary = %q{A Rack::Attack admin dashboard}
14
+ spec.description = %q{Lets you see the current state of all throttles and bans. Delete existing keys/bans. Manually add bans.}
15
+ spec.homepage = "https://github.com/TylerRick/rack_attack_admin"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.metadata["source_code_uri"]}/blob/master/Changelog.md"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.required_ruby_version = ">= 2.3.0"
31
+ spec.add_dependency "activesupport", [">= 4.2", "< 5.3"]
32
+ spec.add_dependency "haml"
33
+ spec.add_dependency "memoist"
34
+ spec.add_dependency "rack-attack"
35
+ spec.add_dependency "rails", [">= 4.2", "< 5.3"]
36
+
37
+ spec.add_development_dependency "bundler", "~> 2.0"
38
+ spec.add_development_dependency "rake", "~> 10.0"
39
+ spec.add_development_dependency "rspec", "~> 3.0"
40
+ end
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack_attack_admin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Rick
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-04-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.3'
33
+ - !ruby/object:Gem::Dependency
34
+ name: haml
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: memoist
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rack-attack
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rails
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '4.2'
82
+ - - "<"
83
+ - !ruby/object:Gem::Version
84
+ version: '5.3'
85
+ type: :runtime
86
+ prerelease: false
87
+ version_requirements: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '4.2'
92
+ - - "<"
93
+ - !ruby/object:Gem::Version
94
+ version: '5.3'
95
+ - !ruby/object:Gem::Dependency
96
+ name: bundler
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '2.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '2.0'
109
+ - !ruby/object:Gem::Dependency
110
+ name: rake
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '10.0'
116
+ type: :development
117
+ prerelease: false
118
+ version_requirements: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '10.0'
123
+ - !ruby/object:Gem::Dependency
124
+ name: rspec
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.0'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '3.0'
137
+ description: Lets you see the current state of all throttles and bans. Delete existing
138
+ keys/bans. Manually add bans.
139
+ email:
140
+ - tyler@tylerrick.com
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - ".gitignore"
146
+ - ".rspec"
147
+ - ".travis.yml"
148
+ - Changelog.md
149
+ - Gemfile
150
+ - License
151
+ - Rakefile
152
+ - Readme.md
153
+ - app/controllers/rack_attack_admin/application_controller.rb
154
+ - app/controllers/rack_attack_admin/banned_ips_controller.rb
155
+ - app/controllers/rack_attack_admin/keys_controller.rb
156
+ - app/controllers/rack_attack_admin/rack_attack_controller.rb
157
+ - app/views/rack_attack_admin/banned_ips/_banned_ip.html.haml
158
+ - app/views/rack_attack_admin/rack_attack/index.html.haml
159
+ - bin/console
160
+ - bin/setup
161
+ - config/routes.rb
162
+ - lib/attack_admin/engine.rb
163
+ - lib/rack/attack_extensions.rb
164
+ - lib/rack_attack_admin.rb
165
+ - lib/rack_attack_admin/version.rb
166
+ - lib/tasks/rack_attack_admin_tasks.rake
167
+ - rack_attack_admin.gemspec
168
+ homepage: https://github.com/TylerRick/rack_attack_admin
169
+ licenses:
170
+ - MIT
171
+ metadata:
172
+ homepage_uri: https://github.com/TylerRick/rack_attack_admin
173
+ source_code_uri: https://github.com/TylerRick/rack_attack_admin
174
+ changelog_uri: https://github.com/TylerRick/rack_attack_admin/blob/master/Changelog.md
175
+ post_install_message:
176
+ rdoc_options: []
177
+ require_paths:
178
+ - lib
179
+ required_ruby_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: 2.3.0
184
+ required_rubygems_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '0'
189
+ requirements: []
190
+ rubygems_version: 3.0.1
191
+ signing_key:
192
+ specification_version: 4
193
+ summary: A Rack::Attack admin dashboard
194
+ test_files: []