ftpd 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. data/Changelog.md +25 -3
  2. data/Gemfile +0 -1
  3. data/Gemfile.lock +0 -5
  4. data/README.md +109 -51
  5. data/VERSION +1 -1
  6. data/doc/benchmarks.md +4 -3
  7. data/doc/references.md +1 -1
  8. data/doc/rfc-compliance.md +18 -18
  9. data/examples/example.rb +1 -0
  10. data/features/ftp_server/abort.feature +13 -0
  11. data/features/ftp_server/command_errors.feature +0 -12
  12. data/features/ftp_server/delay_after_failed_login.feature +23 -0
  13. data/features/ftp_server/disconnect_after_failed_logins.feature +25 -0
  14. data/features/ftp_server/features.feature +26 -0
  15. data/features/ftp_server/max_connections.feature +39 -0
  16. data/features/ftp_server/options.feature +17 -0
  17. data/features/ftp_server/put_tls.feature +1 -1
  18. data/features/ftp_server/reinitialize.feature +13 -0
  19. data/features/ftp_server/site.feature +13 -0
  20. data/features/ftp_server/status.feature +1 -2
  21. data/features/ftp_server/step_definitions/test_server.rb +20 -0
  22. data/features/ftp_server/structure_mount.feature +13 -0
  23. data/features/ftp_server/timeout.feature +3 -3
  24. data/features/step_definitions/append.rb +2 -2
  25. data/features/step_definitions/client.rb +19 -9
  26. data/features/step_definitions/client_and_server_files.rb +2 -2
  27. data/features/step_definitions/client_files.rb +4 -4
  28. data/features/step_definitions/command.rb +1 -1
  29. data/features/step_definitions/connect.rb +28 -5
  30. data/features/step_definitions/delete.rb +2 -2
  31. data/features/step_definitions/directory_navigation.rb +4 -4
  32. data/features/step_definitions/error_replies.rb +12 -0
  33. data/features/step_definitions/features.rb +21 -0
  34. data/features/step_definitions/file_structure.rb +2 -2
  35. data/features/step_definitions/generic_send.rb +1 -1
  36. data/features/step_definitions/get.rb +2 -2
  37. data/features/step_definitions/help.rb +1 -1
  38. data/features/step_definitions/invalid_commands.rb +2 -2
  39. data/features/step_definitions/list.rb +2 -2
  40. data/features/step_definitions/login.rb +3 -3
  41. data/features/step_definitions/mkdir.rb +1 -1
  42. data/features/step_definitions/mode.rb +2 -2
  43. data/features/step_definitions/options.rb +9 -0
  44. data/features/step_definitions/passive.rb +1 -1
  45. data/features/step_definitions/port.rb +1 -1
  46. data/features/step_definitions/put.rb +3 -3
  47. data/features/step_definitions/quit.rb +2 -2
  48. data/features/step_definitions/rename.rb +1 -1
  49. data/features/step_definitions/rmdir.rb +1 -1
  50. data/features/step_definitions/server_title.rb +12 -0
  51. data/features/step_definitions/status.rb +1 -9
  52. data/features/step_definitions/system.rb +1 -1
  53. data/features/step_definitions/timing.rb +19 -0
  54. data/features/step_definitions/type.rb +2 -2
  55. data/features/support/test_client.rb +62 -7
  56. data/features/support/test_server.rb +4 -0
  57. data/ftpd.gemspec +21 -9
  58. data/lib/ftpd.rb +4 -0
  59. data/lib/ftpd/command_sequence_checker.rb +4 -2
  60. data/lib/ftpd/config.rb +13 -0
  61. data/lib/ftpd/connection_throttle.rb +56 -0
  62. data/lib/ftpd/connection_tracker.rb +110 -0
  63. data/lib/ftpd/disk_file_system.rb +2 -2
  64. data/lib/ftpd/ftp_server.rb +118 -35
  65. data/lib/ftpd/server.rb +27 -3
  66. data/lib/ftpd/session.rb +84 -25
  67. data/lib/ftpd/tls_server.rb +11 -5
  68. data/rake_tasks/cucumber.rake +1 -0
  69. data/rake_tasks/jeweler.rake +1 -1
  70. data/spec/connection_throttle_spec.rb +96 -0
  71. data/spec/connection_tracker_spec.rb +126 -0
  72. data/spec/spec_helper.rb +1 -0
  73. metadata +22 -23
  74. data/config/cucumber.yml +0 -2
  75. data/features/step_definitions/stop_server.rb +0 -3
  76. data/features/step_definitions/timeout.rb +0 -11
@@ -5,14 +5,20 @@ module Ftpd
5
5
 
6
6
  # The interface to bind to (e.g. "127.0.0.1", "0.0.0.0",
7
7
  # "10.0.0.12", etc.). Defaults to "localhost"
8
- # Changes made after #start have no effect.
8
+ #
9
+ # Set this before calling #start.
10
+ #
11
+ # @return [String]
9
12
 
10
13
  attr_accessor :interface
11
14
 
12
15
  # The port to bind to. Defaults to 0, which causes an ephemeral
13
16
  # port to be used. When bound to an ephemeral port, use
14
17
  # #bound_port to find out which port was actually bound to.
15
- # Changes made after #start have no effect.
18
+ #
19
+ # Set this before calling #start.
20
+ #
21
+ # @return [String]
16
22
 
17
23
  attr_accessor :port
18
24
 
@@ -23,6 +29,8 @@ module Ftpd
23
29
 
24
30
  # The port the server is bound to. Must not be called until after
25
31
  # #start is called.
32
+ #
33
+ # @return [Integer]
26
34
 
27
35
  def bound_port
28
36
  @server_socket.addr[1]
@@ -61,7 +69,7 @@ module Ftpd
61
69
  sleep(0.2)
62
70
  retry
63
71
  end
64
- start_session_thread socket
72
+ start_session socket
65
73
  rescue IOError
66
74
  break
67
75
  end
@@ -69,6 +77,22 @@ module Ftpd
69
77
  end
70
78
  end
71
79
 
80
+ def start_session(socket)
81
+ if allow_session?(socket)
82
+ start_session_thread socket
83
+ else
84
+ deny_session socket
85
+ close_socket socket
86
+ end
87
+ end
88
+
89
+ def allow_session?(socket)
90
+ true
91
+ end
92
+
93
+ def deny_session socket
94
+ end
95
+
72
96
  def start_session_thread(socket)
73
97
  Thread.new do
74
98
  begin
@@ -6,6 +6,9 @@ module Ftpd
6
6
  include Error
7
7
 
8
8
  def initialize(opts)
9
+ @failed_login_delay = opts[:failed_login_delay]
10
+ @connection_tracker = opts[:connection_tracker]
11
+ @max_failed_logins = opts[:max_failed_logins]
9
12
  @log = opts[:log] || NullLogger.new
10
13
  @allow_low_data_ports = opts[:allow_low_data_ports]
11
14
  @server_name = opts[:server_name]
@@ -14,41 +17,38 @@ module Ftpd
14
17
  @auth_level = opts[:auth_level]
15
18
  @socket = opts[:socket]
16
19
  @tls = opts[:tls]
20
+ @list_formatter = opts[:list_formatter]
21
+ @response_delay = opts[:response_delay]
22
+ @session_timeout = opts[:session_timeout]
17
23
  if @tls == :implicit
18
24
  @socket.encrypt
19
25
  end
20
- @name_prefix = '/'
21
- @list_formatter = opts[:list_formatter]
22
- @data_type = 'A'
23
- @mode = 'S'
24
- @structure = 'F'
25
- @response_delay = opts[:response_delay]
26
- @data_channel_protection_level = :clear
27
26
  @command_sequence_checker = init_command_sequence_checker
28
- @session_timeout = opts[:session_timeout]
29
- @logged_in = false
30
27
  set_socket_options
28
+ initialize_session
31
29
  end
32
30
 
33
31
  def run
34
- reply "220 ftpd"
35
32
  catch :done do
36
- loop do
37
- begin
38
- s = get_command
39
- s = process_telnet_sequences(s)
40
- syntax_error unless s =~ /^(\w+)(?: (.*))?$/
41
- command, argument = $1.downcase, $2
42
- method = 'cmd_' + command
43
- unless respond_to?(method, true)
44
- unrecognized_error s
33
+ begin
34
+ reply "220 #{server_name_and_version}"
35
+ loop do
36
+ begin
37
+ s = get_command
38
+ s = process_telnet_sequences(s)
39
+ syntax_error unless s =~ /^(\w+)(?: (.*))?$/
40
+ command, argument = $1.downcase, $2
41
+ method = 'cmd_' + command
42
+ unless respond_to?(method, true)
43
+ unrecognized_error s
44
+ end
45
+ @command_sequence_checker.check command
46
+ send(method, argument)
47
+ rescue CommandError => e
48
+ reply e.message
45
49
  end
46
- @command_sequence_checker.check command
47
- send(method, argument)
48
- rescue CommandError => e
49
- reply e.message
50
- rescue Errno::ECONNRESET, Errno::EPIPE
51
50
  end
51
+ rescue Errno::ECONNRESET, Errno::EPIPE
52
52
  end
53
53
  end
54
54
  end
@@ -465,7 +465,7 @@ module Ftpd
465
465
  def cmd_stat(argument)
466
466
  ensure_logged_in
467
467
  syntax_error if argument
468
- reply "211 #{@server_name} #{@server_version}"
468
+ reply "211 #{server_name_and_version}"
469
469
  end
470
470
 
471
471
  def self.unimplemented(command)
@@ -476,12 +476,38 @@ module Ftpd
476
476
  private method_name
477
477
  end
478
478
 
479
+ def cmd_feat(argument)
480
+ syntax_error if argument
481
+ reply '211-Extensions supported:'
482
+ extensions.each do |extension|
483
+ reply " #{extension}"
484
+ end
485
+ reply '211 END'
486
+ end
487
+
488
+ def cmd_opts(argument)
489
+ syntax_error unless argument
490
+ error '501 Unsupported option'
491
+ end
492
+
479
493
  unimplemented :abor
480
494
  unimplemented :rein
481
495
  unimplemented :rest
482
496
  unimplemented :site
483
497
  unimplemented :smnt
484
498
 
499
+ def extensions
500
+ [
501
+ (TLS_EXTENSIONS if tls_enabled?)
502
+ ].flatten.compact
503
+ end
504
+
505
+ TLS_EXTENSIONS = [
506
+ 'AUTH TLS',
507
+ 'PBSZ',
508
+ 'PROT'
509
+ ]
510
+
485
511
  def supported_commands
486
512
  private_methods.map do |method|
487
513
  method.to_s[/^cmd_(\w+)$/, 1]
@@ -754,11 +780,13 @@ module Ftpd
754
780
 
755
781
  def login(*auth_tokens)
756
782
  unless authenticate(*auth_tokens)
783
+ failed_auth
757
784
  error "530 Login incorrect"
758
785
  end
759
786
  reply "230 Logged in"
760
787
  set_file_system @driver.file_system(@user)
761
788
  @logged_in = true
789
+ reset_failed_auths
762
790
  end
763
791
 
764
792
  def set_socket_options
@@ -782,5 +810,36 @@ module Ftpd
782
810
  telnet.plain
783
811
  end
784
812
 
813
+ def reset_failed_auths
814
+ @failed_auths = 0
815
+ end
816
+
817
+ def failed_auth
818
+ @failed_auths += 1
819
+ sleep @failed_login_delay
820
+ if @max_failed_logins && @failed_auths >= @max_failed_logins
821
+ reply "421 server unavailable"
822
+ throw :done
823
+ end
824
+ end
825
+
826
+ def initialize_session
827
+ @logged_in = false
828
+ @data_type = 'A'
829
+ @mode = 'S'
830
+ @structure = 'F'
831
+ @name_prefix = '/'
832
+ @data_channel_protection_level = :clear
833
+ @data_hostname = nil
834
+ @data_port = nil
835
+ @protection_buffer_size_set = 0
836
+ close_data_server_socket
837
+ reset_failed_auths
838
+ end
839
+
840
+ def server_name_and_version
841
+ "#{@server_name} #{@server_version}"
842
+ end
843
+
785
844
  end
786
845
  end
@@ -8,15 +8,21 @@ module Ftpd
8
8
  # * :explicit
9
9
  # * :implicit
10
10
  #
11
- # Defaults to :off
11
+ # Notes:
12
+ # * Defaults to :off
13
+ # * Set this before calling #start.
14
+ # * If other than :off, then #certfile_path must be set.
12
15
  #
13
- # Changes made after #start have no effect. If TLS is enabled,
14
- # then #certfile_path must be set.
16
+ # @return [Symbol]
15
17
 
