ftpd 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog.md +33 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +5 -0
- data/README.md +29 -30
- data/VERSION +1 -1
- data/config/cucumber.yml +2 -0
- data/doc/benchmarks.md +92 -0
- data/doc/references.md +39 -12
- data/doc/rfc-compliance.md +6 -6
- data/examples/example.rb +21 -0
- data/features/ftp_server/command_errors.feature +0 -1
- data/features/ftp_server/logging.feature +11 -0
- data/features/ftp_server/port.feature +15 -0
- data/features/ftp_server/status.feature +19 -0
- data/features/ftp_server/step_definitions/logging.rb +8 -0
- data/features/ftp_server/step_definitions/test_server.rb +19 -2
- data/features/ftp_server/timeout.feature +26 -0
- data/features/step_definitions/error_replies.rb +5 -0
- data/features/step_definitions/status.rb +17 -0
- data/features/step_definitions/timeout.rb +11 -0
- data/features/support/test_client.rb +13 -1
- data/features/support/test_server.rb +23 -9
- data/ftpd.gemspec +18 -5
- data/lib/ftpd.rb +3 -0
- data/lib/ftpd/ftp_server.rb +52 -18
- data/lib/ftpd/null_logger.rb +22 -0
- data/lib/ftpd/server.rb +11 -2
- data/lib/ftpd/session.rb +71 -25
- data/lib/ftpd/telnet.rb +119 -0
- data/rake_tasks/cucumber.rake +0 -1
- data/spec/null_logger_spec.rb +24 -0
- data/spec/telnet_spec.rb +75 -0
- metadata +32 -6
- data/features/ftp_server/debug.feature +0 -17
- data/features/ftp_server/step_definitions/debug.rb +0 -8
@@ -0,0 +1,22 @@
|
|
1
|
+
module Ftpd
|
2
|
+
|
3
|
+
# A logger that does not log.
|
4
|
+
# Quacks enough like a Logger to fool Ftpd.
|
5
|
+
|
6
|
+
class NullLogger
|
7
|
+
|
8
|
+
def self.stub(method_name)
|
9
|
+
define_method method_name do |*args|
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
stub :unknown
|
14
|
+
stub :fatal
|
15
|
+
stub :error
|
16
|
+
stub :warn
|
17
|
+
stub :info
|
18
|
+
stub :debug
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
data/lib/ftpd/server.rb
CHANGED
@@ -72,9 +72,9 @@ module Ftpd
|
|
72
72
|
def start_session_thread(socket)
|
73
73
|
Thread.new do
|
74
74
|
begin
|
75
|
-
session
|
75
|
+
session socket
|
76
76
|
ensure
|
77
|
-
socket
|
77
|
+
close_socket socket
|
78
78
|
end
|
79
79
|
end
|
80
80
|
end
|
@@ -83,5 +83,14 @@ module Ftpd
|
|
83
83
|
@server_socket.accept
|
84
84
|
end
|
85
85
|
|
86
|
+
def close_socket(socket)
|
87
|
+
if socket.respond_to?(:shutdown)
|
88
|
+
socket.shutdown
|
89
|
+
socket.read
|
90
|
+
end
|
91
|
+
ensure
|
92
|
+
socket.close
|
93
|
+
end
|
94
|
+
|
86
95
|
end
|
87
96
|
end
|
data/lib/ftpd/session.rb
CHANGED
@@ -6,6 +6,10 @@ module Ftpd
|
|
6
6
|
include Error
|
7
7
|
|
8
8
|
def initialize(opts)
|
9
|
+
@log = opts[:log] || NullLogger.new
|
10
|
+
@allow_low_data_ports = opts[:allow_low_data_ports]
|
11
|
+
@server_name = opts[:server_name]
|
12
|
+
@server_version = opts[:server_version]
|
9
13
|
@driver = opts[:driver]
|
10
14
|
@auth_level = opts[:auth_level]
|
11
15
|
@socket = opts[:socket]
|
@@ -14,8 +18,6 @@ module Ftpd
|
|
14
18
|
@socket.encrypt
|
15
19
|
end
|
16
20
|
@name_prefix = '/'
|
17
|
-
@debug_path = opts[:debug_path]
|
18
|
-
@debug = opts[:debug]
|
19
21
|
@list_formatter = opts[:list_formatter]
|
20
22
|
@data_type = 'A'
|
21
23
|
@mode = 'S'
|
@@ -23,7 +25,9 @@ module Ftpd
|
|
23
25
|
@response_delay = opts[:response_delay]
|
24
26
|
@data_channel_protection_level = :clear
|
25
27
|
@command_sequence_checker = init_command_sequence_checker
|
28
|
+
@session_timeout = opts[:session_timeout]
|
26
29
|
@logged_in = false
|
30
|
+
set_socket_options
|
27
31
|
end
|
28
32
|
|
29
33
|
def run
|
@@ -32,6 +36,7 @@ module Ftpd
|
|
32
36
|
loop do
|
33
37
|
begin
|
34
38
|
s = get_command
|
39
|
+
s = process_telnet_sequences(s)
|
35
40
|
syntax_error unless s =~ /^(\w+)(?: (.*))?$/
|
36
41
|
command, argument = $1.downcase, $2
|
37
42
|
method = 'cmd_' + command
|
@@ -111,8 +116,13 @@ module Ftpd
|
|
111
116
|
syntax_error unless (0..255) === i
|
112
117
|
i
|
113
118
|
end
|
114
|
-
|
115
|
-
|
119
|
+
hostname = pieces[0..3].join('.')
|
120
|
+
port = pieces[4] << 8 | pieces[5]
|
121
|
+
if port < 1024 && !@allow_low_data_ports
|
122
|
+
error "504 Command not implemented for that parameter"
|
123
|
+
end
|
124
|
+
@data_hostname = hostname
|
125
|
+
@data_port = port
|
116
126
|
reply "200 PORT command successful"
|
117
127
|
end
|
118
128
|
|
@@ -452,6 +462,12 @@ module Ftpd
|
|
452
462
|
end
|
453
463
|
end
|
454
464
|
|
465
|
+
def cmd_stat(argument)
|
466
|
+
ensure_logged_in
|
467
|
+
syntax_error if argument
|
468
|
+
reply "211 #{@server_name} #{@server_version}"
|
469
|
+
end
|
470
|
+
|
455
471
|
def self.unimplemented(command)
|
456
472
|
method_name = "cmd_#{command}"
|
457
473
|
define_method method_name do |arguments|
|
@@ -465,7 +481,6 @@ module Ftpd
|
|
465
481
|
unimplemented :rest
|
466
482
|
unimplemented :site
|
467
483
|
unimplemented :smnt
|
468
|
-
unimplemented :stat
|
469
484
|
|
470
485
|
def supported_commands
|
471
486
|
private_methods.map do |method|
|
@@ -520,21 +535,31 @@ module Ftpd
|
|
520
535
|
def transmit_file(contents, data_type = @data_type)
|
521
536
|
open_data_connection do |data_socket|
|
522
537
|
contents = unix_to_nvt_ascii(contents) if data_type == 'A'
|
523
|
-
|
524
|
-
|
538
|
+
handle_data_disconnect do
|
539
|
+
data_socket.write(contents)
|
540
|
+
end
|
541
|
+
@log.debug "Sent #{contents.size} bytes"
|
525
542
|
reply "226 Transfer complete"
|
526
543
|
end
|
527
544
|
end
|
528
545
|
|
529
546
|
def receive_file(path_to_advertise = nil)
|
530
547
|
open_data_connection(path_to_advertise) do |data_socket|
|
531
|
-
contents =
|
548
|
+
contents = handle_data_disconnect do
|
549
|
+
data_socket.read
|
550
|
+
end
|
532
551
|
contents = nvt_ascii_to_unix(contents) if @data_type == 'A'
|
533
|
-
debug
|
552
|
+
@log.debug "Received #{contents.size} bytes"
|
534
553
|
contents
|
535
554
|
end
|
536
555
|
end
|
537
556
|
|
557
|
+
def handle_data_disconnect
|
558
|
+
return yield
|
559
|
+
rescue Errno::ECONNRESET, Errno::EPIPE
|
560
|
+
reply "426 Connection closed; transfer aborted."
|
561
|
+
end
|
562
|
+
|
538
563
|
def unix_to_nvt_ascii(s)
|
539
564
|
return s if s =~ /\r\n/
|
540
565
|
s.gsub(/\n/, "\r\n")
|
@@ -642,20 +667,31 @@ module Ftpd
|
|
642
667
|
end
|
643
668
|
|
644
669
|
def get_command
|
645
|
-
s = @socket
|
670
|
+
s = gets_with_timeout(@socket)
|
646
671
|
throw :done if s.nil?
|
647
672
|
s = s.chomp
|
648
|
-
debug
|
673
|
+
@log.debug s
|
649
674
|
s
|
650
675
|
end
|
651
676
|
|
677
|
+
def gets_with_timeout(socket)
|
678
|
+
ready = IO.select([@socket], nil, nil, @session_timeout)
|
679
|
+
timeout if ready.nil?
|
680
|
+
ready[0].first.gets
|
681
|
+
end
|
682
|
+
|
683
|
+
def timeout
|
684
|
+
reply '421 Control connection timed out.'
|
685
|
+
throw :done
|
686
|
+
end
|
687
|
+
|
652
688
|
def reply(s)
|
653
689
|
if @response_delay.to_i != 0
|
654
|
-
|
690
|
+
@log.warn "#{@response_delay} second delay before replying"
|
655
691
|
sleep @response_delay
|
656
692
|
end
|
657
|
-
debug
|
658
|
-
@socket.
|
693
|
+
@log.debug s
|
694
|
+
@socket.write s + "\r\n"
|
659
695
|
end
|
660
696
|
|
661
697
|
def unique_path(path)
|
@@ -677,17 +713,6 @@ module Ftpd
|
|
677
713
|
end.join
|
678
714
|
end
|
679
715
|
|
680
|
-
def debug(*s)
|
681
|
-
return unless debug?
|
682
|
-
File.open(@debug_path, 'a') do |file|
|
683
|
-
file.puts(*s)
|
684
|
-
end
|
685
|
-
end
|
686
|
-
|
687
|
-
def debug?
|
688
|
-
@debug || ENV['FTPD_DEBUG'].to_i != 0
|
689
|
-
end
|
690
|
-
|
691
716
|
def init_command_sequence_checker
|
692
717
|
checker = CommandSequenceChecker.new
|
693
718
|
checker.must_expect 'acct'
|
@@ -736,5 +761,26 @@ module Ftpd
|
|
736
761
|
@logged_in = true
|
737
762
|
end
|
738
763
|
|
764
|
+
def set_socket_options
|
765
|
+
disable_nagle @socket
|
766
|
+
receive_oob_data_inline @socket
|
767
|
+
end
|
768
|
+
|
769
|
+
def disable_nagle(socket)
|
770
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
771
|
+
end
|
772
|
+
|
773
|
+
def receive_oob_data_inline(socket)
|
774
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_OOBINLINE, 1)
|
775
|
+
end
|
776
|
+
|
777
|
+
def process_telnet_sequences(s)
|
778
|
+
telnet = Telnet.new(s)
|
779
|
+
unless telnet.reply.empty?
|
780
|
+
@socket.write telnet.reply
|
781
|
+
end
|
782
|
+
telnet.plain
|
783
|
+
end
|
784
|
+
|
739
785
|
end
|
740
786
|
end
|
data/lib/ftpd/telnet.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
# -*- ruby encoding: us-ascii -*-
|
2
|
+
|
3
|
+
module Ftpd
|
4
|
+
|
5
|
+
# Handle the limited processing of Telnet sequences required by the
|
6
|
+
# FTP RFCs.
|
7
|
+
#
|
8
|
+
# Telnet option processing is quite complex, but we need do only a
|
9
|
+
# simple subset of it, since we can disagree with any request by the
|
10
|
+
# client to turn on an option (RFC-1123 4.1.2.12). Adhering to
|
11
|
+
# RFC-1143 ("The Q Method of Implementing TELNET Option Negiation"),
|
12
|
+
# and supporting only what's needed to keep all options turned off:
|
13
|
+
#
|
14
|
+
# * Reply to WILL sequence with DONT sequence
|
15
|
+
# * Reply to DO sequence with WONT sequence
|
16
|
+
# * Ignore WONT sequence
|
17
|
+
# * Ignore DONT sequence
|
18
|
+
#
|
19
|
+
# We also handle the "interrupt process" and "data mark" sequences,
|
20
|
+
# which the client sends before the ABORT command, by ignoring them.
|
21
|
+
#
|
22
|
+
# All Telnet sequence start with an IAC, followed by at least one
|
23
|
+
# character. Here are the sequences we care about:
|
24
|
+
#
|
25
|
+
# SEQUENCE CODES
|
26
|
+
# ----------------- --------------------
|
27
|
+
# WILL IAC WILL option-code
|
28
|
+
# WONT IAC WONT option-code
|
29
|
+
# DO IAC DO option-code
|
30
|
+
# DONT IAC DONT option-code
|
31
|
+
# escaped 255 IAC IAC
|
32
|
+
# interrupt process IAC IP
|
33
|
+
# data mark IAC DM
|
34
|
+
#
|
35
|
+
# Any pathalogical sequence (e.g. IAC + \x01), or any sequence we
|
36
|
+
# don't recognize, we pass through.
|
37
|
+
|
38
|
+
class Telnet
|
39
|
+
|
40
|
+
# The command with recognized Telnet sequences removed
|
41
|
+
|
42
|
+
attr_reader :plain
|
43
|
+
|
44
|
+
# Any Telnet sequences to send
|
45
|
+
|
46
|
+
attr_reader :reply
|
47
|
+
|
48
|
+
# Create a new instance with a command that may contain Telnet
|
49
|
+
# sequences.
|
50
|
+
# @param command [String]
|
51
|
+
|
52
|
+
def initialize(command)
|
53
|
+
telnet_state_machine command
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
module Codes
|
59
|
+
IAC = 255.chr # 0xff
|
60
|
+
DONT = 254.chr # 0xfe
|
61
|
+
DO = 253.chr # 0xfd
|
62
|
+
WONT = 252.chr # 0xfc
|
63
|
+
WILL = 251.chr # 0xfb
|
64
|
+
IP = 244.chr # 0xf4
|
65
|
+
DM = 242.chr # 0xf2
|
66
|
+
end
|
67
|
+
include Codes
|
68
|
+
|
69
|
+
def telnet_state_machine (command)
|
70
|
+
@plain = ''
|
71
|
+
@reply = ''
|
72
|
+
state = :idle
|
73
|
+
command.each_char do |c|
|
74
|
+
case state
|
75
|
+
when :idle
|
76
|
+
if c == IAC
|
77
|
+
state = :iac
|
78
|
+
else
|
79
|
+
@plain << c
|
80
|
+
end
|
81
|
+
when :iac
|
82
|
+
case c
|
83
|
+
when IAC
|
84
|
+
@plain << c
|
85
|
+
state = :idle
|
86
|
+
when WILL
|
87
|
+
state = :will
|
88
|
+
when WONT
|
89
|
+
state = :wont
|
90
|
+
when DO
|
91
|
+
state = :do
|
92
|
+
when DONT
|
93
|
+
state = :dont
|
94
|
+
when IP
|
95
|
+
state = :idle
|
96
|
+
when DM
|
97
|
+
state = :idle
|
98
|
+
else
|
99
|
+
@plain << IAC + c
|
100
|
+
state = :idle
|
101
|
+
end
|
102
|
+
when :will
|
103
|
+
@reply << IAC + DONT + c
|
104
|
+
state = :idle
|
105
|
+
when :wont
|
106
|
+
state = :idle
|
107
|
+
when :do
|
108
|
+
@reply << IAC + WONT + c
|
109
|
+
state = :idle
|
110
|
+
when :dont
|
111
|
+
state = :idle
|
112
|
+
else
|
113
|
+
raise "Unknown state #{state.inspect}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
data/rake_tasks/cucumber.rake
CHANGED
@@ -0,0 +1,24 @@
|
|
1
|
+
require File.expand_path('spec_helper', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
module Ftpd
|
4
|
+
describe NullLogger do
|
5
|
+
|
6
|
+
subject {NullLogger.new}
|
7
|
+
|
8
|
+
def self.should_stub(method)
|
9
|
+
describe "#{method}" do
|
10
|
+
specify do
|
11
|
+
subject.should respond_to method
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
should_stub :unknown
|
17
|
+
should_stub :fatal
|
18
|
+
should_stub :error
|
19
|
+
should_stub :warn
|
20
|
+
should_stub :info
|
21
|
+
should_stub :debug
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
data/spec/telnet_spec.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
# -*- ruby encoding: us-ascii -*-
|
2
|
+
|
3
|
+
require File.expand_path('spec_helper', File.dirname(__FILE__))
|
4
|
+
|
5
|
+
module Ftpd
|
6
|
+
describe Telnet do
|
7
|
+
|
8
|
+
IAC = 255.chr # 0xff
|
9
|
+
DONT = 254.chr # 0xfe
|
10
|
+
DO = 253.chr # 0xfd
|
11
|
+
WONT = 252.chr # 0xfc
|
12
|
+
WILL = 251.chr # 0xfb
|
13
|
+
IP = 244.chr # 0xf4
|
14
|
+
DM = 242.chr # 0xf2
|
15
|
+
|
16
|
+
subject {Telnet.new(command)}
|
17
|
+
let(:plain_command) {"NOOP\r\n"}
|
18
|
+
let(:command) {codes + plain_command}
|
19
|
+
|
20
|
+
context '(plain command)' do
|
21
|
+
let(:codes) {''}
|
22
|
+
its(:reply) {should == ''}
|
23
|
+
its(:plain) {should == plain_command}
|
24
|
+
end
|
25
|
+
|
26
|
+
context '(escaped IAC)' do
|
27
|
+
let(:codes) {"#{IAC}#{IAC}"}
|
28
|
+
its(:reply) {should == ''}
|
29
|
+
its(:plain) {should == "#{IAC}" + plain_command}
|
30
|
+
end
|
31
|
+
|
32
|
+
context '(IAC + unknown code)' do
|
33
|
+
let(:codes) {"#{IAC}\x01"}
|
34
|
+
its(:reply) {should == ''}
|
35
|
+
its(:plain) {should == codes + plain_command}
|
36
|
+
end
|
37
|
+
|
38
|
+
context '(WILL)' do
|
39
|
+
let(:codes) {"#{IAC}#{WILL}\x01"}
|
40
|
+
its(:reply) {should == "#{IAC}#{DONT}\x01"}
|
41
|
+
its(:plain) {should == plain_command}
|
42
|
+
end
|
43
|
+
|
44
|
+
context '(WONT)' do
|
45
|
+
let(:codes) {"#{IAC}#{WONT}\x01"}
|
46
|
+
its(:reply) {should == ''}
|
47
|
+
its(:plain) {should == plain_command}
|
48
|
+
end
|
49
|
+
|
50
|
+
context '(DO)' do
|
51
|
+
let(:codes) {"#{IAC}#{DO}\x01"}
|
52
|
+
its(:reply) {should == "#{IAC}#{WONT}\x01"}
|
53
|
+
its(:plain) {should == plain_command}
|
54
|
+
end
|
55
|
+
|
56
|
+
context '(DONT)' do
|
57
|
+
let(:codes) {"#{IAC}#{DONT}\x01"}
|
58
|
+
its(:reply) {should == ''}
|
59
|
+
its(:plain) {should == plain_command}
|
60
|
+
end
|
61
|
+
|
62
|
+
context '(interrupt process)' do
|
63
|
+
let(:codes) {"#{IAC}#{IP}"}
|
64
|
+
its(:reply) {should == ''}
|
65
|
+
its(:plain) {should == plain_command}
|
66
|
+
end
|
67
|
+
|
68
|
+
context '(data mark)' do
|
69
|
+
let(:codes) {"#{IAC}#{DM}"}
|
70
|
+
its(:reply) {should == ''}
|
71
|
+
its(:plain) {should == plain_command}
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|