spanx 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,77 @@
1
+ require 'mixlib/cli'
2
+ require 'spanx/logger'
3
+
4
+ class Spanx::CLI::Report < Spanx::CLI
5
+
6
+ banner 'Usage: spanx report [ -b | -t ] [options]'
7
+ description 'Report on tracked and/or blocked IPs'
8
+
9
+ option :blocked,
10
+ :short => '-b',
11
+ :long => '--blocked',
12
+ :description => 'Show all currently blocked IPs',
13
+ :required => false
14
+
15
+ option :tracked,
16
+ :short => '-t',
17
+ :long => '--tracked',
18
+ :description => 'Show all IPs seen within the tracked period',
19
+ :required => false
20
+
21
+ option :summary,
22
+ :short => '-s',
23
+ :long => '--summary',
24
+ :description => 'Only show summary',
25
+ :required => false
26
+
27
+ option :config_file,
28
+ :short => '-c CONFIG',
29
+ :long => '--config CONFIG',
30
+ :description => 'Path to config file (YML)',
31
+ :required => true
32
+
33
+ option :debug,
34
+ :short => '-g',
35
+ :long => '--debug',
36
+ :description => 'Log to STDOUT status of execution and some time metrics',
37
+ :boolean => true,
38
+ :required => false,
39
+ :default => false
40
+
41
+ option :help,
42
+ :short => '-h',
43
+ :long => '--help',
44
+ :description => 'Show this message',
45
+ :on => :tail,
46
+ :boolean => true,
47
+ :show_options => true,
48
+ :exit => 0
49
+
50
+
51
+ def run(argv = ARGV)
52
+ generate_config(argv)
53
+ out = ''
54
+ if config[:blocked]
55
+ ips = Spanx::IPChecker.rate_limited_identifiers
56
+ out << report_ips('Blocked', ips)
57
+ end
58
+ if config[:tracked]
59
+ ips = Spanx::IPChecker.tracked_identifiers
60
+ out << report_ips('Tracked', ips)
61
+ end
62
+ if config[:summary] or (config[:blocked].nil? && config[:tracked].nil?)
63
+ out << " Total tracked IPS: #{Spanx::IPChecker.tracked_identifiers.size}\n"
64
+ out << " Total blocked IPS: #{Spanx::IPChecker.rate_limited_identifiers.size}\n"
65
+ out << "___________________\n\n"
66
+ out << "Keeping history for: #{config[:collector][:history] / 3600 }hrs\n"
67
+ out << " Time resolution: #{config[:collector][:resolution] / 60 }min\n"
68
+ end
69
+ puts out
70
+ out
71
+ end
72
+
73
+ private
74
+ def report_ips name, ips = []
75
+ ips.empty? ? "No #{name.downcase} IPs were found.\n" : "#{name} IPs:\n" + ips.join("\n") + "\n"
76
+ end
77
+ end
@@ -10,10 +10,17 @@ class Spanx::CLI::Watch < Spanx::CLI
10
10
  Usage: spanx watch [options]
11
11
  EOF
12
12
 
13
+ description 'Watch a server log file and write out a block list file'
14
+
13
15
  option :access_log,
14
- :short => "-f ACCESS_LOG",
15
- :long => "--file ACCESS_LOG",
16
- :description => "Apache/nginx access log file to scan continuously",
16
+ :short => '-f ACCESS_LOG',
17
+ :long => '--file ACCESS_LOG',
18
+ :description => 'Apache/nginx access log file to scan continuously. Can be set multiple times.',
19
+ :proc => ->(f) {
20
+ @watched_log_files ||= []
21
+ @watched_log_files << f
22
+ @watched_log_files.uniq!
23
+ },
17
24
  :required => false
18
25
 
19
26
  option :config_file,
@@ -42,9 +49,9 @@ class Spanx::CLI::Watch < Spanx::CLI
42
49
  :required => false
43
50
 
44
51
  option :daemonize,
45
- :short => "-d",
46
- :long => "--daemonize",
47
- :description => "Detach from TTY and run as a daemon",
52
+ :short => '-d',
53
+ :long => '--daemonize',
54
+ :description => 'Detach from TTY and run as a daemon',
48
55
  :boolean => true,
49
56
  :default => false
50
57
 
@@ -64,9 +71,9 @@ class Spanx::CLI::Watch < Spanx::CLI
64
71
  :default => false
65
72
 
66
73
  option :help,
