ftpd 0.5.0 → 0.6.0

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