rack_attack_admin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []