redis_knock 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ *.sw*
2
+ *.gem
3
+ .bundle
4
+ Gemfile.lock
5
+ pkg/*
6
+ vendor/bundle/*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
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
+
@@ -0,0 +1,4 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'redis_knock'
4
+
@@ -0,0 +1,7 @@
1
+ # encoding: UTF-8
2
+
3
+ module RedisKnock
4
+ class ConnectionError < Exception
5
+ end
6
+ end
7
+
@@ -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
+
@@ -0,0 +1,5 @@
1
+ # encoding: UTF-8
2
+
3
+ module RedisKnock
4
+ VERSION = '0.0.1'
5
+ end
@@ -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
+
@@ -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
+
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'bundler'
4
+ require 'rspec'
5
+ require 'rack/test'
6
+ require 'redis_knock'
7
+
8
+ RSpec.configure do |config|
9
+ config.include Rack::Test::Methods
10
+
11
+ def get_app
12
+ @app ||= mock 'An app'
13
+ end
14
+ end
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