Custom PHP FTP server: 客户端在发送 LIST 命令后断开连接

Custom PHP FTP server: The client gets disconnected after sending LIST command

我打算从头开始编写一个 FTP 服务器,主要是为了了解 client/socket FTP 通信的工作原理,并尝试开发一些自定义功能。

我怀疑服务器如何处理从客户端收到的 PASV 命令,因为当我尝试实例化一个新端口时,客户端正在断开连接。

这是我正在处理的完整 PHP 代码:

<?
//-- Server runs on port :2121 and (at the moment) accept any user with any password
$server = new Ftpd(2121);
class ftpd {
    private $clients = array();                 //Array of connected clients
    private $server = "";                       //Server connection handler
    private $listen_address = "";               //Listen Address
    private $listen_port = 0;                   //Listen Port
    private $min_pasv_port = 15000;             //Port range for PASSIVE connection
    private $max_pasv_port = 16000;
    private $eol = "\n";                        //EndOfLine
/* Show log on stdout */
    private function log($msg) {
        $output = date("d-M-Y H:i:s") . " - " . $msg;
        echo $output . "\n";
    }
/* Display socket error and abort */
    function socket_error($command = "") {
        $this->errorcode    = socket_last_error($this->server);
        $this->errormessage = socket_strerror($this->errorcode);
        $this->log("[ ERROR ] on command " . $command . "() : " . $this->errorcode . " - " . $this->errormessage);
        die();
    }
/* Get list of connections currently alive */
    private function socketlist() {
        $socketlist = array(
            'server' => $this->server
        );
        reset($this->clients);
        while (list($k,$c) = each($this->clients)) {
            $socketlist[$k] = $c['conn'];
        }
        return($socketlist);
    }
/* Add new client */
    private function add_client($conn) {
        $clientID = uniqid("client_");
        socket_getpeername($conn, $ip, $port);
        $this->clients[$clientID] = array(
            'conn'      => $conn,
            'ip'        => $ip,
            'hostname'  => gethostbyaddr($ip),
            'port'      => $port,
            'id'        => $clientID,
            'user'      => '',
            'password'  => ''
        );
        return($this->clients[$clientID]);
    }
/* Get connected client list */
    private function get_client($clientID) {
        reset($this->clients);
        while (list($id,$c) = each($this->clients)) {
            if ($c['conn'] == $clientID)    return($c);
        }
        return(false);
    }
/* Remove a connection with a client */
    private function remove_client($clientID) {
        reset($this->clients);
        while (list($k,$c) = each($this->clients)) {
            if ($c['conn'] == $clientID)    unset($this->clients[$k]);
        }
        return(true);
    }
/* Constructor */
    function ftpd($listen_port = 21) {
        $listen_address = gethostbyname($_SERVER['HOSTNAME']);
        /* Open socket */
        if (! ($server = @socket_create(AF_INET, SOCK_STREAM, 0)))          $this->socket_error('socket_create');
        else                                                                $this->log("[ DONE ] socket_create");
        /* reuse listening socket address */
        if (! @socket_setopt($server, SOL_SOCKET, SO_REUSEADDR, 1))         $this->socket_error('socket_setopt');
        else                                                                $this->log("[ DONE ] socket_setopt");
        /* set socket to non-blocking */
        if (! @socket_set_nonblock($server))                                $this->socket_error('socket_set_nonblock');
        else                                                                $this->log("[ DONE ] socket_set_nonblock");
        /* bind socket with address and port */
        if (! @socket_bind($server, $listen_address, $listen_port))         $this->socket_error('socket_bind');
        else                                                                $this->log("[ DONE ] socket_bind on " . $listen_address . ":" . $listen_port);
        /* start listening */
        if (! @socket_listen($server))                                      $this->socket_error('socket_listen');
        else                                                                $this->log("[ DONE ] socket_listen");
        $this->server           = $server;
        $this->listen_address   = $listen_address;
        $this->listen_port      = $listen_port;
        /* Loop waiting connections */
        while (true) {
            $this->log("[ WAIT ] Accept incoming connections (" . count($this->clients) . " clients currently connected)");
            $write = NULL;
            $exeption = NULL;
            /* Build list of active sockets */
            $slist = $this->socketlist();
            if (socket_select($slist, $write, $exeption, 1, 0) > 0) {
                foreach($slist as $sock) {
                    if ($sock == $this->server) {
                        /* accept a connection on server */
                        $this->log("New connection");
                        if (! ($conn = socket_accept($this->server))) {
                            $this->socket_error('socket_accept');
                        } else {
                            $lastclient = $this->add_client($conn);
                            $this->log("Client " . $lastclient['hostname'] . " (" . $lastclient['ip'] . ":" . $lastclient['port'] . ") connected");
                            $this->write($lastclient['conn'], 220, "Welcome!");
                        }
                    } else {
                        $this->log("ANOTHER MESSAGE");
                        $this->read($sock);
                    }
                }
            }
        }
    }
/* write data to socket connection */
    function write($clientID, $id, $message) {
        $connected_client = $this->get_client($clientID);
        $this->log("[ WRITE to " . $connected_client['hostname'] . " ] Message: " . $id . " " . $message);
        if (! (socket_write($clientID, $id . " " . $message . "\r\n"))) $this->socket_error('socket_write');
    }
/* receive data from socket connection */
    function read($clientID) {
        $connected_client = $this->get_client($clientID);
        $keyclient = $connected_client['id'];
        $this->log("[ READ from " . $connected_client['hostname'] . " ] Ready");
        //$this->log("Client " . $connected_client['hostname'] . " (" . $connected_client['ip'] . ":" . $connected_client['port'] . ") ready to write");
        if (($msg = @socket_read($clientID, 1024)) === false || $msg == '') {
            if ($msg != '') $this->socket_error('socket_read');
            $this->log("[ READ from " . $connected_client['hostname'] . " ] **** Message: " . $msg);
            $this->remove_client($clientID);
            $this->log("[ DISCONNECT ] " . $clientID);
        } else {
            $msg = trim($msg);
            $this->log("[ READ from " . $connected_client['hostname'] . " ] Message: " . $msg);
            list($cmd, $cmd_option) = explode(" ", $msg, 2);
            if ($cmd == "USER") { //-- USER command received
                //-- any user are allowed to login with any password
                $this->clients[$keyclient]['user'] = $cmd_option;
                $this->Write($clientID, 331, "Password required for " . $cmd_option);
            } elseif ($cmd == "PASS") { //-- PASS command received
                //-- any user are allowed to login with any password
                $this->clients[$keyclient]['password'] = $cmd_option;
                $this->Write($clientID, 230, "Welcome!");
            } elseif ($cmd == "PWD") { //-- PWD command received
                $this->Write($clientID, 257, "/ is the current directory");
            } elseif ($cmd == "TYPE") { //-- TYPE command received
                $this->eol = ($cmd_option == "A" ? "\r\n" : "\n");
                $this->Write($clientID, 200, "TYPE set to " . $cmd_option);
            } elseif ($cmd == "SYST") { //-- SYST command received
                $this->Write($clientID, 215, "UNIX Type: L8");
            } elseif ($cmd == "AUTH") { //-- AUTH command to be implemented 
                $this->Write($clientID, 500, $msg . " handled but not understood");
            } elseif ($cmd == "PASV") { //-- PASV command to be implemented 
                while (true) {              /* loop until a free port can be used */
                    $port = rand($this->min_pasv_port, $this->max_pasv_port);
                    if (! ($conn = @socket_create(AF_INET, SOCK_STREAM, 0)))    $this->socket_error('PASV.socket_create');
                    else                                                        $this->log("[ DONE ] PASV.socket_create");
                    /* reuse listening socket address */
                    if (! @socket_setopt($conn, SOL_SOCKET, SO_REUSEADDR, 1))   $this->socket_error('PASV.socket_setopt');
                    else                                                        $this->log("[ DONE ] PASV.socket_setopt");
                    /* set socket to non-blocking */
                    if (! @socket_set_nonblock($conn))                          $this->socket_error('PASV.socket_set_nonblock');
                    else                                                        $this->log("[ DONE ] PASV.socket_set_nonblock");
                    /* bind socket with address and port */
                    if (! @socket_bind($conn, $this->listen_address, $port))    $this->socket_error('PASV.socket_bind');
                    else                                                        $this->log("[ DONE ] PASV.socket_bind on " . $this->listen_address . ":" . $port);
                    /* start listening */
                    if (! @socket_listen($conn))                                $this->socket_error('PASV.socket_listen');
                    else                                                        $this->log("[ DONE ] PASV.socket_listen");
                    $this->clients[$keyclient]['conn'] = $conn;
                    $this->clients[$keyclient]['port'] = $port;
                    $p1 = $port >>  8;
                    $p2 = $port & 0xff;
                    $tmp = str_replace(".", ",", $this->listen_address);
                    $this->Write($clientID, 227, "Entering Passive Mode (" . $tmp . "," . $p1 . "," . $p2 . ").");
                    print_r($this->clients);
                    break;
                }
            } elseif ($cmd == "LIST") { //-- LIST command to be developped
                exec("ls /ews/tmp", $output);
                $this->Write($clientID, "", implode("\n", $output));
                $this->Write($clientID, 226, "Transfer complete");
            } else {
                $this->Write($clientID, 500, $msg . " unhandled");
            }
        }
    }
}
?>

