diff --git a/README.md b/README.md
index a994af46..674a65de 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,14 @@
-# ftp-srv [](https://badge.fury.io/js/ftp-srv) [](https://travis-ci.org/stewarttylerr/ftp-srv) [](https://github.com/semantic-release/semantic-release) [](http://commitizen.github.io/cz-cli/)
+
+
+[](https://badge.fury.io/js/ftp-srv) [](https://travis-ci.org/trs/ftp-srv) [](https://github.com/semantic-release/semantic-release) [](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_mailbox ftp-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') {