raval 0.0.1
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/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +202 -0
- data/examples/fake.rb +110 -0
- data/examples/fog.rb +183 -0
- data/lib/raval.rb +8 -0
- data/lib/raval/active_socket.rb +49 -0
- data/lib/raval/app.rb +137 -0
- data/lib/raval/connection.rb +50 -0
- data/lib/raval/handler.rb +549 -0
- data/lib/raval/list_formatter.rb +48 -0
- data/lib/raval/passive_socket.rb +86 -0
- data/lib/raval/server.rb +45 -0
- data/spec/handler_spec.rb +832 -0
- data/spec/list_formatter_spec.rb +71 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/test_driver.rb +88 -0
- metadata +165 -0
data/lib/raval.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'celluloid/io'
|
4
|
+
require 'socket'
|
5
|
+
|
6
|
+
module Raval
|
7
|
+
# In Active FTP mode, the client opens a listening data socket on their host
|
8
|
+
# and we connect to it.
|
9
|
+
#
|
10
|
+
# A different class is used when operating in passive FTP mode. They both
|
11
|
+
# have a #read and #write method, so the quack like each other in the ways
|
12
|
+
# that matter.
|
13
|
+
class ActiveSocket
|
14
|
+
include Celluloid::IO
|
15
|
+
|
16
|
+
def initialize(host, port)
|
17
|
+
@host, @port = host, port
|
18
|
+
@socket = nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def connect
|
22
|
+
unless @socket
|
23
|
+
@socket = ::TCPSocket.new(@host, @port)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def read
|
28
|
+
connect unless @socket
|
29
|
+
@socket.readpartial(4096)
|
30
|
+
rescue EOFError, Errno::ECONNRESET
|
31
|
+
close
|
32
|
+
nil
|
33
|
+
end
|
34
|
+
|
35
|
+
def write(data)
|
36
|
+
connect unless @socket
|
37
|
+
@socket.write(data)
|
38
|
+
end
|
39
|
+
|
40
|
+
def connected?
|
41
|
+
@socket != nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def close
|
45
|
+
@socket.close if @socket
|
46
|
+
@socket = nil
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
data/lib/raval/app.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Raval
|
4
|
+
|
5
|
+
# Holds the logic for booting a raval server and configuring the
|
6
|
+
# process as required, binding to a port, writing a pid file, etc.
|
7
|
+
#
|
8
|
+
# Use it like so:
|
9
|
+
#
|
10
|
+
# Raval::App.start(:port => 3000)
|
11
|
+
#
|
12
|
+
# Options:
|
13
|
+
#
|
14
|
+
# :driver - the driver class that implements persistance
|
15
|
+
# :driver_opts - an optional Hash of options to be passed to the driver
|
16
|
+
# constructor
|
17
|
+
# :host - the host IP to listen on. [default: 127.0.0.1]
|
18
|
+
# :port - the TCP port to listen on [default: 21]
|
19
|
+
# :pid_file - a path to write the process pid to. Useful for monitoring
|
20
|
+
# :user - the user ID to change the process owner to
|
21
|
+
# :group - the group ID to change the process owner to
|
22
|
+
# :name - an optional name to place in the process description
|
23
|
+
class App
|
24
|
+
|
25
|
+
def initialize(options = {})
|
26
|
+
@options = options
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.start(options = {})
|
30
|
+
self.new(options).start
|
31
|
+
end
|
32
|
+
|
33
|
+
def start
|
34
|
+
update_procline
|
35
|
+
|
36
|
+
puts "Starting ftp server on 0.0.0.0:#{port}"
|
37
|
+
Raval::Server.supervise(host,port, driver, driver_opts)
|
38
|
+
|
39
|
+
change_gid
|
40
|
+
change_uid
|
41
|
+
write_pid
|
42
|
+
setup_signal_handlers
|
43
|
+
sleep # for ever
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def name
|
49
|
+
@options[:name]
|
50
|
+
end
|
51
|
+
|
52
|
+
def host
|
53
|
+
@options.fetch(:host, "127.0.0.1")
|
54
|
+
end
|
55
|
+
|
56
|
+
def port
|
57
|
+
@options.fetch(:port, 21)
|
58
|
+
end
|
59
|
+
|
60
|
+
def driver_opts
|
61
|
+
@options[:driver_opts]
|
62
|
+
end
|
63
|
+
|
64
|
+
def driver
|
65
|
+
@options.fetch(:driver)
|
66
|
+
rescue KeyError
|
67
|
+
raise ArgumentError, "the :driver option must be provided"
|
68
|
+
end
|
69
|
+
|
70
|
+
def uid
|
71
|
+
if @options[:user]
|
72
|
+
begin
|
73
|
+
detail = Etc.getpwnam(@options[:user])
|
74
|
+
return detail.uid
|
75
|
+
rescue
|
76
|
+
$stderr.puts "user must be nil or a real account" if detail.nil?
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def gid
|
82
|
+
if @options[:group]
|
83
|
+
begin
|
84
|
+
detail = Etc.getgrnam(@options[:group])
|
85
|
+
return detail.gid
|
86
|
+
rescue
|
87
|
+
$stderr.puts "group must be nil or a real group" if detail.nil?
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def pid_file
|
93
|
+
@options[:pid_file]
|
94
|
+
end
|
95
|
+
|
96
|
+
def write_pid
|
97
|
+
if pid_file
|
98
|
+
File.open(pid_file, "w") { |io| io.write pid }
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def update_procline
|
103
|
+
if name
|
104
|
+
$0 = "raval ftpd [#{name}]"
|
105
|
+
else
|
106
|
+
$0 = "raval"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def change_gid
|
111
|
+
if gid && Process.gid == 0
|
112
|
+
Process.gid = gid
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def change_uid
|
117
|
+
if uid && Process.euid == 0
|
118
|
+
Process::Sys.setuid(uid)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def setup_signal_handlers
|
123
|
+
=begin
|
124
|
+
trap('QUIT') do
|
125
|
+
EM.stop
|
126
|
+
end
|
127
|
+
trap('TERM') do
|
128
|
+
EM.stop
|
129
|
+
end
|
130
|
+
trap('INT') do
|
131
|
+
EM.stop
|
132
|
+
end
|
133
|
+
=end
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Raval
|
4
|
+
# wraps a raw TCP socket and simplifies working with FTP style data.
|
5
|
+
#
|
6
|
+
# FTP commands and responses are always a single line ending in a line break,
|
7
|
+
# so make it easy to read and write single lines.
|
8
|
+
class Connection
|
9
|
+
|
10
|
+
BUFFER_SIZE = 1024
|
11
|
+
|
12
|
+
attr_reader :port, :host
|
13
|
+
attr_reader :myport, :myhost
|
14
|
+
|
15
|
+
def initialize(handler, socket)
|
16
|
+
@socket, @handler = socket, handler
|
17
|
+
_, @port, @host = socket.peeraddr
|
18
|
+
_, @myport, @myhost = socket.addr
|
19
|
+
handler.new_connection(self)
|
20
|
+
puts "*** Received connection from #{host}:#{port}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def send_data(str)
|
24
|
+
@socket.write(str)
|
25
|
+
end
|
26
|
+
|
27
|
+
def send_response(code, message)
|
28
|
+
@socket.write("#{code} #{message}#{Raval::LBRK}")
|
29
|
+
end
|
30
|
+
|
31
|
+
# Close the connection
|
32
|
+
def close
|
33
|
+
@socket.close
|
34
|
+
end
|
35
|
+
|
36
|
+
def read_commands
|
37
|
+
input = ""
|
38
|
+
while true
|
39
|
+
input << @socket.readpartial(BUFFER_SIZE)
|
40
|
+
match = input.match(/(^.+\r\n)/)
|
41
|
+
if match
|
42
|
+
line = match[1]
|
43
|
+
input = input[line.bytesize, line.bytesize]
|
44
|
+
@handler.receive_line(line)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,549 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'raval/active_socket'
|
4
|
+
require 'raval/passive_socket'
|
5
|
+
require 'raval/list_formatter'
|
6
|
+
require 'stringio'
|
7
|
+
require 'tempfile'
|
8
|
+
|
9
|
+
module Raval
|
10
|
+
# implement the FTP wire protocol, deferring the users driver when interaction
|
11
|
+
# with the persistence layer is required. Nothing in this class is network
|
12
|
+
# aware, it should remain ignorant of where the FTP connection is from and
|
13
|
+
# assume the connection will handle it.
|
14
|
+
#
|
15
|
+
# A new instance of this class is created for each active FTP connection, so
|
16
|
+
# it's OK to store connection specific state in instance variables.
|
17
|
+
class Handler
|
18
|
+
|
19
|
+
COMMANDS = %w[quit type user retr stor eprt port cdup cwd dele rmd pwd
|
20
|
+
list size syst mkd pass xcup xpwd xcwd xrmd rest allo nlst
|
21
|
+
pasv epsv help noop mode rnfr rnto stru mdtm]
|
22
|
+
|
23
|
+
REQUIRE_AUTH = %w[cdup xcup cwd xcwd eprt epsv mode nlst list mdtm pasv port
|
24
|
+
pwd xpwd retr size stor stru syst type]
|
25
|
+
REQUIRE_PARAM = %w[cwd xcwd eprt mdtm mode pass port retr size stor stru
|
26
|
+
type user]
|
27
|
+
|
28
|
+
attr_reader :connection, :name_prefix
|
29
|
+
|
30
|
+
def initialize(driver)
|
31
|
+
@driver = driver
|
32
|
+
end
|
33
|
+
|
34
|
+
def new_connection(connection)
|
35
|
+
@mode = :binary
|
36
|
+
@user = nil
|
37
|
+
@name_prefix = "/"
|
38
|
+
@connection = connection
|
39
|
+
@connection.send_response(220, "FTP server (raval) ready")
|
40
|
+
end
|
41
|
+
|
42
|
+
def receive_line(line)
|
43
|
+
cmd, param = parse_request(line)
|
44
|
+
|
45
|
+
# if the command is contained in the whitelist, and there is a method
|
46
|
+
# to handle it, call it. Otherwise send an appropriate response to the
|
47
|
+
# client
|
48
|
+
if REQUIRE_AUTH.include?(cmd) && !logged_in?
|
49
|
+
@connection.send_response(530, "Not logged in")
|
50
|
+
elsif REQUIRE_PARAM.include?(cmd) && param.nil?
|
51
|
+
@connection.send_response(553, "action aborted, required param missing")
|
52
|
+
elsif COMMANDS.include?(cmd) && self.respond_to?("cmd_#{cmd}".to_sym, true)
|
53
|
+
self.__send__("cmd_#{cmd}".to_sym, param)
|
54
|
+
else
|
55
|
+
@connection.send_response(500, "Sorry, I don't understand #{cmd.upcase}")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def cmd_allo(param)
|
62
|
+
@connection.send_response(202, "Obsolete")
|
63
|
+
end
|
64
|
+
|
65
|
+
# handle the HELP FTP command by sending a list of available commands.
|
66
|
+
def cmd_help(param)
|
67
|
+
@connection.send_response("214-", "The following commands are recognized.")
|
68
|
+
commands = COMMANDS
|
69
|
+
str = ""
|
70
|
+
commands.sort.each_slice(3) { |slice|
|
71
|
+
str += " " + slice.join("\t\t") + Raval::LBRK
|
72
|
+
}
|
73
|
+
@connection.send_response(str, "")
|
74
|
+
@connection.send_response(214, "End of list.")
|
75
|
+
end
|
76
|
+
|
77
|
+
# the original FTP spec had various options for hosts to negotiate how data
|
78
|
+
# would be sent over the data socket, In reality these days (S)tream mode
|
79
|
+
# is all that is used for the mode - data is just streamed down the data
|
80
|
+
# socket unchanged.
|
81
|
+
#
|
82
|
+
def cmd_mode(param)
|
83
|
+
if param.upcase.eql?("S")
|
84
|
+
@connection.send_response(200, "OK")
|
85
|
+
else
|
86
|
+
@connection.send_response(504, "MODE is an obsolete command")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# handle the NOOP FTP command. This is essentially a ping from the client
|
91
|
+
# so we just respond with an empty 200 message.
|
92
|
+
def cmd_noop(param)
|
93
|
+
@connection.send_response(200, "")
|
94
|
+
end
|
95
|
+
|
96
|
+
# handle the QUIT FTP command by closing the connection
|
97
|
+
def cmd_quit(param)
|
98
|
+
@connection.send_response(221, "Bye")
|
99
|
+
@connection.close
|
100
|
+
end
|
101
|
+
|
102
|
+
# like the MODE and TYPE commands, stru[cture] dates back to a time when the FTP
|
103
|
+
# protocol was more aware of the content of the files it was transferring, and
|
104
|
+
# would sometimes be expected to translate things like EOL markers on the fly.
|
105
|
+
#
|
106
|
+
# These days files are sent unmodified, and F(ile) mode is the only one we
|
107
|
+
# really need to support.
|
108
|
+
def cmd_stru(param)
|
109
|
+
if param.upcase.eql?("F")
|
110
|
+
@connection.send_response(200, "OK")
|
111
|
+
else
|
112
|
+
@connection.send_response(504, "STRU is an obsolete command")
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# return the name of the server
|
117
|
+
def cmd_syst(param)
|
118
|
+
@connection.send_response(215, "UNIX Type: L8")
|
119
|
+
end
|
120
|
+
|
121
|
+
# like the MODE and STRU commands, TYPE dates back to a time when the FTP
|
122
|
+
# protocol was more aware of the content of the files it was transferring, and
|
123
|
+
# would sometimes be expected to translate things like EOL markers on the fly.
|
124
|
+
#
|
125
|
+
# Valid options were A(SCII), I(mage), E(BCDIC) or LN (for local type). Since
|
126
|
+
# we plan to just accept bytes from the client unchanged, I think Image mode is
|
127
|
+
# adequate. The RFC requires we accept ASCII mode however, so accept it, but
|
128
|
+
# ignore it.
|
129
|
+
def cmd_type(param)
|
130
|
+
if param.upcase.eql?("A")
|
131
|
+
@connection.send_response(200, "Type set to ASCII")
|
132
|
+
elsif param.upcase.eql?("I")
|
133
|
+
@connection.send_response(200, "Type set to binary")
|
134
|
+
else
|
135
|
+
@connection.send_response(500, "Invalid type")
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# handle the USER FTP command. This is a user attempting to login.
|
140
|
+
# we simply store the requested user name as an instance variable
|
141
|
+
# and wait for the password to be submitted before doing anything
|
142
|
+
def cmd_user(param)
|
143
|
+
@connection.send_response(500, "Already logged in") and return unless @user.nil?
|
144
|
+
@requested_user = param
|
145
|
+
@connection.send_response(331, "OK, password required")
|
146
|
+
end
|
147
|
+
|
148
|
+
# handle the PASS FTP command. This is the second stage of a user logging in
|
149
|
+
def cmd_pass(param)
|
150
|
+
@connection.send_response(202, "User already logged in") and return unless @user.nil?
|
151
|
+
@connection.send_response(530, "password with no username") and return if @requested_user.nil?
|
152
|
+
|
153
|
+
if @driver.authenticate(@requested_user, param)
|
154
|
+
@name_prefix = "/"
|
155
|
+
@user = @requested_user
|
156
|
+
@requested_user = nil
|
157
|
+
@connection.send_response(230, "OK, password correct")
|
158
|
+
else
|
159
|
+
@user = nil
|
160
|
+
@connection.send_response(530, "incorrect login. not logged in.")
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Passive FTP. At the clients request, listen on a port for an incoming
|
165
|
+
# data connection. The listening socket is opened on a random port, so
|
166
|
+
# the host and port is sent back to the client on the control socket.
|
167
|
+
#
|
168
|
+
def cmd_pasv(param)
|
169
|
+
host, port = start_passive_socket
|
170
|
+
|
171
|
+
p1, p2 = *port.divmod(256)
|
172
|
+
host_with_commas = host.split(".").join(",")
|
173
|
+
port_with_commas = "#{p1},#{p2}"
|
174
|
+
message = "Entering Passive Mode (#{host_with_commas},#{port_with_commas})"
|
175
|
+
|
176
|
+
@connection.send_response(227, message)
|
177
|
+
end
|
178
|
+
|
179
|
+
# listen on a port, see RFC 2428
|
180
|
+
#
|
181
|
+
def cmd_epsv(param)
|
182
|
+
host, port = start_passive_socket
|
183
|
+
|
184
|
+
send_response "229 Entering Extended Passive Mode (|||#{port}|)"
|
185
|
+
end
|
186
|
+
|
187
|
+
# Active FTP. An alternative to Passive FTP. The client has a listening socket
|
188
|
+
# open, waiting for us to connect and establish a data socket. Attempt to
|
189
|
+
# open a connection to the host and port they specify and save the connection,
|
190
|
+
# ready for either end to send something down it.
|
191
|
+
def cmd_port(param)
|
192
|
+
nums = param.split(',')
|
193
|
+
port = nums[4].to_i * 256 + nums[5].to_i
|
194
|
+
host = nums[0..3].join('.')
|
195
|
+
close_datasocket
|
196
|
+
start_active_socket(host, port)
|
197
|
+
end
|
198
|
+
|
199
|
+
# open a data connection to the client on the requested port. See RFC 2428
|
200
|
+
def cmd_eprt(param)
|
201
|
+
delim = param[0,1]
|
202
|
+
m, af, host, port = *param.match(/#{delim}(.+?)#{delim}(.+?)#{delim}(.+?)#{delim}/)
|
203
|
+
port = port.to_i
|
204
|
+
close_datasocket
|
205
|
+
|
206
|
+
if af.to_i != 1 && ad.to_i != 2
|
207
|
+
@connection.send_response(522, "Network protocol not supported, use (1,2)")
|
208
|
+
else
|
209
|
+
start_active_socket(host, port)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# go up a directory, really just an alias
|
214
|
+
def cmd_cdup(param)
|
215
|
+
cmd_cwd("..")
|
216
|
+
end
|
217
|
+
alias cmd_xcup cmd_cdup
|
218
|
+
|
219
|
+
# change directory
|
220
|
+
def cmd_cwd(param)
|
221
|
+
path = build_path(param)
|
222
|
+
|
223
|
+
if @driver.change_dir(path)
|
224
|
+
@name_prefix = path
|
225
|
+
@connection.send_response(250, "Directory changed to #{path}")
|
226
|
+
else
|
227
|
+
send_permission_denied
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# As per RFC1123, XCWD is a synonym for CWD
|
232
|
+
alias cmd_xcwd cmd_cwd
|
233
|
+
|
234
|
+
# make directory
|
235
|
+
def cmd_mkd(param)
|
236
|
+
send_unauthorised and return unless logged_in?
|
237
|
+
send_param_required and return if param.nil?
|
238
|
+
|
239
|
+
if @driver.make_dir(build_path(param))
|
240
|
+
@connection.send_response(257, "Directory created")
|
241
|
+
else
|
242
|
+
send_action_not_taken
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# return the current directory
|
247
|
+
def cmd_pwd(param)
|
248
|
+
@connection.send_response(257, "\"#{@name_prefix}\" is the current directory")
|
249
|
+
end
|
250
|
+
|
251
|
+
# As per RFC1123, XPWD is a synonym for PWD
|
252
|
+
alias cmd_xpwd cmd_pwd
|
253
|
+
|
254
|
+
# delete a directory
|
255
|
+
def cmd_rmd(param)
|
256
|
+
send_unauthorised and return unless logged_in?
|
257
|
+
send_param_required and return if param.nil?
|
258
|
+
|
259
|
+
if @driver.delete_dir(build_path(param))
|
260
|
+
@connection.send_response(250, "Directory deleted.")
|
261
|
+
else
|
262
|
+
send_action_not_taken
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# As per RFC1123, XRMD is a synonym for RMD
|
267
|
+
alias cmd_xrmd cmd_rmd
|
268
|
+
|
269
|
+
# return a listing of the current directory, one per line, each line
|
270
|
+
# separated by the standard FTP EOL sequence. The listing is returned
|
271
|
+
# to the client over a data socket.
|
272
|
+
#
|
273
|
+
def cmd_nlst(param)
|
274
|
+
@connection.send_response(150, "Opening ASCII mode data connection for file list")
|
275
|
+
|
276
|
+
files = @driver.dir_contents(build_path(param))
|
277
|
+
formatter = ListFormatter.new(files)
|
278
|
+
send_outofband_data(formatter.short)
|
279
|
+
end
|
280
|
+
|
281
|
+
# return a detailed list of files and directories
|
282
|
+
def cmd_list(param)
|
283
|
+
@connection.send_response(150, "Opening ASCII mode data connection for file list")
|
284
|
+
|
285
|
+
param = '' if param.to_s == '-a'
|
286
|
+
|
287
|
+
files = @driver.dir_contents(build_path(param))
|
288
|
+
formatter = ListFormatter.new(files)
|
289
|
+
send_outofband_data(formatter.detailed)
|
290
|
+
end
|
291
|
+
|
292
|
+
# delete a file
|
293
|
+
def cmd_dele(param)
|
294
|
+
send_unauthorised and return unless logged_in?
|
295
|
+
send_param_required and return if param.nil?
|
296
|
+
|
297
|
+
path = build_path(param)
|
298
|
+
|
299
|
+
if @driver.delete_file(path)
|
300
|
+
@connection.send_response(250, "File deleted")
|
301
|
+
else
|
302
|
+
send_action_not_taken
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# resume downloads
|
307
|
+
def cmd_rest(param)
|
308
|
+
@connection.send_response(500, "Feature not implemented")
|
309
|
+
end
|
310
|
+
|
311
|
+
# send a file to the client
|
312
|
+
def cmd_retr(param)
|
313
|
+
path = build_path(param)
|
314
|
+
|
315
|
+
io = @driver.get_file(path)
|
316
|
+
if io
|
317
|
+
@connection.send_response(150, "Data transfer starting #{io.size} bytes")
|
318
|
+
send_outofband_data(io)
|
319
|
+
else
|
320
|
+
@connection.send_response(551, "file not available")
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# rename a file
|
325
|
+
def cmd_rnfr(param)
|
326
|
+
send_unauthorised and return unless logged_in?
|
327
|
+
send_param_required and return if param.nil?
|
328
|
+
|
329
|
+
@from_filename = build_path(param)
|
330
|
+
@connection.send_response(350, "Requested file action pending further information.")
|
331
|
+
end
|
332
|
+
|
333
|
+
# rename a file
|
334
|
+
def cmd_rnto(param)
|
335
|
+
send_unauthorised and return unless logged_in?
|
336
|
+
send_param_required and return if param.nil?
|
337
|
+
|
338
|
+
if @driver.rename(@from_filename, build_path(param))
|
339
|
+
@connection.send_response(250, "File renamed.")
|
340
|
+
else
|
341
|
+
send_action_not_taken
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
# return the size of a file in bytes
|
346
|
+
def cmd_size(param)
|
347
|
+
bytes = @driver.bytes(build_path(param))
|
348
|
+
if bytes
|
349
|
+
@connection.send_response(213, bytes)
|
350
|
+
else
|
351
|
+
@connection.send_response(450, "file not available")
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# return the last modified time of a file
|
356
|
+
#
|
357
|
+
def cmd_mdtm(param)
|
358
|
+
time = @driver.modified_time(build_path(param))
|
359
|
+
if time
|
360
|
+
@connection.send_response(213, "#{time.strftime("%Y%m%d%H%M%S")}")
|
361
|
+
else
|
362
|
+
@connection.send_response(450, "file not available")
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# save a file from a client
|
367
|
+
def cmd_stor(param)
|
368
|
+
path = build_path(param)
|
369
|
+
|
370
|
+
if @driver.respond_to?(:put_file_streamed)
|
371
|
+
cmd_stor_streamed(path)
|
372
|
+
elsif @driver.respond_to?(:put_file)
|
373
|
+
cmd_stor_tempfile(path)
|
374
|
+
else
|
375
|
+
raise "driver MUST respond to put_file OR put_file_streamed"
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
def cmd_stor_streamed(target_path)
|
380
|
+
if @datasocket
|
381
|
+
@connection.send_response(150, "Data transfer starting")
|
382
|
+
@driver.put_file_streamed(target_path, @datasocket) do |bytes|
|
383
|
+
if bytes
|
384
|
+
@connection.send_response(226, "OK, received #{bytes} bytes")
|
385
|
+
else
|
386
|
+
send_action_not_taken
|
387
|
+
end
|
388
|
+
end
|
389
|
+
else
|
390
|
+
@connection.send_response(425, "Error establishing connection")
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
def cmd_stor_tempfile(target_path)
|
395
|
+
Tempfile.open("raval") do |tmpfile|
|
396
|
+
tmpfile.binmode
|
397
|
+
|
398
|
+
@connection.send_response(150, "Data transfer starting")
|
399
|
+
while chunk = @datasocket.read
|
400
|
+
tmpfile.write(chunk)
|
401
|
+
end
|
402
|
+
tmpfile.flush
|
403
|
+
tmpfile.close
|
404
|
+
bytes = @driver.put_file(target_path, tmpfile.path)
|
405
|
+
if bytes
|
406
|
+
@connection.send_response(226, "OK, received #{bytes} bytes")
|
407
|
+
else
|
408
|
+
send_action_not_taken
|
409
|
+
end
|
410
|
+
end
|
411
|
+
end
|
412
|
+
|
413
|
+
def build_path(filename = nil)
|
414
|
+
if filename && filename[0,1] == "/"
|
415
|
+
path = File.expand_path(filename)
|
416
|
+
elsif filename && filename != '-a'
|
417
|
+
path = File.expand_path("#{@name_prefix}/#{filename}")
|
418
|
+
else
|
419
|
+
path = File.expand_path(@name_prefix)
|
420
|
+
end
|
421
|
+
path.gsub(/\/+/,"/")
|
422
|
+
end
|
423
|
+
|
424
|
+
# send data to the client across the data socket.
|
425
|
+
#
|
426
|
+
def send_outofband_data(data)
|
427
|
+
wait_for_datasocket do |datasocket|
|
428
|
+
if datasocket.nil?
|
429
|
+
@connection.send_response(425, "Error establishing connection")
|
430
|
+
return
|
431
|
+
end
|
432
|
+
|
433
|
+
if data.is_a?(Array)
|
434
|
+
data = data.join(LBRK) << LBRK
|
435
|
+
end
|
436
|
+
data = StringIO.new(data) if data.kind_of?(String)
|
437
|
+
|
438
|
+
# blocks until all data is sent
|
439
|
+
begin
|
440
|
+
bytes = 0
|
441
|
+
data.each do |line|
|
442
|
+
datasocket.write(line)
|
443
|
+
bytes += line.bytesize
|
444
|
+
end
|
445
|
+
@connection.send_response(226, "Closing data connection, sent #{bytes} bytes")
|
446
|
+
rescue => e
|
447
|
+
puts e.inspect
|
448
|
+
ensure
|
449
|
+
close_datasocket
|
450
|
+
data.close if data.respond_to?(:close)
|
451
|
+
end
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
def start_active_socket(host, port)
|
456
|
+
# close any existing data socket
|
457
|
+
close_datasocket
|
458
|
+
|
459
|
+
@datasocket = ActiveSocket.new(host, port)
|
460
|
+
@datasocket.async.connect
|
461
|
+
wait_for_datasocket do
|
462
|
+
@connection.send_response(200, "Connection established (#{port})")
|
463
|
+
end
|
464
|
+
rescue => e
|
465
|
+
puts "Error opening data connection to #{host}:#{port}"
|
466
|
+
puts e.inspect
|
467
|
+
@connection.send_response(425, "Data connection failed")
|
468
|
+
end
|
469
|
+
|
470
|
+
def start_passive_socket
|
471
|
+
# close any existing data socket
|
472
|
+
close_datasocket
|
473
|
+
|
474
|
+
# open a listening socket on the appropriate host
|
475
|
+
# and on a random port
|
476
|
+
@datasocket = PassiveSocket.new(@connection.myhost)
|
477
|
+
|
478
|
+
[@connection.myhost, @datasocket.port]
|
479
|
+
end
|
480
|
+
|
481
|
+
# split a client's request into command and parameter components
|
482
|
+
def parse_request(data)
|
483
|
+
data.strip!
|
484
|
+
space = data.index(" ")
|
485
|
+
if space
|
486
|
+
cmd = data[0, space]
|
487
|
+
param = data[space+1, data.length - space]
|
488
|
+
param = nil if param.strip.size == 0
|
489
|
+
else
|
490
|
+
cmd = data
|
491
|
+
param = nil
|
492
|
+
end
|
493
|
+
|
494
|
+
[cmd.downcase, param]
|
495
|
+
end
|
496
|
+
|
497
|
+
def close_datasocket
|
498
|
+
if @datasocket
|
499
|
+
@datasocket.close
|
500
|
+
@datasocket.terminate
|
501
|
+
@datasocket = nil
|
502
|
+
end
|
503
|
+
|
504
|
+
# stop listening for data socket connections, we have one
|
505
|
+
#if @listen_sig
|
506
|
+
# PassiveSocket.stop(@listen_sig)
|
507
|
+
# @listen_sig = nil
|
508
|
+
#end
|
509
|
+
end
|
510
|
+
|
511
|
+
# waits for the data socket to be established
|
512
|
+
def wait_for_datasocket(interval = 0.1, &block)
|
513
|
+
if (@datasocket.nil? || !@datasocket.connected?) && interval < 25
|
514
|
+
sleep interval
|
515
|
+
wait_for_datasocket(interval * 2, &block)
|
516
|
+
return
|
517
|
+
end
|
518
|
+
if @datasocket.connected?
|
519
|
+
yield @datasocket
|
520
|
+
else
|
521
|
+
yield nil
|
522
|
+
end
|
523
|
+
end
|
524
|
+
|
525
|
+
def logged_in?
|
526
|
+
@user.nil? ? false : true
|
527
|
+
end
|
528
|
+
|
529
|
+
def send_param_required
|
530
|
+
@connection.send_response(553, "action aborted, required param missing")
|
531
|
+
end
|
532
|
+
|
533
|
+
def send_permission_denied
|
534
|
+
@connection.send_response(550, "Permission denied")
|
535
|
+
end
|
536
|
+
|
537
|
+
def send_action_not_taken
|
538
|
+
@connection.send_response(550, "Action not taken")
|
539
|
+
end
|
540
|
+
|
541
|
+
def send_illegal_params
|
542
|
+
@connection.send_response(553, "action aborted, illegal params")
|
543
|
+
end
|
544
|
+
|
545
|
+
def send_unauthorised
|
546
|
+
@connection.send_response(530, "Not logged in")
|
547
|
+
end
|
548
|
+
end
|
549
|
+
end
|