这是守护进程启动时的服务器日志

[/ews/tmp]# ./ftp.server
20-May-2016 11:45:51 - [ DONE ] socket_create
20-May-2016 11:45:51 - [ DONE ] socket_setopt
20-May-2016 11:45:51 - [ DONE ] socket_set_nonblock
20-May-2016 11:45:51 - [ DONE ] socket_bind on 164.130.21.98:2121
20-May-2016 11:45:51 - [ DONE ] socket_listen
20-May-2016 11:45:51 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:45:52 - [ WAIT ] Accept incoming connections (0 clients currently connected)
//--message repeated till when client connects
20-May-2016 11:46:06 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:46:06 - New connection
20-May-2016 11:46:06 - Client ewsserver (164.130.21.98:45071) connected
20-May-2016 11:46:06 - [ WRITE to ewsserver ] Message: 220 Welcome!
20-May-2016 11:46:06 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:07 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:07 - ANOTHER MESSAGE
20-May-2016 11:46:07 - [ READ from ewsserver ] Ready
20-May-2016 11:46:07 - [ READ from ewsserver ] Message: USER dummy
20-May-2016 11:46:07 - [ WRITE to ewsserver ] Message: 331 Password required for dummy
20-May-2016 11:46:07 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:08 - ANOTHER MESSAGE
20-May-2016 11:46:08 - [ READ from ewsserver ] Ready
20-May-2016 11:46:08 - [ READ from ewsserver ] Message: PASS dummy
20-May-2016 11:46:08 - [ WRITE to ewsserver ] Message: 230 Welcome!
20-May-2016 11:46:08 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:08 - ANOTHER MESSAGE
20-May-2016 11:46:08 - [ READ from ewsserver ] Ready
20-May-2016 11:46:08 - [ READ from ewsserver ] Message: SYST
20-May-2016 11:46:08 - [ WRITE to ewsserver ] Message: 215 UNIX Type: L8
20-May-2016 11:46:08 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:09 - [ WAIT ] Accept incoming connections (1 clients currently connected)
//-- client type the "dir" command and PASV command is received
20-May-2016 11:46:13 - ANOTHER MESSAGE
20-May-2016 11:46:13 - [ READ from ewsserver ] Ready
20-May-2016 11:46:13 - [ READ from ewsserver ] Message: PASV
20-May-2016 11:46:13 - [ DONE ] PASV.socket_create
20-May-2016 11:46:13 - [ DONE ] PASV.socket_setopt
20-May-2016 11:46:13 - [ DONE ] PASV.socket_set_nonblock
20-May-2016 11:46:13 - [ DONE ] PASV.socket_bind on 164.130.21.98:15469
20-May-2016 11:46:13 - [ DONE ] PASV.socket_listen
20-May-2016 11:46:13 - [ WRITE to  ] Message: 227 Entering Passive Mode (164,130,21,98,60,109).
Array
(
    [client_573edcde66f87] => Array
        (
            [conn] => Resource id #7
            [ip] => 164.130.21.98
            [hostname] => ewsserver
            [port] => 15469
            [id] => client_573edcde66f87
            [user] => vega
            [password] => vega
        )

)
20-May-2016 11:46:13 - [ WAIT ] Accept incoming connections (1 clients currently connected)
20-May-2016 11:46:13 - ANOTHER MESSAGE
20-May-2016 11:46:13 - [ READ from ewsserver ] Ready
20-May-2016 11:46:13 - [ READ from ewsserver ] **** Message:
//-- Server disconnect
20-May-2016 11:46:13 - [ DISCONNECT ] Resource id #7
20-May-2016 11:46:13 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:46:14 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:46:15 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:46:16 - [ WAIT ] Accept incoming connections (0 clients currently connected)
20-May-2016 11:46:17 - [ WAIT ] Accept incoming connections (0 clients currently connected)