67
- :short => "-h",
68
- :long => "--help",
69
- :description => "Show this message",
74
+ :short => '-h',
75
+ :long => '--help',
76
+ :description => 'Show this message',
70
77
  :on => :tail,
71
78
  :boolean => true,
72
79
  :show_options => true,
@@ -84,8 +91,8 @@ class Spanx::CLI::Watch < Spanx::CLI
84
91
  private
85
92
 
86
93
  def validate!
87
- error_exit_with_msg("Could not find file. Use -f or set :file in config_file") unless config[:access_log] && File.exists?(config[:access_log])
88
- error_exit_with_msg("-b block_file is required") unless config[:block_file]
94
+ error_exit_with_msg('Could not find file. Use -f or set :file in config_file') unless config[:access_log] && File.exist?(config[:access_log].first)
95
+ error_exit_with_msg('-b block_file is required') unless config[:block_file]
89
96
  end
90
97
 
91
98
  end
@@ -2,10 +2,14 @@ module Spanx
2
2
  module Helper
3
3
  module Exit
4
4
  def error_exit_with_msg(msg)
5
- $stderr.puts "Error: #{msg}"
6
- $stderr.puts Spanx::USAGE
5
+ $stderr.puts "Error: #{msg}\n"
6
+ $stderr.puts Spanx::Usage.usage
7
7
  exit 1
8
8
  end
9
+ def help_exit
10
+ $stdout.puts Spanx::Usage.usage
11
+ exit 0
12
+ end
9
13
  end
10
14
  end
11
15
  end
@@ -24,6 +24,15 @@ module Spanx
24
24
 
25
25
  def inherited(subclass)
26
26
  subclasses[subclass.subclass_name] = subclass
27
+ subclass.instance_eval do
28
+ @description = nil
29
+ class << self
30
+ def description value = nil
31
+ @description ||= value
32
+ @description
33
+ end
34
+ end
35
+ end
27
36
  end
28
37
  end
29
38
  end
@@ -38,7 +38,7 @@ module Spanx
38
38
  end
39
39
  log "(#{"%9.2f" % (1000 * elapsed_time)}ms) #{message}"
40
40
  returned_from_block
41
- rescue Exception => e
41
+ rescue StandardError => e
42
42
  elapsed_time = Time.now - start
43
43
  log "(#{"%9.2f" % (1000 * elapsed_time)}ms) error: #{e.message} for #{message} "
44
44
  end
@@ -0,0 +1,42 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+
5
+ module Spanx
6
+ module Notifier
7
+ class Slack < Base
8
+ attr_reader :config
9
+
10
+ def initialize(config)
11
+ @config = config[:slack]
12
+ end
13
+
14
+ def publish blocked_ip
15
+ return nil unless enabled?
16
+ message = generate_block_ip_message(blocked_ip)
17
+
18
+ uri = endpoint
19
+
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = true
22
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
23
+
24
+ request = Net::HTTP::Post.new(uri.request_uri, {'Content-Type' => 'text/json'})
25
+ request.body = { text: message }.to_json
26
+
27
+ http.request(request)
28
+ end
29
+
30
+ def endpoint
31
+ return nil unless enabled?
32
+ token = config[:token]
33
+ base_url = config[:base_url]
34
+ URI.parse("#{base_url}/services/hooks/incoming-webhook?token=#{token}")
35
+ end
36
+
37
+ def enabled?
38
+ config && config[:enabled]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -68,7 +68,7 @@ module Spanx
68
68
  private
69
69
 
70
70
  def validate_args!(args)
71
- raise("Invalid actor") unless (args - %w[collector log_reader writer analyzer]).empty?
71
+ raise('Invalid actor') unless (args - %w[collector log_reader writer analyzer]).empty?
72
72
  end
73
73
  end
74
74
  end
@@ -1,9 +1,13 @@
1
1
  module Spanx
2
- USAGE = %q{Usage: spanx command [options]
3
- watch -- Watch a server log file and write out a block list file
4
- analyze -- Analyze IP traffic and save blocked IPs into Redis
5
- flush -- Remove all IP blocks and delete previous tracking of that IP
6
- disable -- Disable IP blocking
7
- enable -- Enable IP blocking if disabled
8
- }
2
+ class Usage
3
+ HEADER = %q{Usage: spanx [ --help | <command> ] [options]}
4
+
5
+ def self.usage
6
+ out = ''
7
+ out << HEADER + "\n\n"
8
+ Spanx::CLI.subclasses.each_pair{|command, clazz| out << "#{sprintf "%10s", command}\t#{clazz.description}\n"}
9
+ out << "\nRun 'spanx <command> --help' to see command-specific options.\n"
10
+ out
11
+ end
12
+ end
9
13
  end
