Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3d543f1a | ||
|
|
69bec2b01c | ||
|
|
2eac41d127 | ||
|
|
eb32f93fc6 | ||
|
|
095423606e | ||
|
|
61cf1bda39 | ||
|
|
75f847ed5d | ||
|
|
ad4b32fc13 | ||
|
|
be3c57bed0 | ||
|
|
dc7dd1075c | ||
|
|
543e6cc1cc | ||
|
|
5c1f8f7a65 | ||
|
|
557995a1a9 | ||
|
|
45eca5afe0 | ||
|
|
695e594d97 | ||
|
|
97b55fc92c | ||
|
|
577066850b | ||
|
|
0ec989cf1e |
3
.env
Executable file
3
.env
Executable file
@@ -0,0 +1,3 @@
|
||||
FTP_URL=ftp://127.0.0.1:8880
|
||||
PASV_RANGE=8881
|
||||
LOG_LEVEL=fatal
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,5 +2,4 @@ node_modules/
|
||||
|
||||
dist/
|
||||
reports/
|
||||
.env
|
||||
npm-debug.log
|
||||
|
||||
@@ -2,6 +2,10 @@ language: node_js
|
||||
node_js:
|
||||
- "6"
|
||||
|
||||
env:
|
||||
FTP_URL: ftp://127.0.0.1:8880
|
||||
PASV_RANGE: 8881
|
||||
|
||||
install: npm install
|
||||
|
||||
script:
|
||||
|
||||
@@ -89,10 +89,13 @@ ftpServer.listen()
|
||||
- __password__
|
||||
- Password provided in the `PASS` command
|
||||
- Only provided if `anonymous` is set to `false`
|
||||
- __resolve ({fs, cwd, blacklist, whitelist})__
|
||||
- __resolve ({fs, root, cwd, blacklist, whitelist})__
|
||||
- __fs__ _[optional]_
|
||||
- Optional file system class for connection to use
|
||||
- See [File System](#file-system) for implementation details
|
||||
- __root__ _[optional]_
|
||||
- If `fs` not provided, will set the root directory for the connection
|
||||
- The user cannot traverse lower than this directory
|
||||
- __cwd__ _[optional]_
|
||||
- If `fs` not provided, will set the starting directory for the connection
|
||||
- __blacklist__ _[optional]_
|
||||
@@ -129,7 +132,7 @@ Returns new directory relative to cwd
|
||||
> Used in `CWD`, `CDUP`
|
||||
|
||||
`mkdir(path)`
|
||||
Return a path to a newly created directory
|
||||
Returns a path to a newly created directory
|
||||
|
||||
> Used in `MKD`
|
||||
|
||||
@@ -161,7 +164,7 @@ Modify a file or directory's permissions
|
||||
> Used in `SITE CHMOD`
|
||||
|
||||
`getUniqueName()`
|
||||
Return a unique file name to write to
|
||||
Returns a unique file name to write to
|
||||
|
||||
> Used in `STOU`
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bunyan": "^1.8.9",
|
||||
"date-fns": "^1.28.2",
|
||||
"lodash": "^4.17.4",
|
||||
"minimist-string": "^1.0.2",
|
||||
"moment": "^2.18.1",
|
||||
"uuid": "^3.0.1",
|
||||
"when": "^3.7.8"
|
||||
},
|
||||
|
||||
@@ -12,8 +12,13 @@ class FtpCommands {
|
||||
}
|
||||
|
||||
handle(command) {
|
||||
const log = this.connection.log.child({command});
|
||||
log.trace('Handle command');
|
||||
// Obfuscate password from logs
|
||||
const logCommand = _.cloneDeep(command);
|
||||
command.directive = _.upperCase(command._[0]);
|
||||
if (command.directive === 'PASS') logCommand._[1] = '********';
|
||||
|
||||
const log = this.connection.log.child({directive: command.directive});
|
||||
log.trace({command: logCommand}, 'Handle command');
|
||||
|
||||
if (!REGISTRY.hasOwnProperty(command.directive)) {
|
||||
return this.connection.reply(402, 'Command not allowed');
|
||||
@@ -40,7 +45,7 @@ class FtpCommands {
|
||||
const handler = commandRegister.handler.bind(this.connection);
|
||||
return when.try(handler, { log, command, previous_command: this.previousCommand })
|
||||
.finally(() => {
|
||||
this.previousCommand = _.clone(command);
|
||||
this.previousCommand = _.cloneDeep(command);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const when = require('when');
|
||||
const format = require('date-fns/format');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = {
|
||||
directive: 'MDTM',
|
||||
@@ -9,7 +9,7 @@ module.exports = {
|
||||
|
||||
return when.try(this.fs.get.bind(this.fs), command._[1])
|
||||
.then(fileStat => {
|
||||
const modificationTime = format(fileStat.mtime, 'YYYYMMDDHHmmss.SSS');
|
||||
const modificationTime = moment.utc(fileStat.mtime).format('YYYYMMDDHHmmss.SSS');
|
||||
return this.reply(213, modificationTime);
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const commands = [
|
||||
require('./registration/pasv'),
|
||||
require('./registration/port'),
|
||||
require('./registration/pwd'),
|
||||
require('./registration/quit'),
|
||||
require('./registration/retr'),
|
||||
require('./registration/rmd'),
|
||||
require('./registration/rnfr'),
|
||||
|
||||
@@ -15,7 +15,7 @@ class FtpConnection {
|
||||
this.server = server;
|
||||
this.commandSocket = options.socket;
|
||||
this.id = uuid.v4();
|
||||
this.log = options.log.child({id: this.id});
|
||||
this.log = options.log.child({id: this.id, ip: this.ip});
|
||||
this.commands = new Commands(this);
|
||||
this.encoding = 'utf-8';
|
||||
|
||||
@@ -28,7 +28,6 @@ class FtpConnection {
|
||||
const messages = _.compact(data.toString('utf-8').split('\r\n'));
|
||||
const handleMessage = message => {
|
||||
const command = parseCommandString(message);
|
||||
command.directive = _.upperCase(command._[0]);
|
||||
return this.commands.handle(command);
|
||||
};
|
||||
|
||||
@@ -41,13 +40,19 @@ class FtpConnection {
|
||||
});
|
||||
}
|
||||
|
||||
get ip() {
|
||||
try {
|
||||
return this.commandSocket.remoteAddress;
|
||||
} catch (ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
close(code = 421, message = 'Closing connection') {
|
||||
return when(() => {
|
||||
if (code) return this.reply(code, message);
|
||||
})
|
||||
.then(() => {
|
||||
if (this.commandSocket) this.commandSocket.end();
|
||||
});
|
||||
return when
|
||||
.resolve(code)
|
||||
.then(_code => _code && this.reply(_code, message))
|
||||
.then(() => this.commandSocket && this.commandSocket.end());
|
||||
}
|
||||
|
||||
login(username, password) {
|
||||
|
||||
19
src/fs.js
19
src/fs.js
@@ -13,17 +13,24 @@ class FileSystem {
|
||||
cwd = '/'
|
||||
} = {}) {
|
||||
this.connection = connection;
|
||||
this.cwd = cwd;
|
||||
this.root = root;
|
||||
this.cwd = this._normalize(cwd);
|
||||
this.root = this._normalize(root);
|
||||
}
|
||||
|
||||
_normalize(path) {
|
||||
return nodePath.normalize(path
|
||||
.replace(/\\/g, '\/') // replaces \ with /
|
||||
.replace(/\\\\/g, '\/') // replaces \\ with /
|
||||
.replace(/\/\//g, '\/') // replaces // with /
|
||||
);
|
||||
}
|
||||
|
||||
_resolvePath(path) {
|
||||
const pathParts = {
|
||||
root: this.root,
|
||||
base: nodePath.resolve(this.cwd, path)
|
||||
base: nodePath.join(this.cwd, this._normalize(path))
|
||||
};
|
||||
path = nodePath.format(pathParts);
|
||||
return path;
|
||||
return nodePath.format(pathParts);
|
||||
}
|
||||
|
||||
currentDirectory() {
|
||||
@@ -60,7 +67,7 @@ class FileSystem {
|
||||
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
|
||||
})
|
||||
.then(() => {
|
||||
this.cwd = path.replace(new RegExp(`^${this.root}`), '') || '/';
|
||||
this.cwd = path.substring(this.root.length) || '/';
|
||||
return this.currentDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const _ = require('lodash');
|
||||
const dateFns = require('date-fns');
|
||||
const moment = require('moment');
|
||||
const errors = require('../errors');
|
||||
|
||||
module.exports = function (fileStat, format = 'ls') {
|
||||
@@ -16,6 +16,10 @@ module.exports = function (fileStat, format = 'ls') {
|
||||
};
|
||||
|
||||
function ls(fileStat) {
|
||||
const now = moment.utc();
|
||||
const mtime = moment.utc(new Date(fileStat.mtime));
|
||||
const dateFormat = now.diff(mtime, 'months') < 6 ? 'MMM DD HH:mm' : 'MMM DD YYYY';
|
||||
|
||||
return [
|
||||
fileStat.mode !== null
|
||||
? [
|
||||
@@ -35,7 +39,7 @@ function ls(fileStat) {
|
||||
fileStat.uid,
|
||||
fileStat.gid,
|
||||
_.padStart(fileStat.size, 12),
|
||||
_.padStart(dateFns.format(fileStat.mtime, 'MMM DD HH:mm'), 12),
|
||||
_.padStart(mtime.format(dateFormat), 12),
|
||||
fileStat.name
|
||||
].join(' ');
|
||||
}
|
||||
@@ -44,7 +48,7 @@ function ep(fileStat) {
|
||||
const facts = [
|
||||
fileStat.dev && fileStat.ino ? `i${fileStat.dev.toString(16)}.${fileStat.ino.toString(16)}` : null,
|
||||
fileStat.size ? `s${fileStat.size}` : null,
|
||||
fileStat.mtime ? `m${dateFns.format(dateFns.parse(fileStat.mtime), 'X')}` : null,
|
||||
fileStat.mtime ? `m${moment.utc(new Date(fileStat.mtime)).format('X')}` : null,
|
||||
fileStat.mode ? `up${fileStat.mode.toString(8).substr(fileStat.mode.toString(8).length - 3)}` : null,
|
||||
fileStat.isDirectory() ? 'r' : '/'
|
||||
].join(',');
|
||||
|
||||
@@ -2,15 +2,15 @@ const net = require('net');
|
||||
const when = require('when');
|
||||
const errors = require('../errors');
|
||||
|
||||
module.exports = function (min = 22, max = undefined) {
|
||||
module.exports = function (min = 1, max = undefined) {
|
||||
return when.promise((resolve, reject) => {
|
||||
let port = min;
|
||||
let checkPort = min;
|
||||
let portCheckServer = net.createServer();
|
||||
portCheckServer.maxConnections = 0;
|
||||
portCheckServer.on('error', () => {
|
||||
if (!max || port < max) {
|
||||
port = port + 1;
|
||||
portCheckServer.listen(port);
|
||||
if (checkPort < 65535 && (!max || checkPort < max)) {
|
||||
checkPort = checkPort + 1;
|
||||
portCheckServer.listen(checkPort);
|
||||
} else {
|
||||
reject(new errors.GeneralError('Unable to find open port', 500));
|
||||
}
|
||||
@@ -22,6 +22,6 @@ module.exports = function (min = 22, max = undefined) {
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
portCheckServer.listen(port);
|
||||
portCheckServer.listen(checkPort);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ class FtpServer {
|
||||
});
|
||||
this.on = this.server.on.bind(this.server);
|
||||
this.listeners = this.server.listeners.bind(this.server);
|
||||
|
||||
process.on('SIGINT', () => this.close());
|
||||
}
|
||||
|
||||
listen() {
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('Connector - Passive //', function () {
|
||||
sandbox = sinon.sandbox.create();
|
||||
|
||||
sandbox.spy(mockConnection, 'reply');
|
||||
sandbox.spy(mockConnection, 'close');
|
||||
|
||||
mockConnection.commandSocket.remoteAddress = '::ffff:127.0.0.1';
|
||||
mockConnection.server.options.pasv_range = '8000';
|
||||
@@ -45,7 +46,7 @@ describe('Connector - Passive //', function () {
|
||||
});
|
||||
|
||||
it('has invalid pasv range', function (done) {
|
||||
delete mockConnection.server.options.pasv_range;
|
||||
mockConnection.server.options.pasv_range = -1;
|
||||
|
||||
passive.setupServer()
|
||||
.then(() => done('should not happen'))
|
||||
@@ -84,10 +85,13 @@ describe('Connector - Passive //', function () {
|
||||
expect(passive.dataServer).to.exist;
|
||||
|
||||
const {port} = passive.dataServer.address();
|
||||
net.createConnection(port, () => {
|
||||
expect(mockConnection.reply.callCount).to.equal(1);
|
||||
expect(mockConnection.reply.args[0][0]).to.equal(550);
|
||||
done();
|
||||
net.createConnection(port);
|
||||
passive.dataServer.once('connection', () => {
|
||||
setTimeout(() => {
|
||||
expect(passive.connection.reply.callCount).to.equal(1);
|
||||
expect(passive.connection.reply.args[0][0]).to.equal(550);
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
})
|
||||
.catch(done);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const {expect} = require('chai');
|
||||
const dateFns = require('date-fns');
|
||||
|
||||
const fileStat = require('../../src/helpers/file-stat');
|
||||
const errors = require('../../src/errors');
|
||||
@@ -17,24 +16,48 @@ describe('helpers // file-stat', function () {
|
||||
size: 527,
|
||||
blksize: 4096,
|
||||
blocks: 8,
|
||||
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
|
||||
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
|
||||
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
|
||||
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
|
||||
atime: 'Mon, 10 Oct 2017 23:24:11 GMT',
|
||||
mtime: 'Mon, 10 Oct 2017 23:24:11 GMT',
|
||||
ctime: 'Mon, 10 Oct 2017 23:24:11 GMT',
|
||||
birthtime: 'Mon, 10 Oct 2017 23:24:11 GMT',
|
||||
isDirectory: () => false
|
||||
};
|
||||
|
||||
describe.skip('format - ls //', function () {
|
||||
const STAT_OLD = {
|
||||
name: 'test2',
|
||||
dev: 2114,
|
||||
ino: 48064969,
|
||||
mode: 33188,
|
||||
nlink: 1,
|
||||
uid: 84,
|
||||
gid: 101,
|
||||
rdev: 0,
|
||||
size: 530,
|
||||
blksize: 4096,
|
||||
blocks: 8,
|
||||
atime: 'Mon, 10 Oct 2011 14:05:12 GMT',
|
||||
mtime: 'Mon, 10 Oct 2011 14:05:12 GMT',
|
||||
ctime: 'Mon, 10 Oct 2011 14:05:12 GMT',
|
||||
birthtime: 'Mon, 10 Oct 2011 14:05:12 GMT',
|
||||
isDirectory: () => false
|
||||
};
|
||||
|
||||
describe('format - ls //', function () {
|
||||
it('formats correctly', () => {
|
||||
const format = fileStat(STAT, 'ls');
|
||||
expect(format).to.equal('-rwxrw-r-- 1 85 100 527 Oct 10 17:24 test1');
|
||||
expect(format).to.equal('-rwxrw-r-- 1 85 100 527 Oct 10 23:24 test1');
|
||||
});
|
||||
|
||||
it('formats correctly for files over 6 months old', () => {
|
||||
const format = fileStat(STAT_OLD, 'ls');
|
||||
expect(format).to.equal('-rwxrw-r-- 1 84 101 530 Oct 10 2011 test2');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('format - ep //', function () {
|
||||
describe('format - ep //', function () {
|
||||
it('formats correctly', () => {
|
||||
const format = fileStat(STAT, 'ep');
|
||||
expect(format).to.equal('+i842.2dd69c9,s527,m1318289051,up644,/ test1');
|
||||
expect(format).to.equal('+i842.2dd69c9,s527,m1507677851,up644,/ test1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint no-unused-expressions: 0 */
|
||||
require('dotenv').load();
|
||||
const {expect} = require('chai');
|
||||
const bunyan = require('bunyan');
|
||||
const fs = require('fs');
|
||||
@@ -7,9 +6,12 @@ const fs = require('fs');
|
||||
const FtpServer = require('../src');
|
||||
const FtpClient = require('ftp');
|
||||
|
||||
before(() => require('dotenv').load());
|
||||
|
||||
describe('FtpServer', function () {
|
||||
this.timeout(2000);
|
||||
let log = bunyan.createLogger({name: 'test', level: 10});
|
||||
let log = bunyan.createLogger({name: 'test'});
|
||||
log.level(process.env.LOG_LEVEL || 'debug');
|
||||
let server;
|
||||
let client;
|
||||
|
||||
@@ -21,14 +23,9 @@ describe('FtpServer', function () {
|
||||
server.on('login', (data, resolve) => {
|
||||
resolve({root: process.cwd()});
|
||||
});
|
||||
process.on('SIGINT', function () {
|
||||
server.close();
|
||||
});
|
||||
|
||||
require('child_process').exec(`sudo kill $(sudo lsof -t -i:${server.url.port})`, () => {
|
||||
server.listen()
|
||||
.finally(() => done());
|
||||
});
|
||||
server.listen()
|
||||
.then(() => done());
|
||||
});
|
||||
after(() => {
|
||||
server.close();
|
||||
@@ -38,6 +35,7 @@ describe('FtpServer', function () {
|
||||
expect(server).to.exist;
|
||||
client = new FtpClient();
|
||||
client.once('ready', () => done());
|
||||
client.once('error', err => done(err));
|
||||
client.connect({
|
||||
host: server.url.hostname,
|
||||
port: server.url.port,
|
||||
@@ -235,4 +233,10 @@ describe('FtpServer', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('QUIT', done => {
|
||||
client.once('close', done);
|
||||
client.logout(err => {
|
||||
expect(err).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
require('dotenv').load();
|
||||
const bunyan = require('bunyan');
|
||||
|
||||
const FtpServer = require('../src');
|
||||
|
||||
const log = bunyan.createLogger({name: 'test'});
|
||||
log.level(process.env.LOG_LEVEL || 'trace');
|
||||
const server = new FtpServer(process.env.FTP_URL, {
|
||||
log,
|
||||
pasv_range: process.env.PASV_RANGE
|
||||
});
|
||||
server.on('login', ({username, password}, resolve, reject) => {
|
||||
if (username === 'test' && password === 'test') resolve({ root: require('os').homedir() });
|
||||
else reject('Bad username or password');
|
||||
});
|
||||
server.listen();
|
||||
Reference in New Issue
Block a user