而这是客户端命令提示符:

Status: Resolving address of host.name.st.com
Status: Connecting to xxx.xxx.21.98:2121...
Status: Connection established, waiting for welcome message...
Response:   220 Welcome!
Command:    AUTH TLS
Response:   500 AUTH TLS handled but not understood
Command:    AUTH SSL
Response:   500 AUTH SSL handled but not understood
Status: Insecure server, it does not support FTP over TLS.
Command:    USER dummy
Response:   331 Password required for dummy
Command:    PASS *****
Response:   230 Welcome!
Command:    SYST
Response:   215 UNIX Type: L8
Command:    FEAT
Response:   500 FEAT unhandled
Status: Server does not support non-ASCII characters.
Status: Logged in
Status: Retrieving directory listing...
Command:    PWD
Response:   257 / is the current directory
Command:    TYPE I
Response:   200 TYPE set to I
Command:    PASV
Response:   227 Entering Passive Mode (xxx,xxx,21,98,60,172).
Command:    LIST
Error:  Disconnected from server: ECONNABORTED - Connection aborted
Error:  Failed to retrieve directory listing
Status: Disconnected from server
Status: Resolving address of host.name.st.com
Status: Connecting to xxx.xxx.21.98:2121...
Status: Connection established, waiting for welcome message...
Response:   220 Welcome!
Command:    AUTH TLS
Response:   500 AUTH TLS handled but not understood
Command:    AUTH SSL
Response:   500 AUTH SSL handled but not understood
Status: Insecure server, it does not support FTP over TLS.
Command:    USER dummy
Response:   331 Password required for dummy
Command:    PASS *****
Response:   230 Welcome!
Status: Server does not support non-ASCII characters.
Status: Logged in
Status: Retrieving directory listing...
Command:    PWD
Response:   257 / is the current directory
Command:    TYPE I
Response:   200 TYPE set to I
Command:    PASV
Response:   227 Entering Passive Mode (xxx,xxx,21,98,60,251).
Command:    LIST

我在代码中看到了这些问题:

  • 眼前的问题是您尝试从已接受的数据连接中读取数据。但是 "downloading" 目录列表是客户端。所以在你最终超时读取(因为客户端没有发送任何东西)之后,你中止了连接。
  • 您没有用 150 Opening data channel for directory 类响应确认接受数据连接。
  • 您将列表写入控制连接,而不是数据连接。
  • 您使用 LF 终止列表中的行,而 FTP 规范要求使用 CRLF。参见