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