16
18
  attr_accessor :tls
17
19
 
18
- # The path of the SSL certificate to use for TLS.
19
- # Changes made after #start have no effect.
20
+ # The path of the SSL certificate to use for TLS. Defaults to nil
21
+ # (no SSL certificate).
22
+ #
23
+ # Set this before calling #start.
24
+ #
25
+ # @return [String]
20
26
 
21
27
  attr_accessor :certfile_path
22
28
 
@@ -2,6 +2,7 @@ require 'cucumber/rake/task'
2
2
 
3
3
  Cucumber::Rake::Task.new 'test:features' do |t|
4
4
  t.fork = true
5
+ t.cucumber_opts = '--format progress'
5
6
  end
6
7
 
7
8
  task 'test:cucumber' => ['test:features']
@@ -6,7 +6,7 @@ README_PATH = File.expand_path('../README.md', File.dirname(__FILE__))
6
6
 
7
7
  def extract_description_from_readme
8
8
  readme = File.open(README_PATH, 'r', &:read)
9
- s = readme[/^# FTPD\n+((?:.*\n)+?)\n*##/, 1]
9
+ s = readme[/^# FTPD\n+((?:.*\n)+?)\n*##/i, 1]
10
10
  s.gsub(/\n/, ' ').strip
11
11
  end
12
12
 
@@ -0,0 +1,96 @@
1
+ require File.expand_path('spec_helper', File.dirname(__FILE__))
2
+
3
+ module Ftpd
4
+
5
+ describe ConnectionThrottle do
6
+
7
+ let(:socket) {mock TCPSocket}
8
+ let(:connections) {0}
9
+ let(:connections_for_socket) {0}
10
+ let(:connection_tracker) do
11
+ mock ConnectionTracker, :connections => connections
12
+ end
13
+ subject(:connection_throttle) do
14
+ ConnectionThrottle.new(connection_tracker)
15
+ end
16
+
17
+ before(:each) do
18
+ connection_tracker.stub(:connections => connections)
19
+ connection_tracker.stub(:connections_for).with(socket).and_return(connections_for_socket)
20
+ end
21
+
22
+ it 'should have defaults' do
23
+ connection_throttle.max_connections.should be_nil
24
+ connection_throttle.max_connections_per_ip.should be_nil
25
+ end
26
+
27
+ describe '#allow?' do
28
+
29
+ context '(total connections)' do
30
+
31
+ let(:max_connections) {50}
32
+
33
+ before(:each) do
34
+ connection_throttle.max_connections = max_connections
35
+ connection_throttle.max_connections_per_ip = 2 * max_connections
36
+ end
37
+
38
+ context 'almost at maximum connections' do
39
+ let(:connections) {max_connections - 1}
40
+ specify {connection_throttle.allow?(socket).should be_true}
41
+ end
42
+
43
+ context 'at maximum connections' do
44
+ let(:connections) {max_connections}
45
+ specify {connection_throttle.allow?(socket).should be_false}
46
+ end
47
+
48
+ context 'above maximum connections' do
49
+ let(:connections) {max_connections + 1}
50
+ specify {connection_throttle.allow?(socket).should be_false}
51
+ end
52
+
53
+ end
54
+
55
+ context '(per ip)' do
56
+
57
+ let(:max_connections_per_ip) {5}
58
+
59
+ before(:each) do
60
+ connection_throttle.max_connections = 2 * max_connections_per_ip
61
+ connection_throttle.max_connections_per_ip = max_connections_per_ip
62
+ end
63
+
64
+ context 'almost at maximum connections for ip' do
65
+ let(:connections_for_socket) {max_connections_per_ip - 1}
66
+ specify {connection_throttle.allow?(socket).should be_true}
67
+ end
68
+
69
+ context 'at maximum connections for ip' do
70
+ let(:connections_for_socket) {max_connections_per_ip}
71
+ specify {connection_throttle.allow?(socket).should be_false}
72
+ end
73
+
74
+ context 'above maximum connections for ip' do
75
+ let(:connections_for_socket) {max_connections_per_ip + 1}
76
+ specify {connection_throttle.allow?(socket).should be_false}
77
+ end
78
+
79
+ end
80
+
81
+ end
82
+
83
+ describe '#deny' do
84
+
85
+ let(:socket) {StringIO.new}
86
+
87
+ it 'should send a "too many connections" message' do
88
+ connection_throttle.deny socket
89
+ socket.string.should == "421 Too many connections\r\n"
90
+ end
91
+
92
+ end
93
+
94
+ end
95
+
96
+ end
@@ -0,0 +1,126 @@
1
+ require File.expand_path('spec_helper', File.dirname(__FILE__))
2
+
3
+ module Ftpd
4
+
5
+ describe ConnectionTracker do
6
+
7
+ before(:all) do
8
+ Thread.abort_on_exception = true
9
+ end
10
+
11
+ # Create a mock socket with the given peer address
12
+
13
+ def socket_bound_to(source_ip)
14
+ socket = mock TCPSocket
15
+ peeraddr = Socket.pack_sockaddr_in(0, source_ip)
16
+ socket.stub :getpeername => peeraddr
17
+ socket
18
+ end
19
+
20
+ # Since the connection tracker only keeps track of a connection
21
+ # until the block to which it yields returns, we need a way to
22
+ # keep a block active.
23
+
24
+ class Connector
25
+
26
+ def initialize(connection_tracker)
27
+ @connection_tracker = connection_tracker
28
+ @tracked = Queue.new
29
+ @end_session = Queue.new
30
+ @session_ended = Queue.new
31
+ end
32
+
33
+ # Start tracking a connection. Does not return until it is
34
+ # being tracked.
35
+
36
+ def start_session(socket)
37
+ Thread.new do
38
+ @connection_tracker.track(socket) do
39
+ @tracked.enq :go
40
+ command = @end_session.deq
41
+ if command == :close
42
+ socket.stub(:getpeername).and_raise(RuntimeError, "Socket closed")
43
+ end
44
+ end
45
+ @session_ended.enq :go
46
+ end
47
+ @tracked.deq
48
+ end
49
+
50
+ # Stop tracking a connection. Does not return until it is no
51
+ # longer tracked.
52
+
53
+ def end_session(command = :normally)
54
+ @end_session.enq command
55
+ @session_ended.deq
56
+ end
57
+
58
+ end
59
+
60
+ let(:connector) {Connector.new(connection_tracker)}
61
+ subject(:connection_tracker) {ConnectionTracker.new}
62
+
63
+ describe '#connections' do
64
+
65
+ let(:socket) {socket_bound_to('127.0.0.1')}
66
+
67
+ context '(session ends normally)' do
68
+
69
+ it 'should track the total number of connection' do
70
+ connection_tracker.connections.should == 0
71
+ connector.start_session socket
72
+ connection_tracker.connections.should == 1
73
+ connector.end_session
74
+ connection_tracker.connections.should == 0
75
+ end
76
+
77
+ end
78
+
79
+ context '(socket disconnected during session)' do
80
+
81
+ it 'should track the total number of connection' do
82
+ connection_tracker.connections.should == 0
83
+ connector.start_session socket
84
+ connection_tracker.connections.should == 1
85
+ connector.end_session :close
86
+ connection_tracker.connections.should == 0
87
+ end
88
+
89
+ end
90
+
91
+ end
92
+
93
+ describe '#connections_for' do
94
+
95
+ it 'should track the number of connections for an ip' do
96
+ socket1 = socket_bound_to('127.0.0.1')
97
+ socket2 = socket_bound_to('127.0.0.2')
98
+ connection_tracker.connections_for(socket1).should == 0
99
+ connection_tracker.connections_for(socket2).should == 0
100
+ connector.start_session socket1
101
+ connection_tracker.connections_for(socket1).should == 1
102
+ connection_tracker.connections_for(socket2).should == 0
103
+ connector.end_session
104
+ connection_tracker.connections_for(socket1).should == 0
105
+ connection_tracker.connections_for(socket2).should == 0
106
+ end
107
+
108
+ end
109
+
110
+ describe '#known_ip_count' do
111
+
112
+ let(:socket) {socket_bound_to('127.0.0.1')}
113
+
114
+ it 'should forget about an IP that has no connection' do
115
+ connection_tracker.known_ip_count.should == 0
116
+ connector.start_session socket
117
+ connection_tracker.known_ip_count.should == 1
118
+ connector.end_session
119
+ connection_tracker.known_ip_count.should == 0
120
+ end
121
+
122
+ end
123
+
124
+ end
125
+
126
+ end