diff --git a/README.md b/README.md index a994af46..674a65de 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ -# ftp-srv [![npm version](https://badge.fury.io/js/ftp-srv.svg)](https://badge.fury.io/js/ftp-srv) [![Build Status](https://travis-ci.org/stewarttylerr/ftp-srv.svg?branch=master)](https://travis-ci.org/stewarttylerr/ftp-srv) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) +![ftp-srv](logo.png) + +[![npm version](https://badge.fury.io/js/ftp-srv.svg)](https://badge.fury.io/js/ftp-srv) [![Build Status](https://travis-ci.org/trs/ftp-srv.svg?branch=master)](https://travis-ci.org/trs/ftp-srv) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) > Modern, extensible FTP Server +--- + - [Overview](#overview) - [Features](#features) - [Install](#install) @@ -22,7 +26,7 @@ ## Features - Extensible [file systems](#file-system) per connection - Passive and active transfers -- Implicit TLS connections +- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections ## Install `npm install ftp-srv --save` @@ -45,45 +49,48 @@ ftpServer.listen() ## API ### `new FtpSrv(url, [{options}])` -#### `url` +#### url [URL string](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) indicating the protocol, hostname, and port to listen on for connections. Supported protocols: - `ftp` Plain FTP -- `ftps` Implicit FTP over TLS +- `ftps` Implicit FTP over TLS + _Note:_ The hostname must be the external IP address to accept external connections. Setting the hostname to `0.0.0.0` will automatically set the external IP. __Default:__ `"ftp://127.0.0.1:21"` -#### `options` +#### options -- ##### `pasv_range` +##### `pasv_range` A starting port (eg `8000`) or a range (eg `"8000-9000"`) to accept passive connections. This range is then queried for an available port to use when required. __Default:__ `22` -- ##### `greeting` +##### `greeting` A human readable array of lines or string to send when a client connects. __Default:__ `null` -- ##### `tls` -Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit `ftps`. -__Default:__ `{}` +##### `tls` +Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections. +__Default:__ `false` -- ##### `anonymous` -If true, will call the event login after `USER`, not requiring a password from the user. +##### `anonymous` +If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user. +Can also set as a string which allows users to authenticate using the username provided. +The `login` event is then sent with the provided username and `@anonymous` as the password. __Default:__ `false` -- ##### `blacklist` +##### `blacklist` Array of commands that are not allowed. Response code `502` is sent to clients sending one of these commands. __Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files. __Default:__ `[]` -- ##### `whitelist` +##### `whitelist` Array of commands that are only allowed. Response code `502` is sent to clients sending any other command. __Default:__ `[]` -- ##### `file_format` +##### `file_format` Sets the format to use for file stat queries such as `LIST`. __Default:__ `"ls"` __Allowable values:__ @@ -92,7 +99,7 @@ __Allowable values:__ - `function () {}` A custom function returning a format or promise for one. - Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter -- ##### `log` +##### `log` A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default. ## Events @@ -104,7 +111,11 @@ The `FtpSvr` class extends the [node net.Server](https://nodejs.org/api/net.html on('login', {connection, username, password}, resolve, reject) => { ... } ``` -Occurs when a client is attempting to login. Here you can resolve the login request by username and password. +Occurs when a client is attempting to login. Here you can resolve the login request by username and password. + +`connection` [client class object](src/connection.js) +`username` string of username from `USER` command +`password` string of password from `PASS` command `resolve` takes an object of arguments: - `fs` - Set a custom file system class for this connection to use. @@ -127,11 +138,15 @@ Occurs when a client is attempting to login. Here you can resolve the login requ on('client-error', {connection, context, error}) => { ... } ``` -Occurs when an error occurs in the client connection. +Occurs when an error arises in the client connection. + +`connection` [client class object](src/connection.js) +`context` string of where the error occured +`error` error object ## Supported Commands -See [commands](src/commands) for a list of all implemented FTP commands. +See the [command registry](src/commands/registration) for a list of all implemented FTP commands. ## File System The default file system can be overwritten to use your own implementation. @@ -150,8 +165,7 @@ Returns a file stat object of file or directory __Used in:__ `STAT`, `SIZE`, `RNFR`, `MDTM` #### [`list(path)`](src/fs.js#L39) -Returns array of file and directory stat objects - +Returns array of file and directory stat objects __Used in:__ `LIST`, `STAT` #### [`chdir(path)`](src/fs.js#L56) diff --git a/logo.html b/logo.html new file mode 100644 index 00000000..9926dc5c --- /dev/null +++ b/logo.html @@ -0,0 +1,43 @@ + + + + + + + + + +

markunread_mailboxftp-srv

