redis_knock 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/README.md +80 -0
- data/Rakefile +1 -0
- data/UNLICENSE +25 -0
- data/lib/redis-knock.rb +4 -0
- data/lib/redis_knock/connection_error.rb +7 -0
- data/lib/redis_knock/control.rb +48 -0
- data/lib/redis_knock/middleware.rb +30 -0
- data/lib/redis_knock/version.rb +5 -0
- data/lib/redis_knock.rb +12 -0
- data/redis_knock.gemspec +25 -0
- data/spec/lib/redis_knock/control_spec.rb +103 -0
- data/spec/lib/redis_knock/middleware_spec.rb +47 -0
- data/spec/spec_helper.rb +14 -0
- metadata +109 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
Ruby HTTP Throttle Control Engine using Redis
|
2
|
+
=============================================
|
3
|
+
|
4
|
+
This gem implements a simple HTTP throttle control using [Redis][] as database engine to store rate-limiting IP's.
|
5
|
+
|
6
|
+
Why not rack-throttle?
|
7
|
+
------------------------
|
8
|
+
|
9
|
+
The gem [rack-throttle][] works with the same principle and does very well the job, but stores IP's and never clears the database.
|
10
|
+
The main goal of Redis Knock is the use of Redis **[expire][]** command, that removes the key after X seconds.
|
11
|
+
|
12
|
+
Usage
|
13
|
+
-----
|
14
|
+
|
15
|
+
### Using in your Rails Controller with before_filter
|
16
|
+
|
17
|
+
# app/controllers/simple_controller.rb
|
18
|
+
|
19
|
+
require 'redis_knock'
|
20
|
+
|
21
|
+
class SimpleController < ApplicationController
|
22
|
+
before_filter :check_throttle
|
23
|
+
|
24
|
+
private
|
25
|
+
def check_throttle
|
26
|
+
control = RedisKnock::Control.new limit: 1000, interval: 1.hour, redis: { host: 'localhost', port: 6379, db: 1 }
|
27
|
+
|
28
|
+
render(text: 'Rate limit exceeded', status: :forbidden) and return unless control.allowed?(request)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
### Using as a Rack Middleware in your Rails app
|
33
|
+
|
34
|
+
# config/application.rb
|
35
|
+
|
36
|
+
class Application < Rails::Application
|
37
|
+
config.middleware.use RedisKnock::Middleware, limit: 1000, interval: 1.hour, redis: { host: 'localhost', port: 6379, db: 1 }
|
38
|
+
end
|
39
|
+
|
40
|
+
### Using in your Sinatra app
|
41
|
+
|
42
|
+
# a_sinatra_application.rb
|
43
|
+
|
44
|
+
require 'sinatra'
|
45
|
+
require 'redis_knock'
|
46
|
+
|
47
|
+
use RedisKnock::Middleware, limit: 1000, interval: 3600, redis: { host: 'localhost', port: 6379, db: 1 }
|
48
|
+
|
49
|
+
get '/' do
|
50
|
+
'Hello World!'
|
51
|
+
end
|
52
|
+
|
53
|
+
Dependencies
|
54
|
+
------------
|
55
|
+
|
56
|
+
* redis gem
|
57
|
+
* Redis server version >= 2.1
|
58
|
+
|
59
|
+
Installation
|
60
|
+
------------
|
61
|
+
|
62
|
+
### With rubygems:
|
63
|
+
|
64
|
+
$ [sudo] gem install redis_knock
|
65
|
+
|
66
|
+
Authors
|
67
|
+
-------
|
68
|
+
|
69
|
+
* Marcelo Correia Pinheiro - <http://salizzar.net/>
|
70
|
+
|
71
|
+
License
|
72
|
+
-------
|
73
|
+
|
74
|
+
RedisKnock is free and unencumbered pubic domain software. For more
|
75
|
+
information, see <http://unlicense.org/> or the accompanying UNLICENSE file.
|
76
|
+
|
77
|
+
[rack-throttle]: https://raw.github.com/datagraph/rack-throttle
|
78
|
+
[Redis]: http://redis.io/
|
79
|
+
[expire]: http://redis.io/commands/expire
|
80
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/UNLICENSE
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
2
|
+
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
4
|
+
distribute this software, either in source code form or as a compiled
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
6
|
+
means.
|
7
|
+
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
9
|
+
of this software dedicate any and all copyright interest in the
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
11
|
+
of the public at large and to the detriment of our heirs and
|
12
|
+
successors. We intend this dedication to be an overt act of
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
14
|
+
software under copyright law.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
23
|
+
|
24
|
+
For more information, please refer to <http://unlicense.org/>
|
25
|
+
|
data/lib/redis-knock.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module RedisKnock
|
4
|
+
class Control
|
5
|
+
def initialize(options)
|
6
|
+
check! options
|
7
|
+
|
8
|
+
@client = get_connection options[:redis]
|
9
|
+
@limit = options[:limit]
|
10
|
+
@interval = options[:interval]
|
11
|
+
end
|
12
|
+
|
13
|
+
def allowed?(request)
|
14
|
+
cache_key = get_cache_key request
|
15
|
+
|
16
|
+
count = @client.incr cache_key
|
17
|
+
@client.expire(cache_key, @interval) if count == 1
|
18
|
+
|
19
|
+
count <= @limit
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def check!(options)
|
25
|
+
raise ArgumentError.new('A limit must be supplied') if options[:limit].nil?
|
26
|
+
raise ArgumentError.new('A interval must be supplied') if options[:interval].nil?
|
27
|
+
|
28
|
+
redis_opts = options[:redis]
|
29
|
+
raise ArgumentError.new('Redis connection params must be supplied') if redis_opts.nil? || redis_opts.empty?
|
30
|
+
raise ArgumentError.new('Redis host must be supplied') if redis_opts[:host].nil?
|
31
|
+
raise ArgumentError.new('Redis port must be supplied') if redis_opts[:port].nil?
|
32
|
+
raise ArgumentError.new('Redis database must be supplied') if redis_opts[:db].nil?
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_connection(options)
|
36
|
+
begin
|
37
|
+
Redis.new options
|
38
|
+
rescue Exception => e
|
39
|
+
raise ConnectionError.new("Cannot connect to Redis server: #{e.message}")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def get_cache_key(request)
|
44
|
+
request.ip.to_s
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module RedisKnock
|
4
|
+
class Middleware
|
5
|
+
attr_reader :app, :options
|
6
|
+
|
7
|
+
def initialize(app, options = {})
|
8
|
+
@app, @options = app, options
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(env)
|
12
|
+
request = Rack::Request.new env
|
13
|
+
control = RedisKnock::Control.new options
|
14
|
+
control.allowed?(request) ? app.call(env) : limit_exceeded
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def limit_exceeded
|
20
|
+
message = 'Rate limit exceeded'
|
21
|
+
|
22
|
+
headers = {}
|
23
|
+
headers['Content-Type'] = 'text/plain; charset=utf-8'
|
24
|
+
headers['Content-Length'] = message.length.to_s
|
25
|
+
|
26
|
+
[ 403, headers, [ message ] ]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
data/lib/redis_knock.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
|
5
|
+
module RedisKnock
|
6
|
+
autoload :ConnectionFactory, 'redis_knock/connection_factory'
|
7
|
+
autoload :Control, 'redis_knock/control'
|
8
|
+
autoload :Middleware, 'redis_knock/middleware'
|
9
|
+
autoload :ConnectionError, 'redis_knock/connection_error'
|
10
|
+
autoload :Version, 'redis_knock/version'
|
11
|
+
end
|
12
|
+
|
data/redis_knock.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "redis_knock/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "redis_knock"
|
7
|
+
s.version = RedisKnock::VERSION
|
8
|
+
s.authors = ["Marcelo Correia Pinheiro"]
|
9
|
+
s.email = ["salizzar@gmail.com"]
|
10
|
+
s.homepage = "https://github.com/salizzar/redis_knock"
|
11
|
+
s.summary = %q{A Ruby HTTP Throttle Control}
|
12
|
+
s.description = %q{The gem redis_knock implements a HTTP Throttle Control engine using Redis to store rate-limiting IP's.}
|
13
|
+
|
14
|
+
s.rubyforge_project = "redis_knock"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency 'rake'
|
22
|
+
s.add_development_dependency 'rspec'
|
23
|
+
s.add_development_dependency 'rack-test'
|
24
|
+
s.add_runtime_dependency 'redis'
|
25
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe RedisKnock::Control do
|
6
|
+
let(:subject) { RedisKnock::Control }
|
7
|
+
let(:host) { 'localhost' }
|
8
|
+
let(:port) { 6379 }
|
9
|
+
let(:db) { 1 }
|
10
|
+
let(:interval) { 3600 }
|
11
|
+
let(:limit) { 1000 }
|
12
|
+
let(:ip) { '127.0.0.1' }
|
13
|
+
let(:request) { mock(:ip => ip) }
|
14
|
+
let(:redis_client) { mock 'An Redis connection' }
|
15
|
+
let(:redis_options) { { :host => host, :port => port, :db => db } }
|
16
|
+
let(:options) { { :limit => limit, :interval => interval, :redis => redis_options } }
|
17
|
+
|
18
|
+
context 'invalid arguments validation' do
|
19
|
+
it 'raises ArgumentError if limit is not informed' do
|
20
|
+
options.delete :limit
|
21
|
+
|
22
|
+
error = ArgumentError.new 'A limit must be supplied'
|
23
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'raises ArgumentErroro if interval is not informed' do
|
27
|
+
options.delete :interval
|
28
|
+
|
29
|
+
error = ArgumentError.new 'A interval must be supplied'
|
30
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
31
|
+
end
|
32
|
+
|
33
|
+
context 'Redis connection params' do
|
34
|
+
it 'raises ArgumentError if redis connection params are not informed' do
|
35
|
+
options.delete :redis
|
36
|
+
|
37
|
+
error = ArgumentError.new 'Redis connection params must be supplied'
|
38
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'raises ArgumentError if host is not informed' do
|
42
|
+
redis_options.delete :host
|
43
|
+
|
44
|
+
error = ArgumentError.new 'Redis host must be supplied'
|
45
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'raises ArgumentError if port is not informed' do
|
49
|
+
redis_options.delete :port
|
50
|
+
|
51
|
+
error = ArgumentError.new 'Redis port must be supplied'
|
52
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'raises ArgumentError if database is not informed' do
|
56
|
+
redis_options.delete :db
|
57
|
+
|
58
|
+
error = ArgumentError.new 'Redis database must be supplied'
|
59
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'when cannot connect to Redis server' do
|
65
|
+
it 'raises ConnectionError' do
|
66
|
+
Redis.should_receive(:new).and_raise('An error')
|
67
|
+
|
68
|
+
error = RedisKnock::ConnectionError.new 'Cannot connect to Redis server: An error'
|
69
|
+
expect { subject.new options }.to raise_error(error.class, error.message)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
context 'checking for allowed requests' do
|
74
|
+
before :each do
|
75
|
+
Redis.should_receive(:new).with(redis_options).and_return(redis_client)
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'expires key if is first request' do
|
79
|
+
redis_client.should_receive(:incr).and_return(1)
|
80
|
+
redis_client.should_receive(:expire).with(ip, interval)
|
81
|
+
|
82
|
+
control = subject.new options
|
83
|
+
control.should be_allowed(request)
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'returns true when limit is not reached' do
|
87
|
+
redis_client.should_not_receive(:expire)
|
88
|
+
redis_client.should_receive(:incr).and_return(limit - 1)
|
89
|
+
|
90
|
+
control = subject.new options
|
91
|
+
control.should be_allowed(request)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'returns false when limit is reached' do
|
95
|
+
redis_client.should_not_receive(:expire)
|
96
|
+
redis_client.should_receive(:incr).and_return(limit + 1)
|
97
|
+
|
98
|
+
control = subject.new options
|
99
|
+
control.should_not be_allowed(request)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe RedisKnock::Middleware do
|
6
|
+
let(:redis_options) { { :host => 'localhost', :port => 6379, :db => 1 } }
|
7
|
+
let(:options) { { :limit => 1000, :interval => 3600, :redis => redis_options } }
|
8
|
+
let(:target_app) { get_app }
|
9
|
+
let(:app) { RedisKnock::Middleware.new target_app, options }
|
10
|
+
let(:control) { mock 'A Redis Throttle Control' }
|
11
|
+
|
12
|
+
context 'performing throttle check' do
|
13
|
+
before :each do
|
14
|
+
RedisKnock::Control.should_receive(:new).and_return(control)
|
15
|
+
end
|
16
|
+
|
17
|
+
context 'with a allowed request' do
|
18
|
+
it 'process if limit is not reached' do
|
19
|
+
target_app.should_receive(:call).and_return([ 200, {}, [ 'An response body' ] ])
|
20
|
+
control.should_receive(:allowed?).and_return(true)
|
21
|
+
|
22
|
+
get '/'
|
23
|
+
last_response.status.should == 200
|
24
|
+
last_response.body.should == 'An response body'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'with a not allowed request' do
|
29
|
+
before :each do
|
30
|
+
control.should_receive(:allowed?).and_return(false)
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'returns HTTP 403 Forbidden if limit was reached' do
|
34
|
+
get '/'
|
35
|
+
last_response.status.should == 403
|
36
|
+
last_response.body.should == 'Rate limit exceeded'
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'returns content-type and retry-after HTTP headers when is not allowed' do
|
40
|
+
get '/'
|
41
|
+
last_response.headers['Content-Type'].should == 'text/plain; charset=utf-8'
|
42
|
+
last_response.headers['Content-Length'].should == 'Rate limit exceeded'.length.to_s
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: redis_knock
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Marcelo Correia Pinheiro
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-01-18 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rake
|
16
|
+
requirement: &70260527478660 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70260527478660
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: rspec
|
27
|
+
requirement: &70260527403560 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70260527403560
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: rack-test
|
38
|
+
requirement: &70260527402640 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70260527402640
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: redis
|
49
|
+
requirement: &70260527401620 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :runtime
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *70260527401620
|
58
|
+
description: The gem redis_knock implements a HTTP Throttle Control engine using Redis
|
59
|
+
to store rate-limiting IP's.
|
60
|
+
email:
|
61
|
+
- salizzar@gmail.com
|
62
|
+
executables: []
|
63
|
+
extensions: []
|
64
|
+
extra_rdoc_files: []
|
65
|
+
files:
|
66
|
+
- .gitignore
|
67
|
+
- .rspec
|
68
|
+
- Gemfile
|
69
|
+
- README.md
|
70
|
+
- Rakefile
|
71
|
+
- UNLICENSE
|
72
|
+
- lib/redis-knock.rb
|
73
|
+
- lib/redis_knock.rb
|
74
|
+
- lib/redis_knock/connection_error.rb
|
75
|
+
- lib/redis_knock/control.rb
|
76
|
+
- lib/redis_knock/middleware.rb
|
77
|
+
- lib/redis_knock/version.rb
|
78
|
+
- redis_knock.gemspec
|
79
|
+
- spec/lib/redis_knock/control_spec.rb
|
80
|
+
- spec/lib/redis_knock/middleware_spec.rb
|
81
|
+
- spec/spec_helper.rb
|
82
|
+
homepage: https://github.com/salizzar/redis_knock
|
83
|
+
licenses: []
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
|
+
none: false
|
96
|
+
requirements:
|
97
|
+
- - ! '>='
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
requirements: []
|
101
|
+
rubyforge_project: redis_knock
|
102
|
+
rubygems_version: 1.8.10
|
103
|
+
signing_key:
|
104
|
+
specification_version: 3
|
105
|
+
summary: A Ruby HTTP Throttle Control
|
106
|
+
test_files:
|
107
|
+
- spec/lib/redis_knock/control_spec.rb
|
108
|
+
- spec/lib/redis_knock/middleware_spec.rb
|
109
|
+
- spec/spec_helper.rb
|