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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/Changelog.md +0 -0
- data/Gemfile +4 -0
- data/License +20 -0
- data/Rakefile +6 -0
- data/Readme.md +36 -0
- data/app/controllers/rack_attack_admin/application_controller.rb +31 -0
- data/app/controllers/rack_attack_admin/banned_ips_controller.rb +28 -0
- data/app/controllers/rack_attack_admin/keys_controller.rb +10 -0
- data/app/controllers/rack_attack_admin/rack_attack_controller.rb +18 -0
- data/app/views/rack_attack_admin/banned_ips/_banned_ip.html.haml +16 -0
- data/app/views/rack_attack_admin/rack_attack/index.html.haml +114 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config/routes.rb +6 -0
- data/lib/attack_admin/engine.rb +5 -0
- data/lib/rack/attack_extensions.rb +355 -0
- data/lib/rack_attack_admin.rb +8 -0
- data/lib/rack_attack_admin/version.rb +5 -0
- data/lib/tasks/rack_attack_admin_tasks.rake +76 -0
- data/rack_attack_admin.gemspec +40 -0
- metadata +194 -0
checksums.yaml
ADDED
@@ -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
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Changelog.md
ADDED
File without changes
|
data/Gemfile
ADDED
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.
|
data/Rakefile
ADDED
data/Readme.md
ADDED
@@ -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
|
data/bin/console
ADDED
@@ -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__)
|
data/bin/setup
ADDED
data/config/routes.rb
ADDED
@@ -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,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: []
|