+ + diff --git a/logo.png b/logo.png new file mode 100644 index 00000000..7c412dcb Binary files /dev/null and b/logo.png differ diff --git a/package.json b/package.json index 89362d79..2857e074 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "main": "src/index.js", "repository": { "type": "git", - "url": "https://github.com/stewarttylerr/ftp-srv" + "url": "https://github.com/trs/ftp-srv" }, "scripts": { "pre-release": "npm-run-all verify test:coverage build ", diff --git a/src/commands/registration/appe.js b/src/commands/registration/appe.js index afeb3297..b3bf65f3 100644 --- a/src/commands/registration/appe.js +++ b/src/commands/registration/appe.js @@ -5,6 +5,6 @@ module.exports = { handler: function (args) { return stor.call(this, args); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Append to a file' }; diff --git a/src/commands/registration/auth.js b/src/commands/registration/auth.js index 17d5c3f2..4ae6b683 100644 --- a/src/commands/registration/auth.js +++ b/src/commands/registration/auth.js @@ -1,4 +1,5 @@ const _ = require('lodash'); +const tls = require('tls'); module.exports = { directive: 'AUTH', @@ -7,21 +8,34 @@ module.exports = { switch (method) { case 'TLS': return handleTLS.call(this); - case 'SSL': return handleSSL.call(this); default: return this.reply(504); } }, - syntax: '{{cmd}} [type]', + syntax: '{{cmd}} ', description: 'Set authentication mechanism', flags: { - no_auth: true + no_auth: true, + feat: 'AUTH TLS' } }; function handleTLS() { - return this.reply(504); -} + if (!this.server._tls) return this.reply(504); -function handleSSL() { - return this.reply(504); + return this.reply(234) + .then(() => { + const secureContext = tls.createSecureContext(this.server._tls); + const secureSocket = new tls.TLSSocket(this.commandSocket, { + isServer: true, + secureContext + }); + ['data', 'timeout', 'end', 'close', 'drain', 'error'].forEach(event => { + function forwardEvent() { + this.emit.apply(this, arguments); + } + secureSocket.on(event, forwardEvent.bind(this.commandSocket, event)); + }); + this.commandSocket = secureSocket; + this.secure = true; + }); } diff --git a/src/commands/registration/cwd.js b/src/commands/registration/cwd.js index 4496fcdb..3beb7a6c 100644 --- a/src/commands/registration/cwd.js +++ b/src/commands/registration/cwd.js @@ -17,6 +17,6 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}}[path]', + syntax: '{{cmd}} ', description: 'Change working directory' }; diff --git a/src/commands/registration/dele.js b/src/commands/registration/dele.js index 9ea3c76f..e32ed96a 100644 --- a/src/commands/registration/dele.js +++ b/src/commands/registration/dele.js @@ -15,6 +15,6 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Delete file' }; diff --git a/src/commands/registration/eprt.js b/src/commands/registration/eprt.js new file mode 100644 index 00000000..d36d821a --- /dev/null +++ b/src/commands/registration/eprt.js @@ -0,0 +1,22 @@ +const _ = require('lodash'); +const ActiveConnector = require('../../connector/active'); + +const FAMILY = { + 1: 4, + 2: 6 +}; + +module.exports = { + directive: 'EPRT', + handler: function ({command} = {}) { + this.connector = new ActiveConnector(this); + const [protocol, ip, port] = _.compact(command.arg.split('|')); + const family = FAMILY[protocol]; + if (!family) return this.reply(502, 'Unknown network protocol'); + + return this.connector.setupConnection(ip, port, family) + .then(() => this.reply(200)); + }, + syntax: '{{cmd}} ||
||', + description: 'Specifies an address and port to which the server should connect' +}; diff --git a/src/commands/registration/epsv.js b/src/commands/registration/epsv.js new file mode 100644 index 00000000..3b0c6593 --- /dev/null +++ b/src/commands/registration/epsv.js @@ -0,0 +1,16 @@ +const PassiveConnector = require('../../connector/passive'); + +module.exports = { + directive: 'EPSV', + handler: function () { + this.connector = new PassiveConnector(this); + return this.connector.setupServer() + .then(server => { + const {port} = server.address(); + + return this.reply(229, `EPSV OK (|||${port}|)`); + }); + }, + syntax: '{{cmd}} []', + description: 'Initiate passive mode' +}; diff --git a/src/commands/registration/feat.js b/src/commands/registration/feat.js index 56ea0f02..8f3c24d3 100644 --- a/src/commands/registration/feat.js +++ b/src/commands/registration/feat.js @@ -11,7 +11,9 @@ module.exports = { return feats; }, []) .map(feat => ` ${feat}`); - return this.reply(211, 'Extensions supported', ...features, 'END'); + return features.length + ? this.reply(211, 'Extensions supported', ...features, 'End') + : this.reply(211, 'No features'); }, syntax: '{{cmd}}', description: 'Get the feature list implemented by the server', diff --git a/src/commands/registration/help.js b/src/commands/registration/help.js index 2a550565..64f8bf11 100644 --- a/src/commands/registration/help.js +++ b/src/commands/registration/help.js @@ -16,7 +16,7 @@ module.exports = { return this.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.'); } }, - syntax: '{{cmd}} [command(optional)]', + syntax: '{{cmd}} []', description: 'Returns usage documentation on a command if specified, else a general help document is returned', flags: { no_auth: true diff --git a/src/commands/registration/list.js b/src/commands/registration/list.js index 1e7b9e51..f590f1d9 100644 --- a/src/commands/registration/list.js +++ b/src/commands/registration/list.js @@ -55,6 +55,6 @@ module.exports = { this.commandSocket.resume(); }); }, - syntax: '{{cmd}} [path(optional)]', + syntax: '{{cmd}} []', description: 'Returns information of a file or directory if specified, else information of the current working directory is returned' }; diff --git a/src/commands/registration/mdtm.js b/src/commands/registration/mdtm.js index f95fcf87..0d0eaeeb 100644 --- a/src/commands/registration/mdtm.js +++ b/src/commands/registration/mdtm.js @@ -17,7 +17,7 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Return the last-modified time of a specified file', flags: { feat: 'MDTM' diff --git a/src/commands/registration/mkd.js b/src/commands/registration/mkd.js index 3719bbbb..3fe35e5c 100644 --- a/src/commands/registration/mkd.js +++ b/src/commands/registration/mkd.js @@ -17,6 +17,6 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}}[path]', + syntax: '{{cmd}} ', description: 'Make directory' }; diff --git a/src/commands/registration/mode.js b/src/commands/registration/mode.js index 478bdb77..56f4a2a4 100644 --- a/src/commands/registration/mode.js +++ b/src/commands/registration/mode.js @@ -3,7 +3,7 @@ module.exports = { handler: function ({command} = {}) { return this.reply(/^S$/i.test(command.arg) ? 200 : 504); }, - syntax: '{{cmd}} [mode]', + syntax: '{{cmd}} ', description: 'Sets the transfer mode (Stream, Block, or Compressed)', flags: { obsolete: true diff --git a/src/commands/registration/nlst.js b/src/commands/registration/nlst.js index 65e1f898..77fbe5ec 100644 --- a/src/commands/registration/nlst.js +++ b/src/commands/registration/nlst.js @@ -5,6 +5,6 @@ module.exports = { handler: function (args) { return list.call(this, args); }, - syntax: '{{cmd}} [path(optional)]', + syntax: '{{cmd}} []', description: 'Returns a list of file names in a specified directory' }; diff --git a/src/commands/registration/pass.js b/src/commands/registration/pass.js index a1db1b40..afed9a56 100644 --- a/src/commands/registration/pass.js +++ b/src/commands/registration/pass.js @@ -1,15 +1,13 @@ -const _ = require('lodash'); - module.exports = { directive: 'PASS', handler: function ({log, command} = {}) { if (!this.username) return this.reply(503); - if (this.username && this.authenticated && - _.get(this, 'server.options.anonymous') === true) return this.reply(230); + if (this.authenticated) return this.reply(202); // 332 : require account name (ACCT) const password = command.arg; + if (!password) return this.reply(501, 'Must provide password'); return this.login(this.username, password) .then(() => { return this.reply(230); @@ -19,7 +17,7 @@ module.exports = { return this.reply(530, err.message || 'Authentication failed'); }); }, - syntax: '{{cmd}} [password]', + syntax: '{{cmd}} ', description: 'Authentication password', flags: { no_auth: true diff --git a/src/commands/registration/pbsz.js b/src/commands/registration/pbsz.js new file mode 100644 index 00000000..308bca7e --- /dev/null +++ b/src/commands/registration/pbsz.js @@ -0,0 +1,13 @@ +module.exports = { + directive: 'PBSZ', + handler: function ({command} = {}) { + if (!this.server._tls || !this.secure) return this.reply(202, 'Not suppored'); + this.bufferSize = parseInt(command.arg, 10); + return this.reply(200, this.bufferSize === 0 ? 'OK' : 'Buffer too large: PBSZ=0'); + }, + syntax: '{{cmd}}', + description: 'Protection Buffer Size', + flags: { + no_auth: true + } +}; diff --git a/src/commands/registration/port.js b/src/commands/registration/port.js index 453b844b..d53dd377 100644 --- a/src/commands/registration/port.js +++ b/src/commands/registration/port.js @@ -15,6 +15,6 @@ module.exports = { return this.connector.setupConnection(ip, port) .then(() => this.reply(200)); }, - syntax: '{{cmd}} x,x,x,x,y,y', + syntax: '{{cmd}} ,,,,,', description: 'Specifies an address and port to which the server should connect' }; diff --git a/src/commands/registration/prot.js b/src/commands/registration/prot.js new file mode 100644 index 00000000..98f24a35 --- /dev/null +++ b/src/commands/registration/prot.js @@ -0,0 +1,22 @@ +const _ = require('lodash'); + +module.exports = { + directive: 'PROT', + handler: function ({command} = {}) { + if (!this.server._tls || !this.secure) return this.reply(202, 'Not suppored'); + if (!this.bufferSize && typeof this.bufferSize !== 'number') return this.reply(503); + + switch (_.toUpper(command.arg)) { + case 'P': return this.reply(200, 'OK'); + case 'C': + case 'S': + case 'E': return this.reply(536, 'Not supported'); + default: return this.reply(504); + } + }, + syntax: '{{cmd}}', + description: 'Data Channel Protection Level', + flags: { + no_auth: true + } +}; diff --git a/src/commands/registration/retr.js b/src/commands/registration/retr.js index c994e7ff..f8e101d9 100644 --- a/src/commands/registration/retr.js +++ b/src/commands/registration/retr.js @@ -36,6 +36,6 @@ module.exports = { this.commandSocket.resume(); }); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Retrieve a copy of the file' }; diff --git a/src/commands/registration/rmd.js b/src/commands/registration/rmd.js index 65f5f9a1..343c9754 100644 --- a/src/commands/registration/rmd.js +++ b/src/commands/registration/rmd.js @@ -5,6 +5,6 @@ module.exports = { handler: function (args) { return dele.call(this, args); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Remove a directory' }; diff --git a/src/commands/registration/rnfr.js b/src/commands/registration/rnfr.js index d9649253..d0f2aae2 100644 --- a/src/commands/registration/rnfr.js +++ b/src/commands/registration/rnfr.js @@ -17,6 +17,6 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}} [name]', + syntax: '{{cmd}} ', description: 'Rename from' }; diff --git a/src/commands/registration/rnto.js b/src/commands/registration/rnto.js index 3f3305f2..2f57313e 100644 --- a/src/commands/registration/rnto.js +++ b/src/commands/registration/rnto.js @@ -23,6 +23,6 @@ module.exports = { delete this.renameFrom; }); }, - syntax: '{{cmd}} [name]', + syntax: '{{cmd}} ', description: 'Rename to' }; diff --git a/src/commands/registration/site/index.js b/src/commands/registration/site/index.js index ef7be539..efed6647 100644 --- a/src/commands/registration/site/index.js +++ b/src/commands/registration/site/index.js @@ -12,6 +12,6 @@ module.exports = { const handler = registry[subCommand.directive].handler.bind(this); return when.try(handler, { log: subLog, command: subCommand }); }, - syntax: '{{cmd}} [subVerb] [subParams]', + syntax: '{{cmd}} [...]', description: 'Sends site specific commands to remote server' }; diff --git a/src/commands/registration/size.js b/src/commands/registration/size.js index a81dcc4a..04bcd5a6 100644 --- a/src/commands/registration/size.js +++ b/src/commands/registration/size.js @@ -15,7 +15,7 @@ module.exports = { return this.reply(550, err.message); }); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Return the size of a file', flags: { feat: 'SIZE' diff --git a/src/commands/registration/stat.js b/src/commands/registration/stat.js index c6ede7d0..dc08cc42 100644 --- a/src/commands/registration/stat.js +++ b/src/commands/registration/stat.js @@ -39,6 +39,6 @@ module.exports = { return this.reply(211, 'Status OK'); } }, - syntax: '{{cmd}} [path(optional)]', + syntax: '{{cmd}} []', description: 'Returns the current status' }; diff --git a/src/commands/registration/stor.js b/src/commands/registration/stor.js index ed8b2e42..6737f3c3 100644 --- a/src/commands/registration/stor.js +++ b/src/commands/registration/stor.js @@ -39,6 +39,6 @@ module.exports = { this.commandSocket.resume(); }); }, - syntax: '{{cmd}} [path]', + syntax: '{{cmd}} ', description: 'Store data as a file at the server site' }; diff --git a/src/commands/registration/stru.js b/src/commands/registration/stru.js index 1d5ad394..d845c05d 100644 --- a/src/commands/registration/stru.js +++ b/src/commands/registration/stru.js @@ -3,7 +3,7 @@ module.exports = { handler: function ({command} = {}) { return this.reply(/^F$/i.test(command.arg) ? 200 : 504); }, - syntax: '{{cmd}} [structure]', + syntax: '{{cmd}} ', description: 'Set file transfer structure', flags: { obsolete: true diff --git a/src/commands/registration/type.js b/src/commands/registration/type.js index 9cce7a5f..e6f442f7 100644 --- a/src/commands/registration/type.js +++ b/src/commands/registration/type.js @@ -1,7 +1,7 @@ const _ = require('lodash'); const ENCODING_TYPES = { - A: 'utf-8', + A: 'utf8', I: 'binary', L: 'binary' }; @@ -15,6 +15,6 @@ module.exports = { this.encoding = ENCODING_TYPES[encoding]; return this.reply(200); }, - syntax: '{{cmd}} [mode]', - description: 'Set the transfer mode, binary (I) or utf-8 (A)' + syntax: '{{cmd}} ', + description: 'Set the transfer mode, binary (I) or utf8 (A)' }; diff --git a/src/commands/registration/user.js b/src/commands/registration/user.js index 1070f349..4bd7f188 100644 --- a/src/commands/registration/user.js +++ b/src/commands/registration/user.js @@ -2,12 +2,13 @@ module.exports = { directive: 'USER', handler: function ({log, command} = {}) { if (this.username) return this.reply(530, 'Username already set'); + if (this.authenticated) return this.reply(230); this.username = command.arg; - if (!this.username) return this.reply(501, 'Must send username requirement'); + if (!this.username) return this.reply(501, 'Must provide username'); - - if (this.server.options.anonymous === true) { + if (this.server.options.anonymous === true && this.username === 'anonymous' || + this.username === this.server.options.anonymous) { return this.login(this.username, '@anonymous') .then(() => { return this.reply(230); @@ -19,7 +20,7 @@ module.exports = { } return this.reply(331); }, - syntax: '{{cmd}} [username]', + syntax: '{{cmd}} ', description: 'Authentication username', flags: { no_auth: true diff --git a/src/commands/registry.js b/src/commands/registry.js index d03d97a3..b9cfcb26 100644 --- a/src/commands/registry.js +++ b/src/commands/registry.js @@ -33,7 +33,9 @@ const commands = [ require('./registration/stru'), require('./registration/syst'), require('./registration/type'), - require('./registration/user') + require('./registration/user'), + require('./registration/pbsz'), + require('./registration/prot') ]; const registry = commands.reduce((result, cmd) => { diff --git a/src/connection.js b/src/connection.js index ccf919c7..3a994d12 100644 --- a/src/connection.js +++ b/src/connection.js @@ -15,7 +15,8 @@ class FtpConnection { this.id = uuid.v4(); this.log = options.log.child({id: this.id, ip: this.ip}); this.commands = new Commands(this); - this.encoding = 'utf-8'; + this.encoding = 'utf8'; + this.bufferSize = false; this.connector = new BaseConnector(this); @@ -33,8 +34,8 @@ class FtpConnection { } _handleData(data) { - const messages = _.compact(data.toString('utf-8').split('\r\n')); - this.log.trace(messages, 'Messages'); + const messages = _.compact(data.toString('utf8').split('\r\n')); + this.log.trace(messages); return sequence(messages.map(message => this.commands.handle.bind(this.commands, message))); } diff --git a/src/connector/active.js b/src/connector/active.js index 7cec74ff..4e31fcb5 100644 --- a/src/connector/active.js +++ b/src/connector/active.js @@ -1,4 +1,5 @@ const {Socket} = require('net'); +const tls = require('tls'); const when = require('when'); const Connector = require('./base'); @@ -17,7 +18,7 @@ class Active extends Connector { .then(() => this.dataSocket); } - setupConnection(host, port) { + setupConnection(host, port, family = 4) { const closeExistingServer = () => this.dataSocket ? when(this.dataSocket.destroy()) : when.resolve(); @@ -26,11 +27,20 @@ class Active extends Connector { .then(() => { this.dataSocket = new Socket(); this.dataSocket.setEncoding(this.encoding); - this.dataSocket.connect({ host, port }, () => { + this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err})); + this.dataSocket.connect({ host, port, family }, () => { this.dataSocket.pause(); + + if (this.connection.secure) { + const secureContext = tls.createSecureContext(this.server._tls); + const secureSocket = new tls.TLSSocket(this.dataSocket, { + isServer: true, + secureContext + }); + this.dataSocket = secureSocket; + } this.dataSocket.connected = true; }); - this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataSocket', error: err})); }); } } diff --git a/src/connector/passive.js b/src/connector/passive.js index 022b6856..36677e36 100644 --- a/src/connector/passive.js +++ b/src/connector/passive.js @@ -1,4 +1,5 @@ const net = require('net'); +const tls = require('tls'); const when = require('when'); const Connector = require('./base'); @@ -12,9 +13,7 @@ class Passive extends Connector { } waitForConnection({timeout = 5000, delay = 250} = {}) { - if (!this.dataServer) { - return when.reject(new errors.ConnectorError('Passive server not setup')); - } + if (!this.dataServer) return when.reject(new errors.ConnectorError('Passive server not setup')); return when.iterate( () => {}, () => this.dataServer && this.dataServer.listening && this.dataSocket && this.dataSocket.connected, @@ -31,8 +30,7 @@ class Passive extends Connector { return closeExistingServer() .then(() => this.getPort()) .then(port => { - this.dataSocket = null; - this.dataServer = net.createServer({ pauseOnConnect: true }, socket => { + const connectionHandler = socket => { if (this.connection.commandSocket.remoteAddress !== socket.remoteAddress) { this.log.error({ pasv_connection: socket.remoteAddress, @@ -45,17 +43,29 @@ class Passive extends Connector { } this.log.debug({port}, 'Passive connection fulfilled.'); - this.dataSocket = socket; + if (this.connection.secure) { + const secureContext = tls.createSecureContext(this.server._tls); + const secureSocket = new tls.TLSSocket(socket, { + isServer: true, + secureContext + }); + this.dataSocket = secureSocket; + } else { + this.dataSocket = socket; + } this.dataSocket.connected = true; this.dataSocket.setEncoding(this.connection.encoding); - this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataSocket', error: err})); + this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err})); this.dataSocket.on('close', () => { this.log.debug('Passive connection closed'); this.end(); }); - }); + }; + + this.dataSocket = null; + this.dataServer = net.createServer({ pauseOnConnect: true }, connectionHandler); this.dataServer.maxConnections = 1; - this.dataServer.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataServer', error: err})); + this.dataServer.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataServer', error: err})); this.dataServer.on('close', () => { this.log.debug('Passive server closed'); this.dataServer = null; diff --git a/src/helpers/resolve-host.js b/src/helpers/resolve-host.js index d3ccf9c3..21f41901 100644 --- a/src/helpers/resolve-host.js +++ b/src/helpers/resolve-host.js @@ -12,7 +12,7 @@ module.exports = function (hostname) { if (response.statusCode !== 200) { return reject(new errors.GeneralError('Unable to resolve hostname', response.statusCode)); } - response.setEncoding('utf-8'); + response.setEncoding('utf8'); response.on('data', chunk => { ip += chunk; }); diff --git a/src/index.js b/src/index.js index fb977c6c..cecbb6d3 100644 --- a/src/index.js +++ b/src/index.js @@ -19,12 +19,15 @@ class FtpServer { blacklist: [], whitelist: [], greeting: null, - tls: {} + tls: false }, options); this._greeting = this.setupGreeting(this.options.greeting); this._features = this.setupFeaturesMessage(); this._tls = this.setupTLS(this.options.tls); + delete this.options.greeting; + delete this.options.tls; + this.connections = {}; this.log = this.options.log; this.url = nodeUrl.parse(url || 'ftp://127.0.0.1:21'); @@ -58,7 +61,7 @@ class FtpServer { } get isTLS() { - return this.url.protocol === 'ftps:'; + return this.url.protocol === 'ftps:' && this._tls; } listen() { @@ -68,7 +71,11 @@ class FtpServer { return when.promise((resolve, reject) => { this.server.listen(this.url.port, err => { if (err) return reject(err); - this.log.info({ip: this.url.hostname, port: this.url.port}, `Listening${this.isTLS ? ' (TLS)' : ''}`); + this.log.info({ + protocol: this.url.protocol.replace(/\W/g, ''), + ip: this.url.hostname, + port: this.url.port + }, 'Listening'); resolve(); }); }); @@ -87,6 +94,7 @@ class FtpServer { } setupTLS(_tls) { + if (!tls) return false; return _.assign(_tls, { cert: _tls.cert ? fs.readFileSync(_tls.cert) : undefined, key: _tls.key ? fs.readFileSync(_tls.key) : undefined, diff --git a/test/commands/registration/auth.spec.js b/test/commands/registration/auth.spec.js index c2ff6847..798cdfcf 100644 --- a/test/commands/registration/auth.spec.js +++ b/test/commands/registration/auth.spec.js @@ -6,7 +6,10 @@ const CMD = 'AUTH'; describe(CMD, function () { let sandbox; const mockClient = { - reply: () => when.resolve() + reply: () => when.resolve(), + server: { + _tls: {} + } }; const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient); @@ -19,10 +22,11 @@ describe(CMD, function () { sandbox.restore(); }); - it('TLS // not supported', done => { + it('TLS // supported', done => { cmdFn({command: { arg: 'TLS', directive: CMD}}) .then(() => { - expect(mockClient.reply.args[0][0]).to.equal(504); + expect(mockClient.reply.args[0][0]).to.equal(234); + expect(mockClient.secure).to.equal(true); done(); }) .catch(done); diff --git a/test/commands/registration/pass.spec.js b/test/commands/registration/pass.spec.js index 09e45ba8..af97ed1c 100644 --- a/test/commands/registration/pass.spec.js +++ b/test/commands/registration/pass.spec.js @@ -10,7 +10,7 @@ describe(CMD, function () { reply: () => {}, login: () => {}, server: { options: { anonymous: false } }, - username: 'user' + username: 'anonymous' }; const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient); @@ -28,18 +28,18 @@ describe(CMD, function () { cmdFn({log, command: {arg: 'pass', directive: CMD}}) .then(() => { expect(mockClient.reply.args[0][0]).to.equal(230); - expect(mockClient.login.args[0]).to.eql(['user', 'pass']); + expect(mockClient.login.args[0]).to.eql(['anonymous', 'pass']); done(); }) .catch(done); }); - it('// successful (anonymous)', done => { + it('// successful (already authenticated)', done => { mockClient.server.options.anonymous = true; mockClient.authenticated = true; cmdFn({log, command: {directive: CMD}}) .then(() => { - expect(mockClient.reply.args[0][0]).to.equal(230); + expect(mockClient.reply.args[0][0]).to.equal(202); expect(mockClient.login.callCount).to.equal(0); mockClient.server.options.anonymous = false; mockClient.authenticated = false; diff --git a/test/commands/registration/pbsz.spec.js b/test/commands/registration/pbsz.spec.js new file mode 100644 index 00000000..72b896ed --- /dev/null +++ b/test/commands/registration/pbsz.spec.js @@ -0,0 +1,56 @@ +const when = require('when'); +const {expect} = require('chai'); +const sinon = require('sinon'); + +const CMD = 'PBSZ'; +describe(CMD, function () { + let sandbox; + const mockClient = { + reply: () => when.resolve(), + server: {}, + secure: true + }; + const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient); + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + sandbox.spy(mockClient, 'reply'); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('// unsuccessful', done => { + cmdFn() + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(202); + done(); + }) + .catch(done); + }); + + it('// successful', done => { + mockClient.server._tls = {}; + + cmdFn({command: {arg: '0'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(200); + expect(mockClient.bufferSize).to.equal(0); + done(); + }) + .catch(done); + }); + + it('// successful', done => { + mockClient.server._tls = {}; + + cmdFn({command: {arg: '10'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(200); + expect(mockClient.bufferSize).to.equal(10); + done(); + }) + .catch(done); + }); +}); diff --git a/test/commands/registration/prot.spec.js b/test/commands/registration/prot.spec.js new file mode 100644 index 00000000..4d59878f --- /dev/null +++ b/test/commands/registration/prot.spec.js @@ -0,0 +1,72 @@ +const when = require('when'); +const {expect} = require('chai'); +const sinon = require('sinon'); + +const CMD = 'PROT'; +describe(CMD, function () { + let sandbox; + const mockClient = { + reply: () => when.resolve(), + server: {}, + secure: true + }; + const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient); + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + sandbox.spy(mockClient, 'reply'); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('// unsuccessful', done => { + cmdFn() + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(202); + done(); + }) + .catch(done); + }); + + it('// unsuccessful - no bufferSize', done => { + mockClient.server._tls = {}; + + cmdFn({command: {arg: 'P'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(503); + done(); + }) + .catch(done); + }); + + it('// successful', done => { + mockClient.bufferSize = 0; + + cmdFn({command: {arg: 'p'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(200); + done(); + }) + .catch(done); + }); + + it('// unsuccessful - unsupported', done => { + cmdFn({command: {arg: 'C'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(536); + done(); + }) + .catch(done); + }); + + it('// unsuccessful - unknown', done => { + cmdFn({command: {arg: 'QQ'}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(504); + done(); + }) + .catch(done); + }); +}); diff --git a/test/commands/registration/type.spec.js b/test/commands/registration/type.spec.js index 92caa29e..6f60bb30 100644 --- a/test/commands/registration/type.spec.js +++ b/test/commands/registration/type.spec.js @@ -24,7 +24,7 @@ describe(CMD, function () { cmdFn({ command: { arg: 'A' } }) .then(() => { expect(mockClient.reply.args[0][0]).to.equal(200); - expect(mockClient.encoding).to.equal('utf-8'); + expect(mockClient.encoding).to.equal('utf8'); done(); }) .catch(done); diff --git a/test/commands/registration/user.spec.js b/test/commands/registration/user.spec.js index 8163e5f0..0662acfa 100644 --- a/test/commands/registration/user.spec.js +++ b/test/commands/registration/user.spec.js @@ -40,7 +40,7 @@ describe(CMD, function () { it('test // successful | anonymous login', done => { mockClient.server.options = {anonymous: true}; - cmdFn({ command: { arg: 'test' } }) + cmdFn({ command: { arg: 'anonymous' } }) .then(() => { expect(mockClient.reply.args[0][0]).to.equal(230); expect(mockClient.login.callCount).to.equal(1); @@ -71,15 +71,24 @@ describe(CMD, function () { .catch(done); }); - it('test // unsuccessful | login function rejects', done => { + it('test // successful | regular login if anonymous is true', done => { mockClient.server.options = {anonymous: true}; - mockClient.login.restore(); - sandbox.stub(mockClient, 'login').rejects(new Error('test')); - cmdFn({ log: mockLog, command: { arg: 'test' } }) .then(() => { - expect(mockClient.reply.args[0][0]).to.equal(530); + expect(mockClient.reply.args[0][0]).to.equal(331); + expect(mockClient.login.callCount).to.equal(0); + done(); + }) + .catch(done); + }); + + it('test // successful | anonymous login with set username', done => { + mockClient.server.options = {anonymous: 'sillyrabbit'}; + + cmdFn({ log: mockLog, command: { arg: 'sillyrabbit' } }) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(230); expect(mockClient.login.callCount).to.equal(1); done(); }) diff --git a/test/index.spec.js b/test/index.spec.js index 1d703437..111c4b01 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -17,7 +17,12 @@ describe('FtpServer', function () { before(done => { server = new FtpServer(process.env.FTP_URL, { log, - pasv_range: process.env.PASV_RANGE + pasv_range: process.env.PASV_RANGE, + tls: { + key: `${process.cwd()}/test/cert/server.key`, + cert: `${process.cwd()}/test/cert/server.crt`, + ca: `${process.cwd()}/test/cert/server.csr` + } }); server.on('login', (data, resolve) => { resolve({root: process.cwd()}); @@ -59,41 +64,41 @@ describe('FtpServer', function () { }); }); - it('CWD ..', done => { - const dir = '..'; - client.cwd(`${dir}`, (err, data) => { - expect(err).to.not.exist; - expect(data).to.be.a('string'); - done(); + const runFileSystemTests = () => { + it('CWD ..', done => { + const dir = '..'; + client.cwd(`${dir}`, (err, data) => { + expect(err).to.not.exist; + expect(data).to.be.a('string'); + done(); + }); }); - }); - it('CWD test', done => { - const dir = 'test'; - client.cwd(`${dir}`, (err, data) => { - expect(err).to.not.exist; - expect(data).to.be.a('string'); - done(); + it('CWD test', done => { + const dir = 'test'; + client.cwd(`${dir}`, (err, data) => { + expect(err).to.not.exist; + expect(data).to.be.a('string'); + done(); + }); }); - }); - it('PWD', done => { - client.pwd((err, data) => { - expect(err).to.not.exist; - expect(data).to.be.a('string'); - done(); + it('PWD', done => { + client.pwd((err, data) => { + expect(err).to.not.exist; + expect(data).to.be.a('string'); + done(); + }); }); - }); - it('LIST .', done => { - client.list('.', (err, data) => { - expect(err).to.not.exist; - expect(data).to.be.an('array'); - done(); + it('LIST .', done => { + client.list('.', (err, data) => { + expect(err).to.not.exist; + expect(data).to.be.an('array'); + done(); + }); }); - }); - const runFileSystemTests = () => { it('STOR test.txt', done => { const buffer = Buffer.from('test text file'); client.put(buffer, 'test.txt', err => { @@ -180,6 +185,47 @@ describe('FtpServer', function () { done(); }); }); + + it('MKD tmp', done => { + if (fs.existsSync('./test/tmp')) { + fs.rmdirSync('./test/tmp'); + } + client.mkdir('tmp', err => { + expect(err).to.not.exist; + expect(fs.existsSync('./test/tmp')).to.equal(true); + done(); + }); + }); + + it('CWD tmp', done => { + client.cwd('tmp', (err, data) => { + expect(err).to.not.exist; + expect(data).to.be.a('string'); + done(); + }); + }); + + it('CDUP', done => { + client.cdup(err => { + expect(err).to.not.exist; + done(); + }); + }); + + it('RMD tmp', done => { + client.rmdir('tmp', err => { + expect(err).to.not.exist; + expect(fs.existsSync('./test/tmp')).to.equal(false); + done(); + }); + }); + + it('CDUP', done => { + client.cdup(err => { + expect(err).to.not.exist; + done(); + }); + }); }; it('TYPE A', done => { @@ -198,39 +244,26 @@ describe('FtpServer', function () { }); runFileSystemTests(); - it('MKD tmp', done => { - if (fs.existsSync('./test/tmp')) { - fs.rmdirSync('./test/tmp'); - } - client.mkdir('tmp', err => { - expect(err).to.not.exist; - expect(fs.existsSync('./test/tmp')).to.equal(true); - done(); - }); - }); - - it('CWD tmp', done => { - client.cwd('tmp', (err, data) => { - expect(err).to.not.exist; - expect(data).to.be.a('string'); - done(); - }); - }); - - it('CDUP', done => { - client.cdup(err => { - expect(err).to.not.exist; - done(); - }); - }); - - it('RMD tmp', done => { - client.rmdir('tmp', err => { - expect(err).to.not.exist; - expect(fs.existsSync('./test/tmp')).to.equal(false); - done(); + it('AUTH TLS', done => { + client.end(); + client.once('close', () => { + client = new FtpClient(); + client.once('ready', () => done()); + client.once('error', err => done(err)); + client.connect({ + host: server.url.hostname, + port: server.url.port, + user: 'test', + password: 'test', + secure: true, + secureOptions: { + rejectUnauthorized: false, + checkServerIdentity: () => undefined + } + }); }); }); + runFileSystemTests(); it('QUIT', done => { client.once('close', done); diff --git a/test/start.js b/test/start.js index 60c79e27..1653cd46 100644 --- a/test/start.js +++ b/test/start.js @@ -4,7 +4,7 @@ const bunyan = require('bunyan'); const FtpServer = require('../src'); const log = bunyan.createLogger({name: 'test'}); -log.level('info'); +log.level('trace'); const server = new FtpServer('ftp://127.0.0.1:8880', { log, pasv_range: 8881, @@ -14,7 +14,8 @@ const server = new FtpServer('ftp://127.0.0.1:8880', { cert: `${process.cwd()}/test/cert/server.crt`, ca: `${process.cwd()}/test/cert/server.csr` }, - file_format: 'ep' + file_format: 'ep', + anonymous: 'sillyrabbit' }); server.on('login', ({username, password}, resolve, reject) => { if (username === 'test' && password === 'test' || username === 'anonymous') {