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.
- data/Changelog.md +25 -3
- data/Gemfile +0 -1
- data/Gemfile.lock +0 -5
- data/README.md +109 -51
- data/VERSION +1 -1
- data/doc/benchmarks.md +4 -3
- data/doc/references.md +1 -1
- data/doc/rfc-compliance.md +18 -18
- data/examples/example.rb +1 -0
- data/features/ftp_server/abort.feature +13 -0
- data/features/ftp_server/command_errors.feature +0 -12
- data/features/ftp_server/delay_after_failed_login.feature +23 -0
- data/features/ftp_server/disconnect_after_failed_logins.feature +25 -0
- data/features/ftp_server/features.feature +26 -0
- data/features/ftp_server/max_connections.feature +39 -0
- data/features/ftp_server/options.feature +17 -0
- data/features/ftp_server/put_tls.feature +1 -1
- data/features/ftp_server/reinitialize.feature +13 -0
- data/features/ftp_server/site.feature +13 -0
- data/features/ftp_server/status.feature +1 -2
- data/features/ftp_server/step_definitions/test_server.rb +20 -0
- data/features/ftp_server/structure_mount.feature +13 -0
- data/features/ftp_server/timeout.feature +3 -3
- data/features/step_definitions/append.rb +2 -2
- data/features/step_definitions/client.rb +19 -9
- data/features/step_definitions/client_and_server_files.rb +2 -2
- data/features/step_definitions/client_files.rb +4 -4
- data/features/step_definitions/command.rb +1 -1
- data/features/step_definitions/connect.rb +28 -5
- data/features/step_definitions/delete.rb +2 -2
- data/features/step_definitions/directory_navigation.rb +4 -4
- data/features/step_definitions/error_replies.rb +12 -0
- data/features/step_definitions/features.rb +21 -0
- data/features/step_definitions/file_structure.rb +2 -2
- data/features/step_definitions/generic_send.rb +1 -1
- data/features/step_definitions/get.rb +2 -2
- data/features/step_definitions/help.rb +1 -1
- data/features/step_definitions/invalid_commands.rb +2 -2
- data/features/step_definitions/list.rb +2 -2
- data/features/step_definitions/login.rb +3 -3
- data/features/step_definitions/mkdir.rb +1 -1
- data/features/step_definitions/mode.rb +2 -2
- data/features/step_definitions/options.rb +9 -0
- data/features/step_definitions/passive.rb +1 -1
- data/features/step_definitions/port.rb +1 -1
- data/features/step_definitions/put.rb +3 -3
- data/features/step_definitions/quit.rb +2 -2
- data/features/step_definitions/rename.rb +1 -1
- data/features/step_definitions/rmdir.rb +1 -1
- data/features/step_definitions/server_title.rb +12 -0
- data/features/step_definitions/status.rb +1 -9
- data/features/step_definitions/system.rb +1 -1
- data/features/step_definitions/timing.rb +19 -0
- data/features/step_definitions/type.rb +2 -2
- data/features/support/test_client.rb +62 -7
- data/features/support/test_server.rb +4 -0
- data/ftpd.gemspec +21 -9
- data/lib/ftpd.rb +4 -0
- data/lib/ftpd/command_sequence_checker.rb +4 -2
- data/lib/ftpd/config.rb +13 -0
- data/lib/ftpd/connection_throttle.rb +56 -0
- data/lib/ftpd/connection_tracker.rb +110 -0
- data/lib/ftpd/disk_file_system.rb +2 -2
- data/lib/ftpd/ftp_server.rb +118 -35
- data/lib/ftpd/server.rb +27 -3
- data/lib/ftpd/session.rb +84 -25
- data/lib/ftpd/tls_server.rb +11 -5
- data/rake_tasks/cucumber.rake +1 -0
- data/rake_tasks/jeweler.rake +1 -1
- data/spec/connection_throttle_spec.rb +96 -0
- data/spec/connection_tracker_spec.rb +126 -0
- data/spec/spec_helper.rb +1 -0
- metadata +22 -23
- data/config/cucumber.yml +0 -2
- data/features/step_definitions/stop_server.rb +0 -3
- data/features/step_definitions/timeout.rb +0 -11
data/lib/ftpd/server.rb
CHANGED
@@ -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
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
data/lib/ftpd/session.rb
CHANGED
@@ -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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
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 #{
|
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
|
data/lib/ftpd/tls_server.rb
CHANGED
@@ -8,15 +8,21 @@ module Ftpd
|
|
8
8
|
# * :explicit
|
9
9
|
# * :implicit
|
10
10
|
#
|
11
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
|
data/rake_tasks/cucumber.rake
CHANGED
data/rake_tasks/jeweler.rake
CHANGED
@@ -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
|
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
|