# ftpd.tcl -- # # This file contains Tcl/Tk package to create a ftp daemon. # I believe it was originally written by Matt Newman (matt@sensus.org). # Modified by Dan Kuchler (kuchler@ajubasolutions.com) to handle # more ftp commands and to fix some bugs in the original implementation # that was found in the stdtcl module. # # See the file "license.terms" for information on usage and redistribution # of this file, and for a DISCLAIMER OF ALL WARRANTIES. # # RCS: @(#) $Id: ftpd.tcl,v 1.34 2011/08/09 20:55:38 andreas_kupries Exp $ # # Define the ftpd package version 1.2.5 package require Tcl 8.2 namespace eval ::ftpd { # The listening port. variable port 21 variable contact if {![info exists contact]} { global tcl_platform set contact "$tcl_platform(user)@[info hostname]" } variable cwd if {![info exists cwd]} { set cwd "" } variable welcome if {![info exists welcome]} { set welcome "[info hostname] FTP server ready." } # Global configuration. variable cfg if {![info exists cfg]} { array set cfg [list \ closeCmd {} \ authIpCmd {} \ authUsrCmd {::ftpd::anonAuth} \ authFileCmd {::ftpd::fileAuth} \ logCmd {::ftpd::logStderr} \ fsCmd {::ftpd::fsFile::fs} \ xferDoneCmd {}] } variable commands if {![info exists commands]} { array set commands [list \ ABOR {ABOR (abort operation)} \ ACCT {(specify account); unimplemented.} \ ALLO {(allocate storage - vacuously); unimplemented.} \ APPE {APPE file-name} \ CDUP {CDUP (change to parent directory)} \ CWD {CWD [ directory-name ]} \ DELE {DELE file-name} \ HELP {HELP [ ]} \ LIST {LIST [ path-name ]} \ NLST {NLST [ path-name ]} \ MAIL {(mail to user); unimplemented.} \ MDTM {MDTM path-name} \ MKD {MKD path-name} \ MLFL {(mail file); unimplemented.} \ MODE {(specify transfer mode); unimplemented.} \ MRCP {(mail recipient); unimplemented.} \ MRSQ {(mail recipient scheme question); unimplemented.} \ MSAM {(mail send to terminal and mailbox); unimplemented.} \ MSND {(mail send to terminal); unimplemented.} \ MSOM {(mail send to terminal or mailbox); unimplemented.} \ NOOP {NOOP} \ PASS {PASS password} \ PASV {(set server in passive mode); unimplemented.} \ PORT {PORT b0, b1, b2, b3, b4, b5} \ PWD {PWD (return current directory)} \ QUIT {QUIT (terminate service)} \ REIN {REIN (reinitialize server state)} \ REST {(restart command); unimplemented.} \ RETR {RETR file-name} \ RMD {RMD path-name} \ RNFR {RNFR file-name} \ RNTO {RNTO file-name} \ SIZE {SIZE path-name} \ SMNT {(structure mount); unimplemented.} \ STOR {STOR file-name} \ STOU {STOU file-name} \ STRU {(specify file structure); unimplemented.} \ SYST {SYST (get type of operating system)} \ TYPE {TYPE [ A | E | I | L ]} \ USER {USER username} \ XCUP {XCUP (change to parent directory)} \ XCWD {XCWD [ directory-name ]} \ XMKD {XMKD path-name} \ XPWD {XPWD (return current directory)} \ XRMD {XRMD path-name}] } variable passwords [list ] # Exported procedures namespace export config hasCallback logStderr namespace export fileAuth anonAuth unixAuth server accept read } # ::ftpd::config -- # # Configure the configurable parameters of the ftp daemon. # # Arguments: # options - -authIpCmd proc procedure that accepts or rejects an # incoming connection. A value of 0 or # an error causes the connection to be # rejected. There is no default. # -authUsrCmd proc procedure that accepts or rejects a # login. Defaults to ::ftpd::anonAuth # -authFileCmd proc procedure that accepts or rejects # access to read or write a certain # file or path. Defaults to # ::ftpd::userAuth # -logCmd proc procedure that logs information from # the ftp engine. Default is # ::ftpd::logStderr # -fsCmd proc procedure to connect the ftp engine # to the file system it operates on. # Default is ::ftpd::fsFile::fs # # Results: # None. # # Side Effects: # Changes the value of the specified configurables. proc ::ftpd::config {args} { # Processing of global configuration changes. package require cmdline variable cfg # Make default value be the current value so we can call this # command multiple times without resetting already set values array set cfg [cmdline::getoptions args [list \ [list closeCmd.arg $cfg(closeCmd) {Callback when a connection is closed.}] \ [list authIpCmd.arg $cfg(authIpCmd) {Callback to authenticate new connections based on the ip-address of the peer. Optional}] \ [list authUsrCmd.arg $cfg(authUsrCmd) {Callback to authenticate new connections based on the user logging in.}] \ [list authFileCmd.arg $cfg(authFileCmd) {Callback to accept or deny a users access to read and write to a specific path or file.}] \ [list logCmd.arg $cfg(logCmd) {Callback for log information generated by the FTP engine.}] \ [list xferDoneCmd.arg $cfg(xferDoneCmd) {Callback for transfer completion notification. Optional}] \ [list fsCmd.arg $cfg(fsCmd) {Callback to connect the engine to the filesystem it operates on.}]]] return } # ::ftpd::hasCallback -- # # Determines whether or not a non-NULL callback has been defined for one # of the callback types. # # Arguments: # callbackType - One of authIpCmd, authUsrCmd, logCmd, or fsCmd # # Results: # Returns 1 if a non-NULL callback has been specified for the # callbackType that is passed in. # # Side Effects: # None. proc ::ftpd::hasCallback {callbackType} { variable cfg return [expr {[info exists cfg($callbackType)] && [string length $cfg($callbackType)]}] } # ::ftpd::logStderr -- # # Outputs a message with the specified severity to stderr. The default # logCmd callback. # # Arguments: # severity - The severity of the error. One of debug, error, # or note. # text - The error message. # # Results: # None. # # Side Effects: # A message is written to the stderr channel. proc ::ftpd::logStderr {severity text} { # Standard log handler. Prints to stderr. puts stderr "\[$severity\] $text" return } # ::ftpd::Log -- # # Used for all ftpd logging. # # Arguments: # severity - The severity of the error. One of debug, error, # or note. # text - The error message. # # Results: # None. # # Side Effects: # The ftpd logCmd callback is called with the specified severity and # text if there is a non-NULL ftpCmd. proc ::ftpd::Log {severity text} { # Central call out to log handlers. variable cfg if {[hasCallback logCmd]} { set cmd $cfg(logCmd) lappend cmd $severity $text eval $cmd } return } # ::ftpd::fileAuth -- # # Given a username, path, and operation- decides whether or not to accept # the attempted read or write operation. # # Arguments: # user - The name of the user that is attempting to # connect to the ftpd. # path - The path or filename that the user is attempting # to read or write. # operation - read or write. # # Results: # Returns 0 if it rejects access and 1 if it accepts access. # # Side Effects: # None. proc ::ftpd::fileAuth {user path operation} { # Standard authentication handler if {(![Fs exists $path]) && ([string equal $operation "write"])} { if {[Fs exists [file dirname $path]]} { set path [file dirname $path] } } elseif {(![Fs exists $path]) && ([string equal $operation "read"])} { return 0 } if {[Fs exists $path]} { set mode [Fs permissions $path] if {([string equal $operation "read"] && (($mode & 00004) > 0)) || \ ([string equal $operation "write"] && (($mode & 00002) > 0))} { return 1 } } return 0 } # ::ftpd::anonAuth -- # # Given a username and password, decides whether or not to accept the # attempted login. This is the default ftpd authUsrCmd callback. By # default it accepts the annonymous user and does some basic checking # checking on the form of the password to see if it has the form of an # email address. # # Arguments: # user - The name of the user that is attempting to # connect to the ftpd. # pass - The password of the user that is attempting to # connect to the ftpd. # # Results: # Returns 0 if it rejects the login and 1 if it accepts the login. # # Side Effects: # None. proc ::ftpd::anonAuth {user pass} { # Standard authentication handler # # Accept user 'anonymous' if a password was # provided which is at least similar to an # fully qualified email address. if {(![string equal $user anonymous]) && (![string equal $user ftp])} { return 0 } set pass [split $pass @] if {[llength $pass] != 2} { return 0 } set domain [split [lindex $pass 1] .] if {[llength $domain] < 2} { return 0 } return 1 } # ::ftpd::unixAuth -- # # Given a username and password, decides whether or not to accept the # attempted login. This is an alternative to the default ftpd # authUsrCmd callback. By default it accepts the annonymous user and does # some basic checking checking on the form of the password to see if it # has the form of an email address. # # Arguments: # user - The name of the user that is attempting to # connect to the ftpd. # pass - The password of the user that is attempting to # connect to the ftpd. # # Results: # Returns 0 if it rejects the login and 1 if it accepts the login. # # Side Effects: # None. proc ::ftpd::unixAuth {user pass} { variable passwords array set password $passwords # Standard authentication handler # # Accept user 'anonymous' if a password was # provided which is at least similar to an # fully qualified email address. if {([llength $passwords] == 0) && (![catch {package require crypt}])} { foreach file [list /etc/passwd /etc/shadow] { if {([file exists $file]) && ([file readable $file])} { set fh [open $file r] set data [read $fh [file size $file]] foreach line [split $data \n] { foreach {username passwd uid gid dir sh} [split $line :] { if {[string length $passwd] > 2} { set password($username) $passwd } elseif {$passwd == ""} { set password($username) "" } break } } } } set passwords [array get password] } ::ftpd::Log debug $passwords if {[string equal $user anonymous] || [string equal $user ftp]} { set pass [split $pass @] if {[llength $pass] != 2} { return 0 } set domain [split [lindex $pass 1] .] if {[llength $domain] < 2} { return 0 } return 1 } if {[info exists password($user)]} { if {$password($user) == ""} { return 1 } if {[string equal $password($user) [::crypt $pass $password($user)]]} { return 1 } } return 0 } # ::ftpd::server -- # # Creates a server socket at the specified port. # # Arguments: # myaddr - The domain-style name or numerical IP address of # the client-side network interface to use for the # connection. The name of the user that is # attempting to connect to the ftpd. # # Results: # None. # # Side Effects: # A listener is setup on the specified port which will call # ::ftpd::accept when it is connected to. proc ::ftpd::server {{myaddr {}}} { variable port variable serviceSock if {[string length $myaddr]} { set serviceSock [socket -server ::ftpd::accept -myaddr $myaddr $port] } else { set serviceSock [socket -server ::ftpd::accept $port] } set port [lindex [fconfigure $serviceSock -sockname] 2] return } # ::ftpd::accept -- # # Checks if the connecting IP is authorized to connect or not. If not # the socket is closed and failure is logged. Otherwise, a welcome is # printed out, and a ftpd::Read filevent is placed on the socket. # # Arguments: # sock - The channel for this connection to the ftpd. # ipaddr - The client's IP address. # client_port - The client's port number. # # Results: # None. # # Side Effects: # Sets up a ftpd::Read fileevent to trigger whenever the channel is # readable. Logs an error and closes the connection if the IP is # not authorized to connect. proc ::ftpd::accept {sock ipaddr client_port} { upvar #0 ::ftpd::$sock data variable welcome variable cfg variable cwd variable CurrentSocket set CurrentSocket $sock if {[info exists data]} { unset data } if {[hasCallback authIpCmd]} { # Call out to authenticate the peer. A return value of 0 or an # error causes the system to reject the connection. Everything # else (with 1 prefered) leads to acceptance. set cmd $cfg(authIpCmd) lappend cmd $ipaddr set fail [catch {eval $cmd} res] if {$fail} { Log error "AuthIp error: $res" } if {$fail || ($res == 0)} { Log note "AuthIp: Access denied to $ipaddr" # Now: Close the connection. (Is there a standard response # before closing down to signal the peer that we don't want # to talk to it ? -> read RFC). close $sock return } # Accept the connection (for now, 'authUsrCmd' may revoke this # decision). } array set data [list \ access 0 \ ip $ipaddr \ state command \ buffering line \ cwd "$cwd" \ mode binary \ sock2a "" \ sock2 ""] fconfigure $sock -buffering line fileevent $sock readable [list ::ftpd::Read $sock] puts $sock "220 $welcome" Log debug "Accept $ipaddr" return } # ::ftpd::Read -- # # Checks the state of a channel and then reads a command from the # channel if it is not at end of file yet. If there is a command named # ftpd::command::* where '*' is the all upper case name of the command, # then that proc is called to handle the command with the remaining parts # of the command that was read from the channel as arguments. # # Arguments: # sock - The channel for this connection to the ftpd. # # Results: # None. # # Side Effects: # Runs the appropriate command depending on the state in the state # machine, and the command that is specified. proc ::ftpd::Read {sock} { upvar #0 ::ftpd::$sock data variable CurrentSocket set CurrentSocket $sock if {[eof $sock]} { Finish $sock return } switch -exact -- $data(state) { command { gets $sock command set argument "" if {![regexp {^([^ ]+) (.*)$} $command -> cmd argument]} { if {![regexp {^([^ ]+)$} $command -> cmd]} { # Very bad command syntax. puts $sock "500 Command not understood." return } } set cmd [string toupper $cmd] auto_load ::ftpd::command::$cmd if {($data(access) == 0) && ((![info exists data(user)]) || \ ($data(user) == "")) && (![string equal $cmd "USER"])} { if {[string equal $cmd "PASS"]} { puts $sock "503 Login with USER first." } else { puts $sock "530 Please login with USER and PASS." } } elseif {($data(access) == 0) && (![string equal $cmd "PASS"]) \ && (![string equal $cmd "USER"]) \ && (![string equal $cmd "QUIT"])} { puts $sock "530 Please login with USER and PASS." } elseif {[info commands ::ftpd::command::$cmd] != ""} { Log debug $command ::ftpd::command::$cmd $sock $argument catch {flush $sock} } else { Log error "Unknown command: $cmd" puts $sock "500 Unknown command $cmd" } } default { error "Unknown state \"$data(state)\"" } } return } # ::ftpd::Finish -- # # Closes the socket connection between the ftpd and client. # # Arguments: # sock - The channel for this connection to the ftpd. # # Results: # None. # # Side Effects: # The channel is closed. proc ::ftpd::Finish {sock} { upvar #0 ::ftpd::$sock data variable cfg if {[hasCallback closeCmd]} then { ## ## User specified a close command so invoke it ## uplevel #0 $cfg(closeCmd) } close $sock if {[info exists data]} { unset data } return } # ::ftpd::FinishData -- # # Closes the data socket connection that is created when the 'PORT' # command is recieved. # # Arguments: # sock - The channel for this connection to the ftpd. # # Results: # None. # # Side Effects: # The data channel is closed. proc ::ftpd::FinishData {sock} { upvar #0 ::ftpd::$sock data catch {close $data(sock2)} set data(sock2) {} return } # ::ftpd::Fs -- # # The general filesystem command. Used as an intermediary for filesystem # access to allow alternate (virtual, etc.) filesystems to be used. The # ::ftpd::Fs command will call out to the fsCmd callback with the # subcommand and arguments that are passed to it. # # The fsCmd callback is called in the following ways: # # append # delete # dlist