redis_knock 0.0.1
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.
- 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
|