ftpd 0.4.0 → 0.5.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 +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
|