@@ -1,3 +1,3 @@
1
1
  module Spanx
2
- VERSION = "0.1.1"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -15,18 +15,12 @@ Gem::Specification.new do |gem|
15
15
  gem.require_paths = %w(lib)
16
16
  gem.version = Spanx::VERSION
17
17
 
18
- gem.add_dependency 'pause', '~> 0.0.4'
18
+ gem.add_dependency 'pause'
19
19
  gem.add_dependency 'file-tail'
20
20
  gem.add_dependency 'mixlib-cli'
21
21
  gem.add_dependency 'daemons'
22
22
  gem.add_dependency 'tinder'
23
- gem.add_dependency 'mail', '~> 2.4.4'
24
-
25
- gem.add_development_dependency 'rspec'
26
- gem.add_development_dependency 'fakeredis'
27
- gem.add_development_dependency 'timecop'
28
-
29
- gem.add_development_dependency 'guard-rspec'
30
- gem.add_development_dependency 'rb-fsevent'
23
+ gem.add_dependency 'mail'
31
24
 
25
+ gem.add_dependency 'webmachine'
32
26
  end
@@ -6,7 +6,7 @@ describe Spanx::Actor::Analyzer do
6
6
  include Spanx::Helper::Timing
7
7
 
8
8
  before do
9
- pause_config = mock(resolution: 10, history: 100, redis_host: "1.2.3.4", redis_port: 1, redis_db: 1)
9
+ pause_config = double(resolution: 10, history: 100, redis_host: "1.2.3.4", redis_port: 1, redis_db: 1)
10
10
  Pause.stub(:config).and_return(pause_config)
11
11
  pause_analyzer = Pause::Analyzer.new
12
12
  Pause.stub(:analyzer).and_return(pause_analyzer)
@@ -76,8 +76,8 @@ describe Spanx::Actor::Analyzer do
76
76
 
77
77
  before do
78
78
  Spanx::IPChecker.should_receive(:tracked_identifiers).and_return([ip1, ip2])
79
- Spanx::IPChecker.should_receive(:new).with(ip1).and_return(mock(analyze: nil))
80
- Spanx::IPChecker.should_receive(:new).with(ip2).and_return(mock(analyze: nil))
79
+ Spanx::IPChecker.should_receive(:new).with(ip1).and_return(double(analyze: nil))
80
+ Spanx::IPChecker.should_receive(:new).with(ip2).and_return(double(analyze: nil))
81
81
  end
82
82
 
83
83
  it "analyzes each IP found" do
@@ -88,8 +88,8 @@ describe Spanx::Actor::Analyzer do
88
88
 
89
89
  context "notifiers" do
90
90
  let(:notifiers) { ["FakeNotifier"] }
91
- let(:fake_notifier) { mock() }
92
- let(:blocked_ip) { mock() }
91
+ let(:fake_notifier) { double() }
92
+ let(:blocked_ip) { double() }
93
93
 
94
94
  class FakeNotifier
95
95
  end
@@ -3,66 +3,103 @@ require 'file/tail'
3
3
  require 'timeout'
4
4
  require 'thread'
5
5
  require 'tempfile'
6
+ require 'timecop'
6
7
 
7
8
  describe Spanx::Actor::LogReader do
8
9
 
