└── ftpd.rb /ftpd.rb: -------------------------------------------------------------------------------- 1 | # 2 | ## ftpd.rb 3 | ## a simple ruby ftp server 4 | # 5 | # version: 3 (2006-03-09) 6 | # 7 | # author: chris wanstrath // chris@ozmm.org 8 | # site: http://github.com/defunkt/ftpd.rb 9 | # 10 | # license: MIT License // http://www.opensource.org/licenses/mit-license.php 11 | # copyright: (c) two thousand six chris wanstrath 12 | # 13 | # tested on: ruby 1.8.4 (2005-12-24) [powerpc-darwin8.4.0] 14 | # 15 | # special thanks: 16 | # - Peter Harris for his ftpd.py (Jacqueline FTP) script 17 | # - RFC 959 (ftp) 18 | # 19 | # get started: 20 | # $ ruby ftpd.rb --help 21 | # 22 | 23 | %w[socket logger optparse yaml].each { |f| require f } 24 | 25 | Thread.abort_on_exception = true 26 | 27 | class FTPServer < TCPServer 28 | 29 | PROGRAM = "ftpd.rb" 30 | VERSION = 3 31 | AUTHOR = "Chris Wanstrath" 32 | AUTHOR_EMAIL = "chris@ozmm.org" 33 | 34 | LBRK = "\r\n" # ftp protocol says this is a line break 35 | 36 | # commands supported 37 | COMMANDS = %w[quit type user retr stor port cdup cwd dele rmd pwd list size 38 | syst site mkd] 39 | 40 | # setup a TCPServer instance and house our main loop 41 | def initialize(config) 42 | host = config[:host] 43 | port = config[:port] 44 | 45 | @config = config 46 | @logger = Logger.new(STDOUT) 47 | @logger.datetime_format = "%H:%M:%S" 48 | @logger.progname = "ftpd.rb" 49 | @logger.level = config[:debug] ? Logger::DEBUG : Logger::ERROR 50 | @threads = [] 51 | 52 | begin 53 | server = super(host, port) 54 | rescue Errno::EACCES 55 | fatal "The port you have chosen is already in use or reserved." 56 | return 57 | end 58 | 59 | @status = :alive 60 | 61 | notice "Server started successfully at ftp://#{host}:#{port} " << \ 62 | "[PID: #{Process.pid}]" 63 | 64 | # periodically check for inactive connections and kill them 65 | kill_dead_connections 66 | 67 | while (@status == :alive) 68 | begin 69 | socket = server.accept 70 | clients = 0 71 | @threads.each { |t| clients += 1 if t.alive? } 72 | if clients >= @config[:clients] 73 | socket.print "530 Too many connections" << LBRK 74 | socket.close 75 | else 76 | @threads << threaded_connection(socket) 77 | end 78 | rescue Interrupt 79 | @status = :dead 80 | rescue Exception => ex 81 | @status = :dead 82 | request ||= 'No request' 83 | fatal "#{ex.class}: #{ex.message} - #{request}\n\t#{ex.backtrace[0]}" 84 | end 85 | end 86 | 87 | notice "Shutting server down..." 88 | 89 | # clean up anything we've still got open - a simple join won't work because 90 | # we may still have open sockets, which we need to terminate 91 | @threads.each do |t| 92 | next if t.alive? == false 93 | sk = t[:socket] 94 | sk.close unless sk.nil? or sk.closed? or sk.is_a?(Socket) == false 95 | t[:socket] = sk = nil 96 | t.kill 97 | end 98 | server.close 99 | end 100 | 101 | private 102 | 103 | def threaded_connection(sock) 104 | Thread.new(sock) do |socket| 105 | thread[:socket] = socket 106 | thread[:mode] = :binary 107 | info = socket.peeraddr 108 | remote_port, remote_ip = info[1], info[3] 109 | thread[:addr] = [remote_ip, remote_port] 110 | debug "Got connection" 111 | response "200 #{@config[:host]}:#{@config[:port]} FTP server " \ 112 | "(#{PROGRAM}) ready." 113 | while socket.nil? == false and socket.closed? == false 114 | request = socket.gets 115 | response handler(request) 116 | end 117 | end 118 | end 119 | 120 | # send a message to the client 121 | def response(msg) 122 | sock = thread[:socket] 123 | sock.print msg << LBRK unless msg.nil? or sock.nil? or sock.closed? 124 | end 125 | 126 | # deals with the user input 127 | def handler(request) 128 | stamp! 129 | return if request.nil? or request.to_s == '' 130 | begin 131 | command = request[0,4].downcase.strip 132 | rqarray = request.split 133 | message = rqarray.length > 2 ? rqarray[1..rqarray.length] : rqarray[1] 134 | debug "Request: #{command}(#{message})" 135 | case command 136 | when *COMMANDS 137 | __send__ command, message 138 | else 139 | bad_command command, message 140 | end 141 | rescue Errno::EACCES, Errno::EPERM 142 | "553 Permission denied" 143 | rescue Errno::ENOENT 144 | "553 File doesn't exist" 145 | rescue Exception => e 146 | debug "Request: #{request}" 147 | fatal "Error: #{e.class} - #{e.message}\n\t#{e.backtrace[0]}" 148 | exit! 149 | end 150 | end 151 | 152 | # periodically kill inactive connections 153 | def kill_dead_connections 154 | Thread.new do 155 | loop do 156 | @threads.delete_if do |t| 157 | if Time.now - t[:stamp] > 400 158 | t[:socket].close 159 | t.kill 160 | debug "Killed inactive connection." 161 | true 162 | end 163 | end 164 | sleep 20 165 | end 166 | end 167 | end 168 | 169 | # set a timestamp (user's last action) 170 | def stamp!; thread[:stamp] = Time.now end 171 | 172 | # Thread.current wrapper 173 | def thread; Thread.current end 174 | 175 | # 176 | # logging functions 177 | # 178 | def debug(msg) 179 | @logger.debug "#{remote_addr} - #{msg} (threads: #{show_threads})" 180 | end 181 | 182 | # a bunch of wrappers for Logger methods 183 | %w[warn info error fatal].each do |meth| 184 | define_method( meth.to_sym ) { |msg| @logger.send(meth.to_sym, msg) } 185 | end 186 | 187 | # always show 188 | def notice(msg) STDOUT << "=> #{msg}\n" end 189 | 190 | # where the user's from 191 | def remote_addr; thread[:addr].join(':') end 192 | 193 | # thread count 194 | def show_threads 195 | threads = 0 196 | @threads.each { |t| threads += 1 if t.alive? } 197 | threads 198 | end 199 | 200 | # command not understood 201 | def bad_command(name, *params) 202 | arg = (params.is_a? Array) ? params.join(' ') : params 203 | if @config[:debug] 204 | "500 I don't understand " << name.to_s << "(" << arg << ")" 205 | else 206 | "500 Sorry, I don't understand #{name.to_s}" 207 | end 208 | end 209 | 210 | # 211 | # actions a user can perform 212 | # 213 | # all of these methods are expected to return a string 214 | # which will then be sent to the client. 215 | # 216 | 217 | # login 218 | def user(msg) 219 | return "502 Only anonymous user implemented" if msg != 'anonymous' 220 | debug "User #{msg} logged in." 221 | thread[:user] = msg 222 | "230 OK, password not required" 223 | end 224 | 225 | # open up a port / socket to send data 226 | def port(msg) 227 | nums = msg.split(',') 228 | port = nums[4].to_i * 256 + nums[5].to_i 229 | host = nums[0..3].join('.') 230 | if thread[:datasocket] 231 | thread[:datasocket].close 232 | thread[:datasocket] = nil 233 | end 234 | thread[:datasocket] = TCPSocket.new(host, port) 235 | debug "Opened passive connection at #{host}:#{port}" 236 | "200 Passive connection established (#{port})" 237 | end 238 | 239 | # listen on a port 240 | def pasv(msg) 241 | "500 pasv not yet implemented" 242 | end 243 | 244 | # retrieve a file 245 | def retr(msg) 246 | response "125 Data transfer starting" 247 | bytes = send_data(File.new(msg, 'r')) 248 | "226 Closing data connection, sent #{bytes} bytes" 249 | end 250 | 251 | # upload a file 252 | def stor(msg) 253 | file = File.new(msg, 'w') 254 | response "125 Data transfer starting" 255 | data = thread[:datasocket].recv(1024) 256 | bytes = data.length 257 | file.write data 258 | debug "#{thread[:user]} created file #{Dir::pwd}/#{msg}" 259 | "200 OK, received #{bytes} bytes" 260 | end 261 | 262 | # make directory 263 | def mkd(msg) 264 | return %[521 "#{msg}" already exists] if File.directory? msg 265 | Dir::mkdir(msg) 266 | debug %[#{thread[:user]} created directory #{Dir::pwd}/#{msg}" 267 | "257 "#{msg}" created] 268 | end 269 | 270 | # crazy site command 271 | def site(msg) 272 | command = (msg.is_a?(Array) ? msg[0] : msg).downcase 273 | case command 274 | when 'chmod' 275 | File.chmod(msg[1].oct, msg[2]) 276 | return "200 CHMOD of #{msg[2]} to #{msg[1]} successful" 277 | end 278 | "502 Command not implemented" 279 | end 280 | 281 | # wrapper for rmd 282 | def dele(msg); rmd(msg); end 283 | 284 | # delete a file / dir 285 | def rmd(msg) 286 | if File.directory? msg 287 | Dir::delete msg 288 | elsif File.file? msg 289 | File::delete msg 290 | end 291 | debug "#{thread[:user]} deleted #{Dir::pwd}/#{msg}" 292 | "200 OK, deleted #{msg}" 293 | end 294 | 295 | # file size in bytes 296 | def size(msg) 297 | bytes = File.size(msg) 298 | "#{msg} #{bytes}" 299 | end 300 | 301 | # report the name of the server 302 | def syst(msg) 303 | "215 UNIX #{PROGRAM} v#{VERSION} " 304 | end 305 | 306 | # list files in current directory 307 | def list(msg) 308 | response "125 Opening ASCII mode data connection for file list" 309 | send_data(`ls -l`.split("\n").join(LBRK) << LBRK) 310 | "226 Transfer complete" 311 | end 312 | 313 | # crazy tab nlst command 314 | def nlst(msg) 315 | Dir["*"].join " " 316 | end 317 | 318 | # print the current directory 319 | def pwd(msg) 320 | %[257 "#{Dir.pwd}" is the current directory] 321 | end 322 | 323 | # change directory 324 | def cwd(msg) 325 | begin 326 | Dir.chdir(msg) 327 | rescue Errno::ENOENT 328 | "550 Directory not found" 329 | else 330 | "250 Directory changed to " << Dir.pwd 331 | end 332 | end 333 | 334 | # go up a directory, really just an alias 335 | def cdup(msg) 336 | cwd('..') 337 | end 338 | 339 | # ascii / binary mode 340 | def type(msg) 341 | if msg == "A" 342 | thread[:mode] == :ascii 343 | "200 Type set to ASCII" 344 | elsif msg == "I" 345 | thread[:mode] == :binary 346 | "200 Type set to binary" 347 | end 348 | end 349 | 350 | # quit the ftp session 351 | def quit(msg = false) 352 | thread[:socket].close 353 | thread[:socket] = nil 354 | debug "User #{thread[:user]} disconnected." 355 | "221 Laterz" 356 | end 357 | 358 | # help! 359 | def help(msg) 360 | commands = COMMANDS 361 | commands.sort! 362 | response "214-" 363 | response " The following commands are recognized." 364 | i = 1 365 | str = " " 366 | commands.each do |c| 367 | str += "#{c}" 368 | str += "\t\t" 369 | str += LBRK << " " if (i % 3) == 0 370 | i += 1 371 | end 372 | response str 373 | "214 Send comments to #{AUTHOR_EMAIL}" 374 | end 375 | 376 | # no operation 377 | def noop(msg); "200 "; end 378 | 379 | # send data over a connection 380 | def send_data(data) 381 | bytes = 0 382 | begin 383 | # this is where we do ascii / binary modes, if we ever get that far 384 | data.each do |line| 385 | if thread[:mode] == :binary 386 | thread[:datasocket].syswrite(line) 387 | else 388 | thread[:datasocket].send(line, 0) 389 | end 390 | bytes += line.length 391 | end 392 | rescue Errno::EPIPE 393 | debug "#{thread[:user]} aborted file transfer" 394 | return quit 395 | else 396 | debug "#{thread[:user]} got #{bytes} bytes" 397 | ensure 398 | thread[:datasocket].close 399 | thread[:datasocket] = nil 400 | end 401 | bytes 402 | end 403 | 404 | # 405 | # graveyard -- non implemented features with no plans 406 | # 407 | def mode(msg) 408 | "202 Stream mode only supported" 409 | end 410 | 411 | def stru(msg) 412 | "202 File structure only supported" 413 | end 414 | 415 | end 416 | 417 | class FTPConfig 418 | 419 | # 420 | # command line option business 421 | # 422 | def self.parse_options(args) 423 | config = Hash.new 424 | config[:d] = Hash.new # the defaults 425 | config[:d][:host] = "127.0.0.1" 426 | config[:d][:port] = 21 427 | config[:d][:clients] = 5 428 | config[:d][:yaml_cfg] = "ftpd.yml" 429 | config[:d][:debug] = false 430 | 431 | opts = OptionParser.new do |opts| 432 | opts.banner = "Usage: #{FTPServer::PROGRAM} [options]" 433 | 434 | opts.separator "" 435 | opts.separator "Specific options:" 436 | 437 | opts.on("-h", "--host HOST", 438 | "The hostname or ip of the host to bind to " << \ 439 | "(default 127.0.0.1)") do |host| 440 | config[:host] = host 441 | end 442 | 443 | opts.on("-p", "--port PORT", 444 | "The port to listen on (default 21)") do |port| 445 | config[:port] = port 446 | end 447 | 448 | opts.on("-c", "--clients NUM", Integer, 449 | "The number of connections to allow at once (default 5)") do |c| 450 | config[:clients] = c 451 | end 452 | 453 | opts.on("--config FILE", "Load configuration from YAML file") do |file| 454 | config[:yaml_cfg] = file 455 | end 456 | 457 | opts.on("--sample", "See a sample YAML config file") do 458 | sample = Hash.new 459 | config[:d].each do |k, v| 460 | sample = sample.merge(k.to_s => v) unless k == :yaml_cfg 461 | end 462 | puts YAML::dump( sample ) 463 | exit 464 | end 465 | 466 | opts.on("-d", "--debug", "Turn on debugging mode") do 467 | config[:debug] = true 468 | end 469 | 470 | opts.separator "" 471 | opts.separator "Common options:" 472 | 473 | opts.on_tail("--help", "Show this message") do 474 | puts opts 475 | exit 476 | end 477 | 478 | opts.on_tail("-v", "--version", "Show version") do 479 | puts "#{FTPServer::PROGRAM} FTP server v#{FTPServer::VERSION}" 480 | exit 481 | end 482 | end 483 | opts.parse!(args) 484 | config 485 | end 486 | 487 | end 488 | 489 | # 490 | # config 491 | # 492 | if $0 == __FILE__ 493 | # gather config options 494 | config = FTPConfig.parse_options(ARGV) 495 | 496 | # try and get name for yaml config file from command line or defaults 497 | config_file = config[:yaml_cfg] || config[:d][:yaml_cfg] 498 | 499 | # if file exists, override default options with arguments from it 500 | if File.file? config_file 501 | yaml = YAML.load(File.open(config_file, "r")) 502 | yaml.each { |k,v| config[k.to_sym] ||= v } 503 | end 504 | 505 | # now fill in missing config options from the default set 506 | config[:d].each { |k,v| config[k.to_sym] ||= v } 507 | 508 | # run the daemon 509 | server = FTPServer.new(config) 510 | end 511 | --------------------------------------------------------------------------------