9
- def test_log_file(file, expected_ip_count, expected_line_count, whitelist = nil)
10
- counter = 0
11
- ip_hash = {}
12
- reader = Spanx::Actor::LogReader.new(file, Queue.new, 1, whitelist)
13
- reader.file.backward(1000)
14
-
15
- t_reader = Thread.new do
16
- begin
17
- timeout(read_timeout) do
18
- reader.read do |ip|
19
- counter += 1
20
- ip_hash[ip] ||= 0
21
- ip_hash[ip] += 1
10
+ subject(:reader) { Spanx::Actor::LogReader.new(files, queue, 1, whitelist) }
11
+ let(:files) { [] }
12
+ let(:queue) { [] }
13
+ let(:whitelist) { nil }
14
+ let(:read_timeout) { 0.1 }
15
+
16
+ describe '#read' do
17
+ let(:tempfile) { Tempfile.new('access.log') }
18
+ let!(:file) { Spanx::Actor::File.new(tempfile.path) }
19
+
20
+ it 'yields each ip in a file as it is written' do
21
+ expect { |b|
22
+ reader_thread = Thread.new do
23
+ begin
24
+ timeout(read_timeout) do
25
+ reader.read(file, &b)
26
+ end
27
+ rescue TimeoutError
22
28
  end
23
29
  end
24
- rescue TimeoutError
25
- end
30
+
31
+ ::File.open(tempfile.path, 'a') do |t|
32
+ t.puts '9.9.9.9 - some stuff'
33
+ t.puts '9.9.9.10 - some other stuff'
34
+ t.puts '9.9.9.9 - whoa moar stuff'
35
+ end
36
+
37
+ reader_thread.join
38
+ }.to yield_successive_args('9.9.9.9', '9.9.9.10', '9.9.9.9')
26
39
  end
27
40
 
28
- yield if block_given?
41
+ context 'if a line matches a whitelist' do
42
+ let(:whitelist) { double }
29
43
 
30
- t_reader.join
44
+ before do
45
+ allow(whitelist).to receive(:match?).with("9.9.9.9 - some stuff\n").and_return(true)
46
+ allow(whitelist).to receive(:match?).with("9.9.9.10 - some other stuff\n").and_return(false)
47
+ end
31
48
 
32
- counter.should eql(expected_line_count)
33
- ip_hash.keys.size.should eql(expected_ip_count)
34
- end
49
+ it 'skips that line' do
50
+ expect { |b|
51
+ reader_thread = Thread.new do
52
+ begin
53
+ timeout(read_timeout) do
54
+ reader.read(file, &b)
55
+ end
56
+ rescue TimeoutError
57
+ end
58
+ end
35
59
 
36
- let(:file_name) { "spec/fixtures/access.log.1" }
37
- let(:read_timeout) { 0.05 }
60
+ ::File.open(tempfile.path, 'a') do |t|
61
+ t.puts '9.9.9.9 - some stuff'
62
+ t.puts '9.9.9.10 - some other stuff'
63
+ end
38
64
 
39
- context "#read" do
40
- it "should be able to read and parse IPs from a static file" do
41
- test_log_file(file_name, 82, 104)
65
+ reader_thread.join
66
+ }.to yield_successive_args('9.9.9.10')
67
+ end
42
68
  end
69
+ end
43
70
 
44
- it "should be able to read and parse IPs from a file being appended to" do
45
- tempfile = Tempfile.new("access.log")
71
+ describe '#run' do
72
+ let!(:file1) { Tempfile.new('log.log') }
73
+ let!(:file2) { Tempfile.new('loge.log') }
74
+ let!(:files) { [file1.path, file2.path] }
75
+ let(:read_timeout) { 0.1 }
46
76
 
47
- contents = ::File.read(file_name)
48
- tempfile.write(contents)
49
- tempfile.close
77
+ after do
78
+ file1.close
79
+ file2.close
80
+ end
50
81
 
51
- test_log_file(tempfile.path, 83, 105) do
52
- t_log_appender = Thread.new do
53
- ::File.open(tempfile.path, "a") do |t|
54
- t.write("9.9.9.9 - content")
82
+ xit 'pushes ips from each watched file onto queue' do
83
+ Timecop.freeze(Time.at(1409270561))
84
+
85
+ runner = Thread.new do
86
+ begin
87
+ timeout(read_timeout) do
88
+ reader.run
89
+ reader.threads.last.join
55
90
  end
91
+ rescue TimeoutError
56
92
  end
57
- t_log_appender.join
58
93
  end
59
- end
60
- end
61
94
 
62
- context "#whitelist" do
63
- let(:whitelist_file) { "spec/fixtures/whitelist.txt" }
64
- it "should exclude googlebot log lines" do
65
- test_log_file("spec/fixtures/access.log.bots", 1, 1, Spanx::Whitelist.new(whitelist_file))
95
+ sleep 1
96
+
97
+ file1.puts '1.1.1.1 - everyone loves a log!'
98
+ file2.puts '2.2.2.2 - only some people love a loge'
99
+
100
+ runner.join
101
+
102
+ expect(queue).to match_array([['1.1.1.1', 1409270561], ['2.2.2.2', 1409270561]])
66
103
  end
67
104
  end
68
105
  end