Compare commits

..

20 Commits

Author SHA1 Message Date
Tyler Stewart
30956e1d64 WIP: observables 2020-05-25 20:20:05 -06:00
Tyler Stewart
c59e191a39 fix: correct OPTS error code (#190)
If an unknown option is given, the response should be 501
2020-01-07 09:39:22 -07:00
Tyler Stewart
b2b1b2a0d3 fix(commands): 502 error on unsupported command (#185)
* fix(commands): 502 error on unsupported command

Fixes: https://github.com/trs/ftp-srv/issues/184

* test: update test assertion
2020-01-06 15:09:34 -07:00
dependabot[bot]
81fa7fcb89 chore(deps): bump npm from 6.11.2 to 6.13.4 (#183)
Bumps [npm](https://github.com/npm/cli) from 6.11.2 to 6.13.4.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v6.11.2...v6.13.4)

Signed-off-by: dependabot[bot] <support@github.com>
2019-12-14 13:00:44 -05:00
Leo Bernard
a18841d770 feat: 'disconnect' event (#175)
Fixes #174
2019-11-08 09:45:27 -07:00
Karoly Gossler
0dbb7f9070 docs: fixes the documentation of pasv_url behaviour (#172)
fixes #171
2019-09-26 12:16:19 -06:00
Tyler Stewart
0b9167e1e4 fix: explicitly promisify fs methods
`promisifyAll` will throw if a method with the suffix `Async` already
exists.

Fixes: https://github.com/trs/ftp-srv/pull/159
2019-08-22 14:57:40 -06:00
Tyler Stewart
484409d2eb feat: allow connecting from local 2019-08-22 14:57:40 -06:00
Tyler Stewart
5ffcef3312 fix: dont format message is empty 2019-08-22 14:57:40 -06:00
Tibor Papp
290769a042 Possibility to send back empty message 2019-08-22 14:57:40 -06:00
Tibor Papp
a1c7f2ffda Return response when folder is empty.
If we do not send back a response, some FTP clients can fail, when they use encryption.
2019-08-22 14:57:40 -06:00
Tyler Stewart
7153ffab4d chore: npm audit 2019-08-22 14:57:40 -06:00
Tyler Stewart
c0e132b70e fix: ensure valid fs path resolution 2019-08-22 14:57:40 -06:00
Tyler Stewart
e661bd10e2 fix: remove socket encoding
By setting the encoding, there becomes issues with binary transfers
(such as photos).
2019-08-22 14:57:40 -06:00
Tyler Stewart
bece42a0c9 feat: close passive server when client disconnects
Since a passive server is created for an individual client, when it
disconnects from the server we can assume the server is done and should
close it.
2019-08-22 14:57:40 -06:00
Tyler Stewart
b1fe56826c feat: disconnect passive server after timeout
If no client connects within 30 seconds of requesting, close the server.
This prevents multiple servers from being created and never closing.
2019-08-22 14:57:40 -06:00
Tyler Stewart
16dbc7895c chore: audit fix 2019-06-21 14:37:56 -06:00
Tyler Stewart
94f0b893e4 fix: enable better concurrent port search
Creates a new instance of a server each time the port search is called.
This ensures that concurrent calls to this function wont hang or produce
`ERR_SERVER_ALREADY_LISTEN`
2019-06-21 14:37:56 -06:00
Tyler Stewart
79d7bd9062 chore: ensure correct test path 2019-06-21 14:37:56 -06:00
Tyler Stewart
44999c714d fix(stor): ensure rejection after destroy 2019-06-21 14:37:56 -06:00
70 changed files with 5469 additions and 14405 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,2 @@
test_tmp/
node_modules/
dist/
npm-debug.log

21
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\dist\\index.js",
"preLaunchTask": "build",
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
]
}
]
}

12
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "npm run build"
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Tyler Stewart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,48 @@
```ts
const loginMiddleware = () => (client) => {
let username;
let password;
return {
USER(client, command) => {
username = command.arg;
},
PASS(client, command) => {
password = command.arg;
}
};
}
const fileSystemMiddleware = () => (client) => {
return {
CWD(client, command) => {},
CDUP(client) => {},
}
}
const transferMiddleware = () => (client) => {
}
client.use(loginMiddleware());
```
5.1. MINIMUM IMPLEMENTATION
In order to make FTP workable without needless error messages, the
following minimum implementation is required for all servers:
TYPE - ASCII Non-print
MODE - Stream
STRUCTURE - File, Record
COMMANDS - USER, QUIT, PORT,
TYPE, MODE, STRU,
for the default values
RETR, STOR,
NOOP.
The default values for transfer parameters are:
TYPE - ASCII Non-print
MODE - Stream
STRU - File

View File

@@ -1,15 +0,0 @@
import FTPServer from '../src/server';
const server = new FTPServer();
server.registerPlugin({
command: 'PASS',
handler: async ({connection, reply}) => {
const username = connection.getContext('username');
const password = connection.getContext('password');
// AUTHENTICATE
reply.set([230]);
}
});
server.listen();

6
old/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
test_tmp/
node_modules/
dist/
npm-debug.log

View File

@@ -75,7 +75,6 @@ __Default:__ `"ftp://127.0.0.1:21"`
#### `pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`). This defaults to the provided `url` hostname.
_Note:_ If set to `0.0.0.0`, this will automatically resolve to the external IP of the box.
__Default:__ `"127.0.0.1"`
#### `pasv_min`

0
old/bin/index.js Executable file → Normal file
View File

109
old/ftp-srv.d.ts vendored
View File

@@ -4,9 +4,9 @@ import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
@@ -42,35 +42,35 @@ export class FileSystem {
}
export class FtpConnection extends EventEmitter {
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Record<string, any>, ...letters: any[]): Promise<any>
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Object, ...letters: Array<any>): Promise<any>
}
export interface FtpServerOptions {
url?: string;
pasv_min?: number;
pasv_max?: number;
pasv_url?: string;
greeting?: string | string[];
tls?: tls.SecureContextOptions | false;
anonymous?: boolean;
blacklist?: string[];
whitelist?: string[];
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep";
log?: any;
timeout?: number;
url?: string,
pasv_min?: number,
pasv_max?: number,
pasv_url?: string,
greeting?: string | string[],
tls?: tls.SecureContextOptions | false,
anonymous?: boolean,
blacklist?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log?: any,
timeout?: number
}
export class FtpServer extends EventEmitter {
@@ -85,9 +85,9 @@ export class FtpServer extends EventEmitter {
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string;
cert: string;
key: string;
ca: string
};
setupGreeting(greet: string): string[];
@@ -98,29 +98,36 @@ export class FtpServer extends EventEmitter {
close(): any;
on(event: "login", listener: (
data: {
connection: FtpConnection;
username: string;
password: string;
},
resolve: (config: {
fs?: FileSystem;
root?: string;
cwd?: string;
blacklist?: string[];
whitelist?: string[];
on(event: "login", listener: (
data: {
connection: FtpConnection,
username: string,
password: string
},
resolve: (config: {
fs?: FileSystem,
root?: string,
cwd?: string,
blacklist?: Array<string>,
whitelist?: Array<string>
}) => void,
reject: (err?: Error) => void
) => void): this;
reject: (err?: Error) => void
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection;
context: string;
error: Error;
}
) => void): this;
on(event: "disconnect", listener: (
data: {
connection: FtpConnection,
id: string
}
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection,
context: string,
error: Error,
}
) => void): this;
}
export {FtpServer as FtpSrv};

7884
old/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@
"scripts": {
"pre-release": "npm run verify",
"semantic-release": "semantic-release",
"test": "mocha **/*.spec.js --ui bdd --bail",
"test": "mocha test/**/*.spec.js test/*.spec.js --ui bdd",
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
},
"release": {
@@ -66,14 +66,14 @@
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"ip": "^1.1.5",
"lodash": "^4.17.10",
"lodash": "^4.17.15",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.0",
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"@icetee/ftp": "^1.0.2",
"chai": "^4.2.0",
"condition-circle": "^2.0.2",
@@ -82,7 +82,7 @@
"lint-staged": "^8.1.4",
"mocha": "^5.2.0",
"rimraf": "^2.6.1",
"semantic-release": "^15.10.6",
"semantic-release": "^15.13.24",
"sinon": "^2.3.5"
},
"engines": {

View File

@@ -45,7 +45,7 @@ class FtpCommands {
log.trace({command: logCommand}, 'Handle command');
if (!REGISTRY.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
return this.connection.reply(502, 'Command not allowed');
}
if (_.includes(this.blacklist, command.directive)) {

View File

@@ -36,6 +36,7 @@ module.exports = {
.tap(() => this.reply(150))
.then((fileList) => {
if (fileList.length) return this.reply({}, ...fileList);
return this.reply({socket: this.connector.socket, useEmptyMessage: true});
})
.tap(() => this.reply(226))
.catch(Promise.TimeoutError, (err) => {

View File

@@ -13,7 +13,7 @@ module.exports = {
const [_option, ...args] = command.arg.split(' ');
const option = _.toUpper(_option);
if (!OPTIONS.hasOwnProperty(option)) return this.reply(500);
if (!OPTIONS.hasOwnProperty(option)) return this.reply(501, 'Unknown option command');
return OPTIONS[option].call(this, args);
},
syntax: '{{cmd}}',
@@ -33,7 +33,6 @@ function utf8([setting] = []) {
if (!encoding) return this.reply(501, 'Unknown setting for option');
this.encoding = encoding;
if (this.transferType !== 'binary') this.transferType = this.encoding;
return this.reply(200, `UTF8 encoding ${_.toLower(setting)}`);
}

View File

@@ -1,4 +1,5 @@
const PassiveConnector = require('../../connector/passive');
const {isLocalIP} = require('../../helpers/is-local');
module.exports = {
directive: 'PASV',
@@ -10,7 +11,11 @@ module.exports = {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
const address = this.server.options.pasv_url;
let address = this.server.options.pasv_url;
// Allow connecting from local
if (isLocalIP(this.ip)) {
address = this.ip;
}
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;

View File

@@ -28,7 +28,7 @@ module.exports = {
stream.on('data', (data) => {
if (stream) stream.pause();
if (this.connector.socket) {
this.connector.socket.write(data, this.transferType, () => stream && stream.resume());
this.connector.socket.write(data, () => stream && stream.resume());
}
});
stream.once('end', () => resolve());

View File

@@ -21,11 +21,14 @@ module.exports = {
const serverPath = stream.path || fileName;
const destroyConnection = (connection, reject) => (err) => {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
try {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
}
} finally {
reject(err);
}
reject(err);
};
const streamPromise = new Promise((resolve, reject) => {
@@ -37,7 +40,7 @@ module.exports = {
this.connector.socket.on('data', (data) => {
if (this.connector.socket) this.connector.socket.pause();
if (stream && stream.writable) {
stream.write(data, this.transferType, () => this.connector.socket && this.connector.socket.resume());
stream.write(data, () => this.connector.socket && this.connector.socket.resume());
}
});
this.connector.socket.once('end', () => {

View File

@@ -104,15 +104,21 @@ class FtpConnection extends EventEmitter {
else if (typeof letter === 'string') letter = {message: letter}; // allow passing in message as first param
if (!letter.socket) letter.socket = options.socket ? options.socket : this.commandSocket;
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
if (!options.useEmptyMessage) {
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
}
return Promise.resolve(letter.message) // allow passing in a promise as a message
.then((message) => {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
message = !letter.raw ? _.compact([letter.code || options.code, message]).join(seperator) : message;
letter.message = message;
if (!options.useEmptyMessage) {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
message = !letter.raw ? _.compact([letter.code || options.code, message]).join(seperator) : message;
letter.message = message;
} else {
letter.message = '';
}
return letter;
});
});

View File

@@ -28,7 +28,6 @@ class Active extends Connector {
return closeExistingServer()
.then(() => {
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.connect({host, port, family}, () => {
this.dataSocket.pause();

View File

@@ -6,6 +6,8 @@ const Promise = require('bluebird');
const Connector = require('./base');
const errors = require('../errors');
const CONNECT_TIMEOUT = 30 * 1000;
class Passive extends Connector {
constructor(connection) {
super(connection);
@@ -30,6 +32,9 @@ class Passive extends Connector {
this.closeServer();
return this.server.getNextPasvPort()
.then((port) => {
this.dataSocket = null;
let idleServerTimeout;
const connectionHandler = (socket) => {
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, socket.remoteAddress)) {
this.log.error({
@@ -41,19 +46,19 @@ class Passive extends Connector {
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
clearTimeout(idleServerTimeout);
this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.');
this.dataSocket = socket;
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.once('close', () => this.closeServer());
if (!this.connection.secure) {
this.dataSocket.connected = true;
}
};
this.dataSocket = null;
const serverOptions = Object.assign({}, this.connection.secure ? this.server.options.tls : {}, {pauseOnConnect: true});
this.dataServer = (this.connection.secure ? tls : net).createServer(serverOptions, connectionHandler);
this.dataServer.maxConnections = 1;
@@ -74,6 +79,8 @@ class Passive extends Connector {
this.dataServer.listen(port, this.server.url.hostname, (err) => {
if (err) reject(err);
else {
idleServerTimeout = setTimeout(() => this.closeServer(), CONNECT_TIMEOUT);
this.log.debug({port}, 'Passive connection listening');
resolve(this.dataServer);
}

View File

@@ -2,13 +2,14 @@ const _ = require('lodash');
const nodePath = require('path');
const uuid = require('uuid');
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const {createReadStream, createWriteStream, constants} = require('fs');
const fsAsync = require('./helpers/fs-async');
const errors = require('./errors');
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep;
this.cwd = nodePath.normalize(cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep);
this._root = nodePath.resolve(root || process.cwd());
}
@@ -27,8 +28,8 @@ class FileSystem {
})();
const fsPath = (() => {
const resolvedPath = nodePath.resolve(this.root, `.${nodePath.sep}${clientPath}`);
return nodePath.join(resolvedPath);
const resolvedPath = nodePath.join(this.root, clientPath);
return nodePath.resolve(nodePath.normalize(nodePath.join(resolvedPath)));
})();
return {
@@ -43,19 +44,19 @@ class FileSystem {
get(fileName) {
const {fsPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.then((stat) => _.set(stat, 'name', fileName));
}
list(path = '.') {
const {fsPath} = this._resolvePath(path);
return fs.readdirAsync(fsPath)
return fsAsync.readdir(fsPath)
.then((fileNames) => {
return Promise.map(fileNames, (fileName) => {
const filePath = nodePath.join(fsPath, fileName);
return fs.accessAsync(filePath, fs.constants.F_OK)
return fsAsync.access(filePath, constants.F_OK)
.then(() => {
return fs.statAsync(filePath)
return fsAsync.stat(filePath)
.then((stat) => _.set(stat, 'name', fileName));
})
.catch(() => null);
@@ -66,7 +67,7 @@ class FileSystem {
chdir(path = '.') {
const {fsPath, clientPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.tap((stat) => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
@@ -78,8 +79,8 @@ class FileSystem {
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
const stream = fs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fs.unlinkAsync(fsPath));
const stream = createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fsAsync.unlink(fsPath));
stream.once('close', () => stream.end());
return {
stream,
@@ -89,12 +90,12 @@ class FileSystem {
read(fileName, {start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.tap((stat) => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = fs.createReadStream(fsPath, {flags: 'r', start});
const stream = createReadStream(fsPath, {flags: 'r', start});
return {
stream,
clientPath
@@ -104,28 +105,28 @@ class FileSystem {
delete(path) {
const {fsPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.then((stat) => {
if (stat.isDirectory()) return fs.rmdirAsync(fsPath);
else return fs.unlinkAsync(fsPath);
if (stat.isDirectory()) return fsAsync.rmdir(fsPath);
else return fsAsync.unlink(fsPath);
});
}
mkdir(path) {
const {fsPath} = this._resolvePath(path);
return fs.mkdirAsync(fsPath)
return fsAsync.mkdir(fsPath)
.then(() => fsPath);
}
rename(from, to) {
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return fs.renameAsync(fromPath, toPath);
return fsAsync.rename(fromPath, toPath);
}
chmod(path, mode) {
const {fsPath} = this._resolvePath(path);
return fs.chmodAsync(fsPath, mode);
return fsAsync.chmod(fsPath, mode);
}
getUniqueName() {

View File

@@ -16,10 +16,11 @@ function* portNumberGenerator(min, max = MAX_PORT) {
function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK_ATTEMPT) {
const nextPortNumber = portNumberGenerator(portMin, portMax);
const portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
return () => new Promise((resolve, reject) => {
const portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
let attemptCount = 0;
const tryGetPort = () => {
attemptCount++;
@@ -39,6 +40,7 @@ function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK
}
});
portCheckServer.once('listening', () => {
portCheckServer.removeAllListeners();
portCheckServer.close(() => resolve(port));
});

View File

@@ -0,0 +1,18 @@
const fs = require('fs');
const {promisify} = require('bluebird');
const methods = [
'stat',
'readdir',
'access',
'unlink',
'rmdir',
'mkdir',
'rename',
'chmod'
];
module.exports = methods.reduce((obj, method) => {
obj[method] = promisify(fs[method]);
return obj;
}, {});

View File

@@ -0,0 +1,3 @@
module.exports.isLocalIP = function(ip) {
return ip === '127.0.0.1' || ip == '::1';
}

View File

@@ -40,8 +40,8 @@ class FtpServer extends EventEmitter {
_.get(this, 'options.pasv_min'),
_.get(this, 'options.pasv_max'));
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const serverConnectionHandler = (socket) => {
socket.setTimeout(this.options.timeout);
@@ -53,7 +53,7 @@ class FtpServer extends EventEmitter {
const greeting = this._greeting || [];
const features = this._features || 'Ready';
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
.finally(() => socket.resume());
};
const serverOptions = Object.assign({}, this.isTLS ? this.options.tls : {}, {pauseOnConnect: true});
@@ -119,6 +119,7 @@ class FtpServer extends EventEmitter {
return new Promise((resolve) => {
const client = this.connections[id];
if (!client) return resolve();
this.emit('disconnect', {connection: client, id});
delete this.connections[id];
try {
client.close(0);

View File

@@ -1,56 +1,56 @@
module.exports = {
| 100
| 110
| 120
| 125
| 150
| 200
| 202
| 211
| 212
| 213
| 214
| 215
| 220
| 221
| 225
| 226
| 227
| 230
| 234
| 250
| 257
| 331
| 332
| 350
| 421
| 425
| 426
| 450
| 451
| 452
| 500
| 501
| 502
| 503
| 504
| 530
| 532
| 550
| 551
| 552
| 553
// 100 - 199 :: Remarks
100: 'The requested action is being initiated',
110: 'Restart marker reply',
120: 'Service ready in %s minutes',
125: 'Data connection already open; transfer starting',
150: 'File status okay; about to open data connection',
// 200 - 399 :: Acceptance
/// 200 - 299 :: Positive Completion Replies
/// These type of replies indicate that the requested action was taken and that the server is awaiting another command.
200: 'The requested action has been successfully completed',
202: 'Superfluous command',
211: 'System status, or system help reply',
212: 'Directory status',
213: 'File status',
214: 'Help message', // On how to use the server or the meaning of a particular non-standard command. This reply is useful only to the human user.
215: 'UNIX Type: L8', // NAME system type. Where NAME is an official system name from the list in the Assigned Numbers document.
220: 'Service ready for new user',
221: 'Service closing control connection', // Logged out if appropriate.
225: 'Data connection open; no transfer in progress',
226: 'Closing data connection', // Requested file action successful (for example, file transfer or file abort).
227: 'Entering Passive Mode', // (h1,h2,h3,h4,p1,p2).
230: 'User logged in, proceed',
234: 'Honored',
250: 'Requested file action okay, completed',
257: '\'%s\' created',
/// 300 - 399 :: Positive Intermediate Replies
/// These types of replies indicate that the requested action was taken and that the server is awaiting further information to complete the request.
331: 'Username okay, awaiting password',
332: 'Need account for login',
350: 'Requested file action pending further information',
// 400 - 599 :: Rejection
/// 400 - 499 :: Transient Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// However, the error is temporary and the action may be requested again.
421: 'Service not available, closing control connection', // This may be a reply to any command if the service knows it must shut down.
425: 'Unable to open data connection',
426: 'Connection closed; transfer aborted',
450: 'Requested file action not taken', // File unavailable (e.g., file busy).
451: 'Requested action aborted. Local error in processing',
452: 'Requested action not taken. Insufficient storage',
/// 500 - 599 :: Permanent Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// The FTP client is "discouraged" from repeating the same exact request.
500: 'Syntax error', // Can close connection
501: 'Syntax error in parameters or arguments',
502: 'Command not supported',
503: 'Bad sequence of commands',
504: 'Command parameter not supported',
530: 'Not logged in', // Permission Denied, Can close connection
532: 'Need account for storing files',
550: 'Requested action not taken. File unavailable', // (e.g., file not found, no access).
551: 'Requested action aborted. Page type unknown',
552: 'Requested file action aborted. Exceeded storage allocation', // (for current directory or dataset).
553: 'Requested action not taken. File name not allowed'
};

View File

@@ -90,7 +90,7 @@ describe('FtpCommands', function () {
return commands.handle('bad')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(402);
expect(mockConnection.reply.args[0][0]).to.equal(502);
});
});

View File

@@ -2,9 +2,22 @@
const {expect} = require('chai');
const net = require('net');
const {getNextPortFactory} = require('../../src/helpers/find-port');
const {getNextPortFactory, portNumberGenerator} = require('../../src/helpers/find-port');
describe('getNextPortFactory', function () {
describe('portNumberGenerator', () => {
it('loops through given set of numbers', () => {
const nextNumber = portNumberGenerator(1, 5);
expect(nextNumber.next().value).to.equal(1);
expect(nextNumber.next().value).to.equal(2);
expect(nextNumber.next().value).to.equal(3);
expect(nextNumber.next().value).to.equal(4);
expect(nextNumber.next().value).to.equal(5);
expect(nextNumber.next().value).to.equal(1);
expect(nextNumber.next().value).to.equal(2);
});
});
describe('helpers // find-port', function () {
describe('keeps trying new ports', () => {
let getNextPort;
let serverAlreadyRunning;
@@ -26,4 +39,17 @@ describe('helpers // find-port', function () {
});
});
});
it('finds ports concurrently', () => {
const portStart = 10000;
const getCount = 100;
const getNextPort = getNextPortFactory('::', portStart);
const portFinders = new Array(getCount).fill().map(() => getNextPort());
return Promise.all(portFinders)
.then((ports) => {
expect(ports.length).to.equal(getCount);
expect(ports).to.eql(new Array(getCount).fill().map((v, i) => i + portStart));
});
});
});

View File

@@ -187,9 +187,9 @@ describe('Integration', function () {
});
client.put(buffer, 'fail.txt', (err) => {
expect(err).to.exist;
setImmediate(() => {
const fileExists = fs.existsSync(fsPath);
expect(err).to.exist;
expect(fileExists).to.equal(false);
done();
});
@@ -217,6 +217,24 @@ describe('Integration', function () {
});
});
it('STOR logo.png', (done) => {
const logo = `${__dirname}/../logo.png`;
const fsPath = `${clientDirectory}/${name}/logo.png`;
client.put(logo, 'logo.png', (err) => {
expect(err).to.not.exist;
setImmediate(() => {
expect(fs.existsSync(fsPath)).to.equal(true);
const logoContents = fs.readFileSync(logo);
const transferedContects = fs.readFileSync(fsPath);
expect(logoContents.equals(transferedContects));
done();
});
});
});
it('APPE tést.txt', (done) => {
const buffer = Buffer.from(', awesome!');
const fsPath = `${clientDirectory}/${name}/tést.txt`;
@@ -351,6 +369,39 @@ describe('Integration', function () {
});
}
describe('Server events', function () {
const disconnect = sinon.spy();
const login = sinon.spy();
before(() => {
server.on('login', login);
server.on('disconnect', disconnect);
return connectClient({
host: server.url.hostname,
port: server.url.port,
user: 'test',
password: 'test'
});
});
after(() => {
server.off('login', login);
server.off('disconnect', disconnect);
})
it('should fire a login event on connect', () => {
expect(login.calledOnce).to.be.true;
});
it('should fire a close event on disconnect', (done) => {
client.end();
setTimeout(() => {
expect(disconnect.calledOnce).to.be.true;
done();
}, 100)
});
});
describe('#ASCII', function () {
before(() => {
return connectClient({

View File

@@ -5,7 +5,7 @@ const FtpServer = require('../src');
const server = new FtpServer({
log: bunyan.createLogger({name: 'test', level: 'trace'}),
url: 'ftp://127.0.0.1:8880',
pasv_url: '127.0.0.1',
pasv_url: '192.168.1.1',
pasv_min: 8881,
greeting: ['Welcome', 'to', 'the', 'jungle!'],
tls: {

9692
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,7 @@
{
"name": "ftp-srv",
"version": "5.0.0",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"main": "build/index",
"scripts": {
"lint": "eslint src/**/*.ts",
"lint:fix": "npm run lint -- --fix"
},
"jest": {
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"transform": {
".ts": "ts-jest"
},
"testRegex": "\\.test\\.ts",
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": "plugin:@typescript-eslint/recommended"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"keywords": [
"ftp",
"ftp-server",
@@ -61,32 +11,26 @@
"ftpserver",
"server"
],
"author": "Tyler Stewart",
"license": "MIT",
"files": [
"dist"
],
"main": "dist/index",
"repository": {
"type": "git",
"url": "git+https://github.com/trs/ftp-srv.git"
"url": "https://github.com/trs/ftp-srv"
},
"bugs": {
"url": "https://github.com/trs/ftp-srv/issues"
"scripts": {
"build": "ncc build src/example.ts -o dist -s",
"dev": "ncc run src/example.ts"
},
"homepage": "https://github.com/trs/ftp-srv#readme",
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.0",
"@types/jest": "^24.0.10",
"@types/node": "^11.10.5",
"@types/signale": "^1.2.1",
"@typescript-eslint/eslint-plugin": "^1.4.2",
"@typescript-eslint/parser": "^1.4.2",
"eslint": "^5.15.1",
"eslint-plugin-prettier": "^3.0.1",
"husky": "^1.3.1",
"jest": "^24.3.1",
"lerna": "^3.13.1",
"lint-staged": "^8.1.5",
"prettier": "1.16.4",
"ts-jest": "^24.0.0",
"typescript": "^3.3.3333"
"@types/node": "^13.13.2",
"@zeit/ncc": "^0.22.2",
"typescript": "^3.9.3"
},
"dependencies": {
"ix": "^3.0.2",
"rxjs": "^6.5.5"
}
}

113
src/client.ts Normal file
View File

@@ -0,0 +1,113 @@
// import { Socket } from 'net';
// import { fromEvent, Subject } from 'rxjs';
// import { map, tap, takeUntil } from 'rxjs/operators';
// import { parseCommandString, handleCommand } from '~/command';
// import { RecordMap } from './types';
// import { MiddlewareDefinition, MiddlewareCommandHandler } from './middleware/types';
// import { CommandDirective, Command } from './command/types';
// import { formatReply } from './reply';
// import { CommandError } from './error';
// export interface Context {
// username?: string;
// password?: string;
// account?: string;
// authenticated: boolean;
// }
// function getDefaultContext() {
// const context: RecordMap<Context> = new Map();
// context.set('authenticated', false);
// return context;
// }
// function parseBuffer(buffer: Buffer): string {
// return buffer.toString('utf8');
// }
// export default class FTPClient {
// private middleware = new Set<MiddlewareDefinition>();
// private commandMiddleware: RecordMap<{[T in CommandDirective]?: Set<MiddlewareCommandHandler<T>>}> = new Map();
// private commandSubject: Subject<Command>;
// private replySubject: Subject<[number, ...string[]]>;
// public context = getDefaultContext();
// public constructor(private connection: Socket) {
// this.commandSubject = new Subject();
// fromEvent<Buffer>(this.connection, 'data')
// .pipe(
// takeUntil(fromEvent(this.connection, 'close')),
// map(parseBuffer),
// map(parseCommandString),
// tap((command) => console.log(`recv: ${command.raw.trim()}`))
// )
// .subscribe(this.commandSubject);
// this.replySubject = new Subject();
// this.replySubject
// .pipe(
// takeUntil(fromEvent(this.connection, 'close')),
// map(([code, ...lines]) => formatReply(code, lines)),
// tap((message) => console.log(`send: ${message.trim()}`))
// )
// .subscribe((message) => this.connection.write(message));
// }
// private initializeMiddleware() {
// type Def = ReturnType<MiddlewareDefinition>;
// type MiddlewareEntry = [ keyof Def, Def[keyof Def] ];
// for (const createMiddleware of this.middleware.values()) {
// const ware = createMiddleware(this);
// for (const [command, handle] of Object.entries(ware) as MiddlewareEntry[]) {
// const handles = this.commandMiddleware.get(command) ?? new Set<MiddlewareCommandHandler<typeof command>>();
// handles.add(handle as MiddlewareCommandHandler<typeof command>);
// this.commandMiddleware.set(command, handles);
// }
// }
// }
// public resetContext() {
// this.context = getDefaultContext();
// }
// public use(ware: MiddlewareDefinition) {
// this.middleware.add(ware);
// }
// public send(code: number, ...lines: string[]) {
// this.replySubject.next([code, ...lines]);
// }
// public resume() {
// this.initializeMiddleware();
// this.commandSubject.subscribe(async (command) => {
// try {
// const wares = this.commandMiddleware.get(command.directive) ?? new Set();
// await handleCommand(this, wares)(command);
// } catch (err) {
// if (err instanceof CommandError) {
// this.send(err.code, err.message);
// } else {
// this.connection.emit('error', err);
// }
// }
// });
// this.connection.resume();
// }
// public close() {
// this.connection.destroy();
// }
// }
// export const createFTPClient = (socket: Socket) => {
// return new FTPClient(socket);
// }

View File

@@ -1,65 +0,0 @@
import { CommandRegistration } from ".";
const user: CommandRegistration = {
arguments: ['username'],
description: 'Set the username to authenticate with',
handler: async function ({connection, command, reply}) {
if (connection.hasContext('username')) {
/*
RFC 959
4.1.1.
Servers may allow a new USER command to be
entered at any point in order to change the access control
and/or accounting information. This has the effect of
flushing any user, password, and account information already
supplied and beginning the login sequence again. All
transfer parameters are unchanged and any file transfer in
progress is completed under the old access control
parameters.
*/
connection.unsetContext('username', 'password');
}
connection.setContext('username', command.argument);
reply.set([331]);
}
};
const pass: CommandRegistration = {
arguments: ['password'],
description: 'Set the password to authenticate with',
handler: async function ({connection, command, reply}) {
if (connection.hasContext('password')) {
reply.set([202]);
return;
}
if (!connection.hasContext('username')) {
reply.set([503]);
return;
}
connection.setContext('password', command.argument);
reply.set([230]);
}
};
const acct: CommandRegistration = {
arguments: ['account-information'],
description: 'Set the identifying account',
handler: async function ({connection, command, reply}) {
if (!connection.hasContext('username', 'password')) {
reply.set([503]);
}
connection.setContext('account', command.argument);
reply.set([230]);
}
};
export {
user,
pass,
acct
};

View File

@@ -0,0 +1,37 @@
import { CommandDefinition } from '~/command/types';
import { CommandError, SkipCommandError } from '~/error';
/*
230
202
530
500, 501, 503, 421
*/
export const ACCT: CommandDefinition<'ACCT'> = (client) => {
return {
setup(command) {
if (client.context.get('authenticated') === true) {
throw new SkipCommandError(202);
}
if (client.context.has('account')) {
throw new CommandError(503, 'Account already set');
}
if (!client.context.has('username')) {
throw new CommandError(503, 'Must send USER');
}
if (!client.context.has('password')) {
throw new CommandError(503, 'Must send PASS');
}
const account = command.arg;
if (!account) {
throw new CommandError(501, 'Must provide account');
}
return {account};
}
}
}

View File

@@ -0,0 +1,34 @@
import { CommandDefinition } from '~/command/types';
import { CommandError, SkipCommandError } from '~/error';
/*
230
202
530
500, 501, 503, 421
332
*/
export const PASS: CommandDefinition<'PASS'> = (client) => {
return {
setup(command) {
if (client.context.get('authenticated') === true) {
throw new SkipCommandError(202);
}
if (client.context.has('password')) {
throw new CommandError(503, 'Password already set');
}
if (!client.context.has('username')) {
throw new CommandError(503, 'Must send USER');
}
const password = command.arg;
if (!password) {
throw new CommandError(501, 'Must provide password');
}
return {password};
}
}
}

View File

@@ -0,0 +1,10 @@
import { CommandDefinition } from '~/command/types';
import { CommandError } from '~/error';
export const PASV: CommandDefinition<'PASV'> = () => {
return {
setup(command) {
}
}
}

View File

@@ -0,0 +1,22 @@
import { CommandDefinition } from '~/command/types';
import { CommandError } from '~/error';
export const PORT: CommandDefinition<'PORT'> = () => {
return {
setup(command) {
const connection = command.arg.split(',');
if (connection.length !== 6) {
throw new CommandError(425, 'Unable to open data connection');
}
const ip = connection.slice(0, 4).join('.');
const portBytes = connection.slice(4).map((p) => parseInt(p));
const port = portBytes[0] * 256 + portBytes[1];
return {
ip,
port
};
}
}
}

View File

@@ -0,0 +1,17 @@
import { CommandDefinition } from '~/command/types';
/*
230
202
530
500, 501, 503, 421
*/
export const QUIT: CommandDefinition<'QUIT'> = (client) => {
return {
setup() {
// Wait for data connection, then close
// client.dataconnection.on('close', client.close()) ?
client.destroy();
}
}
}

View File

@@ -0,0 +1,17 @@
import { CommandDefinition } from '~/command/types';
import { getDefaultContext } from '~/connection/command';
/*
120
220
220
421
500, 502
*/
export const REIN: CommandDefinition<'REIN'> = (client) => {
return {
setup() {
client.context = getDefaultContext();
}
}
}

View File

@@ -0,0 +1,13 @@
import { CommandDefinition } from '~/command/types';
/*
215
500, 501, 502, 421
*/
export const SYST: CommandDefinition<'SYST'> = (client) => {
return {
setup() {
client.send(215, 'UNIX Type: L8');
}
}
}

View File

@@ -0,0 +1,29 @@
import { CommandDefinition } from '~/command/types';
import { CommandError, SkipCommandError } from '~/error';
/*
230
530
500, 501, 421
331, 332
*/
export const USER: CommandDefinition<'USER'> = (client) => {
return {
setup(command) {
if (client.context.get('authenticated') === true) {
throw new SkipCommandError(230);
}
if (client.context.has('username')) {
throw new CommandError(530, 'Username already set');
}
const username = command.arg;
if (!username) {
throw new CommandError(501, 'Must provide username');
}
return {username};
}
}
};

View File

@@ -0,0 +1,17 @@
import { CommandDefinition } from '../types';
import {USER} from './USER';
import {PASS} from './PASS';
import {ACCT} from './ACCT';
import {QUIT} from './QUIT';
import {PORT} from './PORT';
const registry = new Map<string, CommandDefinition<any>>();
registry.set('USER', USER);
registry.set('PASS', PASS);
registry.set('ACCT', ACCT);
registry.set('QUIT', QUIT);
registry.set('PORT', PORT);
export default registry;

View File

@@ -1,18 +0,0 @@
import { CommandRegistration } from ".";
import { CommandPlugin } from '../server';
const cwd: CommandRegistration = {
description: '',
handler: async ({command, reply}) => {
}
};
const cdup: CommandRegistration = {
description: '',
handler: async ({command, reply}) => {
}
};
export {cwd, cdup};

View File

@@ -1,42 +1,40 @@
import CommandSocket from "../commandSocket";
import FTPServer from "../server";
import { ReplyCode } from "../reply";
import { Command, CommandDirective } from './types';
import definitions from './definitions';
import { UnsupportedCommandError } from "~/error";
import { CommandConnection } from "~/connection/command";
export interface Command {
identifier: string;
argument: string;
argumentParts: string[];
const CMD_FLAG_REGEX = new RegExp(/^(?:-(\w{1}))|(?:--(\w{2,}))$/);
export function parseCommandString(commandString: string): Command {
// TODO replace this function with something better
const strippedMessage = commandString.replace(/"/g, '');
let [directive, ...args] = strippedMessage.replace(/\r?\n/g, '').split(' ');
directive = directive.trim().toLocaleUpperCase();
const parseCommandFlags = !['RETR', 'SIZE', 'STOR'].includes(directive);
const params = args.reduce(({arg, flags}: {arg: string[], flags: string[]}, param) => {
if (parseCommandFlags && CMD_FLAG_REGEX.test(param)) flags.push(param);
else arg.push(param);
return {arg, flags};
}, {arg: [], flags: []});
const command: Command = {
directive: directive as CommandDirective,
arg: params.arg.length ? params.arg.join(' ') : undefined,
flags: params.flags,
raw: commandString
};
return command;
}
export type CommandReply = [ReplyCode, ...string[]];
export const getCommandContext = (client: CommandConnection, command: Command) => {
const createDefinition = definitions.get(command.directive);
if (!createDefinition) {
throw new UnsupportedCommandError(command.directive);
}
export interface CommandReplyMod {
get: () => CommandReply;
set: (v: CommandReply) => void;
}
export type CommandHandler = (params: {server: Readonly<FTPServer>; connection: Readonly<CommandSocket>; command: Command; reply: CommandReplyMod}) => Promise<void>;
export interface CommandRegistration {
arguments?: string[];
description: string;
handler: CommandHandler;
}
export function parseCommandBuffer(data: Buffer): Command {
const result = data
.toString('utf8')
.replace(/\s+/g, ' ')
.match(/^(\w+?)(?: (.+)$|$)/);
if (!result) throw new Error('Invalid command');
const identifier = result[1].toLocaleUpperCase();
const argument = result.length > 1 ? result[2].trim() : '';
const argumentParts = argument.split(' ');
return {
identifier,
argument,
argumentParts
};
const definition = createDefinition(client);
const context = 'setup' in definition ? definition.setup(command) : undefined;
return context;
}

View File

@@ -1,131 +0,0 @@
/**
* https://tools.ietf.org/html/rfc2228
* https://tools.ietf.org/html/rfc4217
*/
import { CommandRegistration } from ".";
/*
AUTHENTICATION/SECURITY MECHANISM (AUTH)
The argument field is a Telnet string identifying a supported
mechanism. This string is case-insensitive. Values must be
registered with the IANA, except that values beginning with "X-"
are reserved for local use.
If the server does not recognize the AUTH command, it must respond
with reply code 500. This is intended to encompass the large
deployed base of non-security-aware ftp servers, which will
respond with reply code 500 to any unrecognized command. If the
server does recognize the AUTH command but does not implement the
security extensions, it should respond with reply code 502.
If the server does not understand the named security mechanism, it
should respond with reply code 504.
If the server is not willing to accept the named security
mechanism, it should respond with reply code 534.
If the server is not able to accept the named security mechanism,
such as if a required resource is unavailable, it should respond
with reply code 431.
If the server is willing to accept the named security mechanism,
but requires security data, it must respond with reply code 334.
If the server is willing to accept the named security mechanism,
and does not require any security data, it must respond with reply
code 234.
If the server is responding with a 334 reply code, it may include
security data as described in the next section.
Some servers will allow the AUTH command to be reissued in order
to establish new authentication. The AUTH command, if accepted,
removes any state associated with prior FTP Security commands.
The server must also require that the user reauthorize (that is,
reissue some or all of the USER, PASS, and ACCT commands) in this
case (see section 4 for an explanation of "authorize" in this
context).
AUTH
234
334
502, 504, 534, 431
500, 501, 421
*/
const auth: CommandRegistration = {
arguments: ['<mechanism-name>'],
description: 'Set authentication mechanism',
handler: async ({command, reply}) => {
const method = command.argument.toLocaleUpperCase();
switch (method) {
default: reply.set([504]);
}
}
}
/*
PROTECTION BUFFER SIZE (PBSZ)
The argument is a decimal integer representing the maximum size,
in bytes, of the encoded data blocks to be sent or received during
file transfer. This number shall be no greater than can be
represented in a 32-bit unsigned integer.
This command allows the FTP client and server to negotiate a
maximum protected buffer size for the connection. There is no
default size; the client must issue a PBSZ command before it can
issue the first PROT command.
The PBSZ command must be preceded by a successful security data
exchange.
If the server cannot parse the argument, or if it will not fit in
32 bits, it should respond with a 501 reply code.
If the server has not completed a security data exchange with the
client, it should respond with a 503 reply code.
Otherwise, the server must reply with a 200 reply code. If the
size provided by the client is too large for the server, it must
use a string of the form "PBSZ=number" in the text part of the
reply to indicate a smaller buffer size. The client and the
server must use the smaller of the two buffer sizes if both buffer
sizes are specified.
PBSZ
200
503
500, 501, 421, 530
*/
const pbsz: CommandRegistration = {
arguments: ['<decimal-integer>'],
description: 'The maximum size, in bytes, of the encoded data blocks to be sent or received during file transfer.',
handler: async ({reply}) => {
reply.set([500]);
}
}
/*
PROT
200
504, 536, 503, 534, 431
500, 501, 421, 530
*/
const prot: CommandRegistration = {
arguments: ['<prot-code ::= C | S | E | P>'],
description: 'Indicates to the server what type of data channel protection the client and server will be using',
handler: async ({reply}) => {
reply.set([536]);
}
}
export {
auth,
pbsz,
prot
};

View File

@@ -1,11 +0,0 @@
import { CommandRegistration } from ".";
const stru: CommandRegistration = {
arguments: ['<structure>'],
description: 'Set file transfer structure (Only "F" supported)',
handler: async ({command, reply}) => {
const code = /^F$/i.test(command.argument) ? 200 : 504;
reply.set([code]);
}
}
export {stru};

54
src/command/types.ts Normal file
View File

@@ -0,0 +1,54 @@
import { CommandConnection } from '~/connection/command';
import { OrPromise } from '../types';
export interface CommandContext {
USER: {username: string};
PASS: {password: string};
ACCT: {account: string};
CWD: void;
CDUP: void;
SMNT: void;
REIN: void;
QUIT: void;
PORT: {ip: string, port: number};
PASV: void;
MODE: void;
TYPE: void;
STRU: void;
ALLO: void;
REST: void;
STOR: void;
STOU: void;
RETR: void;
LIST: void;
NLST: void;
APPE: void;
RNFR: void;
RNTO: void;
DELE: void;
RMD: void;
MKD: void;
PWD: void;
ABOR: void;
SYST: void;
STAT: void;
HELP: void;
SITE: void;
NOOP: void;
}
export type CommandDirective = keyof CommandContext;
export type Command = {
directive: CommandDirective;
arg: string | undefined,
flags: string[],
raw: string;
}
export type CommandDefinition<T extends CommandDirective> = (client: CommandConnection) => {
/** Checks that command is valid, creates context for handle */
setup?: (command: Command) => OrPromise<CommandContext[T]>;
/** Performs actions required for command, can be extended with plugins */
handle?: (context: CommandContext[T]) => OrPromise<void>;
}

View File

@@ -1,76 +0,0 @@
import { Socket } from "net";
import { ReplyCode, formatReply } from "./reply";
import { FileSystem } from "./filesystem";
interface Meta {
connectedTime: string;
disconnectedTime?: string;
address?: string;
}
interface Context {
username?: string;
password?: string;
account?: string;
}
export default class CommandSocket {
private instance: Socket;
private meta: Meta;
private context: Context = {};
private filesystem: FileSystem;
constructor(socket: Socket) {
this.instance = socket.setEncoding('utf8');
this.meta = {
address: this.instance.remoteAddress,
connectedTime: new Date().toUTCString()
};
this.filesystem = new FileSystem();
};
public async sendReply(code: ReplyCode, ...lines: string[]) {
const reply = formatReply(code, ...lines);
await new Promise((resolve, reject) => {
this.instance.write(Buffer.from(reply), 'utf8', (err: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
public async close() {
}
public hasContext<K extends keyof Context>(...names: K[]): boolean {
for (const name of names) {
if (!this.context.hasOwnProperty(name)) return false;
}
return true;
}
public getContext<K extends keyof Context>(name: K): Context[K] {
return this.context[name];
}
public setContext<K extends keyof Context>(name: K, value: Context[K]): this {
this.context[name] = value;
return this;
}
public unsetContext<K extends keyof Context>(...names: K[]): this {
for (const name of names) {
delete this.context[name];
}
return this;
}
public unsetAllContext() {
this.context = {};
return this;
}
}

101
src/connection/command.ts Normal file
View File

@@ -0,0 +1,101 @@
import { Socket } from "net";
import { fromEvent, Subject, from } from 'rxjs';
import { takeUntil, map, tap, filter, switchMap } from 'rxjs/operators';
import { parseCommandString, getCommandContext } from '~/command';
import { Command } from "~/command/types";
import { formatReply } from "~/reply";
import { MiddlewareDefinition } from "~/middleware/types";
import { CommandError } from "~/error";
import { RecordMap } from "~/types";
interface Context {
username?: string;
password?: string;
account?: string;
authenticated: boolean;
}
type ContextMap = RecordMap<Context>;
export interface CommandConnection extends Socket {
context: ContextMap;
use: (ware: MiddlewareDefinition) => void;
send: (code: number, ...lines: string[]) => void;
}
export function getDefaultContext() {
const context: RecordMap<Context> = new Map();
context.set('authenticated', false);
return context;
}
export const createCommandConnection = (socket: Socket) => {
const connection = socket as CommandConnection;
connection.context = getDefaultContext();
// Observables
const commandSubject = new Subject<Command>();
fromEvent<Buffer>(connection, 'data')
.pipe(
takeUntil(fromEvent(connection, 'close')),
map(parseBuffer),
map(parseCommandString),
tap((command) => console.log(`recv: ${command.raw.trim()}`))
)
.subscribe(commandSubject);
const replySubject = new Subject();
replySubject
.pipe(
takeUntil(fromEvent(connection, 'close')),
map(([code, ...lines]) => formatReply(code, lines)),
tap((message) => console.log(`send: ${message.trim()}`))
)
.subscribe((message) => connection.write(message));
// Methods
connection.use = (createMiddleware) => {
const middleware = createMiddleware(connection);
commandSubject.pipe(
filter((command) => command.directive in middleware),
switchMap(async (command) => {
const context = getCommandContext(connection, command);
await middleware[command.directive](context);
})
)
.subscribe({
error(err) {
if (err instanceof CommandError) {
this.send(err.code, err.message);
} else {
this.connection.emit('error', err);
}
}
});
};
connection.send = (code: number, ...lines: string[]) => {
replySubject.next([code, ...lines]);
};
return connection;
};
const parseBuffer = (buffer: Buffer) => buffer.toString('utf8');
export const createCommandObservable = (connection: CommandConnection) =>
fromEvent<Buffer>(connection, 'data').pipe(
takeUntil(fromEvent(connection, 'close')),
map(parseBuffer),
map(parseCommandString)
);
export const resumeCommandConnection = (connection: CommandConnection) => {
connection.resume();
};

60
src/connection/data.ts Normal file
View File

@@ -0,0 +1,60 @@
import { fromEvent } from 'rxjs';
import { map, timeout, takeUntil, take } from 'rxjs/operators';
import { Socket, createConnection } from 'net';
import {createServer, ServerOptions} from '~/server';
export interface DataConnection extends Socket {
}
export const createDataConnection = () => map<Socket, DataConnection>((socket) => {
const connection = socket as DataConnection;
return connection;
});
// Active
export interface ActiveDataConnectionConfig {
ip: string;
port: number;
timeout: number;
}
export const createActiveDataConnection = (config: ActiveDataConnectionConfig) => {
const connection = createConnection({
port: config.port,
host: config.ip,
family: 4,
allowHalfOpen: false
});
return fromEvent(connection, 'connect', {once: true})
.pipe(
timeout(config.timeout),
createDataConnection()
)
.toPromise();
}
// Passive
export interface PassiveDataConnectionConfig extends ServerOptions {
timeout: number;
}
export const createPassiveDataConnection = (config: PassiveDataConnectionConfig) => {
const server = createServer(config);
server.maxConnections = 1;
const connection = fromEvent(server, 'connection').pipe(
takeUntil(fromEvent(server, 'close')),
timeout(config.timeout),
createDataConnection()
);
server.listen(config.port, config.hostname);
return connection.toPromise();
}

View File

@@ -1,4 +0,0 @@
export default class DataSocket {
}

17
src/error.ts Normal file
View File

@@ -0,0 +1,17 @@
export class CommandError extends Error {
constructor(public code: number, message: string) {
super(message);
}
}
export class UnsupportedCommandError extends CommandError {
constructor(public directive: string) {
super(502, `Command not implemented: ${directive}`);
}
}
export class SkipCommandError extends CommandError {
constructor(code: number, message?: string) {
super(code, message);
}
}

16
src/example.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createFTPServer } from './';
import { createGreetingMiddleware } from './middleware/greeting';
import { createLoginMiddleware } from './middleware/login';
createFTPServer({
port: 2121,
hostname: 'localhost'
})
.use(createGreetingMiddleware({message: 'Hello World!'}))
.use(createLoginMiddleware(
(username, password) => {
return username === 'sudo' && password === 'password'
},
{requirePassword: true, requireAccount: false}
))
.listen();

View File

@@ -1,113 +0,0 @@
import {parse, join, relative, isAbsolute} from 'path';
import {promises as fs, constants, Stats} from 'fs';
type Parameters<T> = T extends (... args: infer T) => any ? T : never;
type ReturnType<T> = T extends (... args: any[]) => infer T ? T : never;
export interface NodeFileSystem {
stat: (...args: Parameters<typeof fs.stat>) => ReturnType<typeof fs.stat>;
rename: (...args: Parameters<typeof fs.rename>) => ReturnType<typeof fs.rename>;
access: (...args: Parameters<typeof fs.access>) => ReturnType<typeof fs.access>;
rmdir: (...args: Parameters<typeof fs.rmdir>) => ReturnType<typeof fs.rmdir>;
unlink: (...args: Parameters<typeof fs.unlink>) => ReturnType<typeof fs.unlink>;
mkdir: (...args: Parameters<typeof fs.mkdir>) => ReturnType<typeof fs.mkdir>;
chmod: (...args: Parameters<typeof fs.chmod>) => ReturnType<typeof fs.chmod>;
readdir: (...args: Parameters<typeof fs.readdir>) => ReturnType<typeof fs.readdir>;
}
interface FileSystemConfig {
root: string;
current: string;
fs: NodeFileSystem;
}
export class FileSystem {
private rootDirectory: string;
private currentDirectory: string;
private fs: NodeFileSystem;
/**
* @param root absolute path on the server to the users root directory
* @param current relative path from root to the users current directory
*/
constructor(config: Partial<FileSystemConfig> = {}) {
this.rootDirectory = config.root || '/';
this.currentDirectory = config.current || '.';
this.fs = config.fs || fs as unknown as NodeFileSystem;
}
public async absoluteDirectory() {
return this.getAbsolutePath();
}
public async navigate(to: string | null) {
const directory = this.resolvePath(this.currentDirectory, to);
await this.fs.access(this.getAbsolutePath(this.rootDirectory, directory), constants.R_OK);
this.currentDirectory = directory;
return this.currentDirectory;
}
public async stat(path: string | null): Promise<Stats> {
path = path ? this.resolvePath(this.currentDirectory, path) : this.currentDirectory;
const stat = await this.fs.stat(this.getAbsolutePath(this.rootDirectory, path));
return stat;
}
public async rename(from: string, to: string): Promise<string> {
from = this.resolvePath(this.currentDirectory, from);
to = this.resolvePath(this.currentDirectory, to);
await this.fs.rename(
this.getAbsolutePath(this.rootDirectory, from),
this.getAbsolutePath(this.rootDirectory, to)
);
return to;
}
public async delete(path: string): Promise<void> {
path = this.resolvePath(this.currentDirectory, path);
path = this.getAbsolutePath(this.rootDirectory, path);
const stat = await this.stat(path);
if (stat.isDirectory()) await this.fs.rmdir(path);
else await this.fs.unlink(path);
}
public async mkdir(path: string, mode?: string | number): Promise<string> {
path = this.resolvePath(this.currentDirectory, path);
await this.fs.mkdir(this.getAbsolutePath(this.rootDirectory, path), mode);
return path;
}
public async chmod(path: string, mode: string | number): Promise<string> {
path = this.resolvePath(this.currentDirectory, path);
await this.fs.chmod(this.getAbsolutePath(this.rootDirectory, path), mode);
return path;
}
public async readdir(path: string | null): Promise<string[]> {
path = this.resolvePath(this.currentDirectory, path);
const paths = await this.fs.readdir(this.getAbsolutePath(this.rootDirectory, path), {
encoding: 'utf8'
});
return paths as string[];
}
// public write(path: string): any;
// public read(path: string | null): any;
private resolvePath(from: string, to: string | null) {
if (!to) return from;
if (isAbsolute(to)) {
const {root} = parse(this.rootDirectory);
const cwd = this.getAbsolutePath(root); // Pretend `cwd` is the absolute path from root
to = relative(cwd, to);
}
return join(from, to);
}
private getAbsolutePath(root = this.rootDirectory, current = this.currentDirectory) {
return join(root, current);
}
}

42
src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import {Server} from 'net';
import {promisify} from 'util';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil, map } from 'rxjs/operators';
import { createCommandConnection, resumeCommandConnection, CommandConnection } from '~/connection/command';
import { MiddlewareDefinition } from './middleware/types';
import { ServerOptions, createServer } from './server';
export default class FTPServer {
private server: Server;
private connectionSubject: Subject<CommandConnection>;
public constructor(private config: ServerOptions) {
this.server = createServer(this.config);
this.connectionSubject = new Subject()
fromEvent(this.server, 'connection').pipe(
takeUntil(fromEvent(this.server, 'close')),
map(createCommandConnection)
)
.subscribe(this.connectionSubject);
}
public close = () => promisify(this.server.close)();
public listen() {
this.server.listen(this.config.port, this.config.hostname);
this.connectionSubject.subscribe(resumeCommandConnection);
}
public use(ware: MiddlewareDefinition) {
this.connectionSubject.subscribe((client) => client.use(ware));
return this;
}
}
export function createFTPServer(...args: ConstructorParameters<typeof FTPServer>) {
return new FTPServer(...args);
}

View File

@@ -0,0 +1,17 @@
import { MiddlewareDefinition } from '~/middleware/types';
export interface FileSystemMiddlewareConfig {
root: string;
cwd: string;
}
export const createFileSystemMiddleware = (config: FileSystemMiddlewareConfig): MiddlewareDefinition => (client) => {
let root = config.root ?? './';
let cwd = config.cwd ?? '/';
return {
PWD() {
client.send(257, cwd);
}
};
};

View File

@@ -0,0 +1,12 @@
import { MiddlewareDefinition } from '~/middleware/types';
interface GreetingMiddlewareConfig {
message: string;
}
export const createGreetingMiddleware = (config: GreetingMiddlewareConfig): MiddlewareDefinition => (client) => {
// TODO: features flags?
client.send(220, config.message);
return {};
};

0
src/middleware/index.ts Normal file
View File

View File

@@ -0,0 +1,70 @@
import { MiddlewareDefinition } from '~/middleware/types';
import { OrPromise } from '~/types';
type Authentication = (username: string, password?: string, account?: string) => OrPromise<boolean>;
interface LoginMiddlewareConfig {
requirePassword: boolean;
requireAccount: boolean;
}
export const createLoginMiddleware = (authenticate: Authentication, config: LoginMiddlewareConfig): MiddlewareDefinition => (client) => ({
async USER(context) {
if (!config.requirePassword) {
if (config.requireAccount) {
client.send(332, 'Need account for login.');
return;
}
const valid = await authenticate(context.username);
if (!valid) {
client.send(530, 'Invalid credentials');
return;
}
client.context.set('username', context.username);
client.context.set('authenticated', true);
client.send(230, 'User logged in, proceed.');
return;
}
client.context.set('username', context.username);
client.send(331, 'User name okay, need password.');
},
async PASS(context) {
if (config.requireAccount) {
client.context.set('password', context.password);
client.send(332, 'Need account for login.');
return;
}
const valid = await authenticate(client.context.get('username'), context.password);
if (!valid) {
client.context.delete('username');
client.send(530, 'Invalid credentials');
return;
}
client.context.set('password', context.password);
client.context.set('authenticated', true);
client.send(230, 'User logged in, proceed.');
return;
},
async ACCT(context) {
const valid = await authenticate(
client.context.get('username'),
client.context.get('password'),
context.account
);
if (!valid) {
client.context.delete('username');
client.context.delete('password');
client.send(530, 'Invalid credentials');
return;
}
client.context.set('account', context.account);
client.context.set('authenticated', true);
client.send(230, 'User logged in, proceed.');
}
});

9
src/middleware/types.ts Normal file
View File

@@ -0,0 +1,9 @@
import {CommandConnection} from "~/connection/command";
import { CommandDirective, CommandContext } from "~/command/types";
import { OrPromise } from "~/types";
export type MiddlewareCommandHandler<T extends CommandDirective> = (context: CommandContext[T]) => OrPromise<void>;
export type MiddlewareDefinition = (connection: CommandConnection) => {
[T in CommandDirective]?: MiddlewareCommandHandler<T>;
}

View File

@@ -1,75 +0,0 @@
const NEW_LINE = '\r\n';
export type ReplyCode = 100
| 110
| 120
| 125
| 150
| 200
| 202
| 211
| 212
| 213
| 214
| 215
| 220
| 221
| 225
| 226
| 227
| 230
| 232
| 234
| 250
| 257
| 331
| 332
| 350
| 421
| 425
| 426
| 450
| 451
| 452
| 500
| 501
| 502
| 503
| 504
| 530
| 532
| 533
| 536
| 550
| 551
| 552
| 553;
export function formatReply(code: ReplyCode, ...lines: string[]) {
if (lines.length === 0) {
return `${code}${NEW_LINE}`;
}
if (lines.length === 1) {
return `${code} ${lines[0]}${NEW_LINE}`;
}
const [firstLine, ...remainingLines] = lines;
const lastLine = remainingLines.pop();
const startsWithNumbers = /^\d+/;
const formattedLines = remainingLines.map((line) => {
if (startsWithNumbers.test(line)) {
return `\t${line}`
} else {
return line;
}
});
const reply = [
`${code}-${firstLine}`,
...formattedLines,
`${code} ${lastLine}`
];
return `${reply.join(NEW_LINE)}${NEW_LINE}`;
}

32
src/reply/index.ts Normal file
View File

@@ -0,0 +1,32 @@
const STARTS_WITH_THREE_DIGITS = /^\d{3}/;
const CR = '\r';
const LF = '\n';
const EOL = CR + LF;
export function formatReply(code: number, lines: string[]): string {
if (lines.length <= 1) {
return [code, ...lines].join(' ') + EOL;
}
const formattedLines = lines.reduce((message, line, i) => {
const seperator = lines.length -1 === i ? ' ' : '-';
let prefix = '';
if (i === 0) {
prefix = `${code}${seperator}`;
} else if (i === lines.length -1) {
prefix = `${code} `
} else if (STARTS_WITH_THREE_DIGITS.test(line)) {
prefix = ` `;
}
return [
...message,
`${prefix}${line}`
];
}, []);
const message = formattedLines.join(EOL);
return message + EOL;
}

71
src/reply/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface Reply {
code: number;
message?: string;
}
/*
110 Restart marker reply.
In this case, the text is exact and not left to the
particular implementation; it must read:
MARK yyyy = mmmm
Where yyyy is User-process data stream marker, and mmmm
server's equivalent marker (note the spaces between markers
and "=").
120 Service ready in nnn minutes.
125 Data connection already open; transfer starting.
150 File status okay; about to open data connection.
200 Command okay.
202 Command not implemented, superfluous at this site.
211 System status, or system help reply.
212 Directory status.
213 File status.
214 Help message.
On how to use the server or the meaning of a particular
non-standard command. This reply is useful only to the
human user.
215 NAME system type.
Where NAME is an official system name from the list in the
Assigned Numbers document.
220 Service ready for new user.
221 Service closing control connection.
Logged out if appropriate.
225 Data connection open; no transfer in progress.
226 Closing data connection.
Requested file action successful (for example, file
transfer or file abort).
227 Entering Passive Mode (h1,h2,h3,h4,p1,p2).
230 User logged in, proceed.
250 Requested file action okay, completed.
257 "PATHNAME" created.
331 User name okay, need password.
332 Need account for login.
350 Requested file action pending further information.
421 Service not available, closing control connection.
This may be a reply to any command if the service knows it
must shut down.
425 Can't open data connection.
426 Connection closed; transfer aborted.
450 Requested file action not taken.
File unavailable (e.g., file busy).
451 Requested action aborted: local error in processing.
452 Requested action not taken.
Insufficient storage space in system.
500 Syntax error, command unrecognized.
This may include errors such as command line too long.
501 Syntax error in parameters or arguments.
502 Command not implemented.
503 Bad sequence of commands.
504 Command not implemented for that parameter.
530 Not logged in.
532 Need account for storing files.
550 Requested action not taken.
File unavailable (e.g., file not found, no access).
551 Requested action aborted: page type unknown.
552 Requested file action aborted.
Exceeded storage allocation (for current directory or
dataset).
553 Requested action not taken.
File name not allowed.
*/

View File

@@ -1,204 +1,30 @@
import { SecureContextOptions, createServer as createSecureServer } from 'tls';
import { Server, isIP, createServer, Socket } from 'net';
import {createServer as createInsecureServer} from 'net';
import {createServer as createSecureServer, TlsOptions} from 'tls';
import Connection from './commandSocket';
import { Command, parseCommandBuffer, CommandRegistration, CommandReply, CommandHandler } from './command';
import { stru } from './command/stru';
import { user, pass, acct } from './command/authentication';
import { NodeFileSystem } from './filesystem';
interface FTPServerConfig {
hostname: string;
port: number;
export type ServerSecureOptions = {
secure?: boolean;
tls?: TlsOptions;
}
interface PassiveConfig {
hostname: string;
port_min: number;
port_max: number;
export type ServerOptions = {
port: number;
hostname?: string;
} & ServerSecureOptions
export function createServer(config: ServerSecureOptions) {
const serverOptions = {
allowHalfOpen: false,
pauseOnConnect: true
};
if (config.secure === true && config.tls) {
return createSecureServer({
...serverOptions,
...config.tls
});
} else {
return createInsecureServer({
...serverOptions
});
}
}
type EncryptionType = 'IMPLICIT'
| 'EXPLICIT';
interface EncryptionConfig {
type: EncryptionType;
context: SecureContextOptions;
}
export interface CommandPlugin {
command: string;
handler: CommandHandler;
at?: 'before' | 'after';
}
export default class FTPServer {
private instance?: Server;
private config: FTPServerConfig;
private passive?: PassiveConfig;
private encryption?: EncryptionConfig;
private fs?: NodeFileSystem;
private commandHandlers: Map<string, CommandRegistration>;
private plugins: Set<CommandPlugin>;
constructor(config: Partial<FTPServerConfig> = {}) {
this.config = {
hostname: 'localhost',
port: 21,
...config
};
this.plugins = new Set();
this.commandHandlers = new Map([
['USER', user],
['PASS', pass],
['ACCT', acct],
['STRU', stru]
]);
}
public configureEncryption(type: EncryptionType, context: SecureContextOptions) {
this.encryption = {
type,
context
};
return this;
}
public configurePassive(config: PassiveConfig) {
this.passive = {
port_min: 49152,
port_max: 65535,
...config
};
if (isIP(this.passive.hostname) === 0) {
// TODO: resolve url into ip using dns
throw new Error('Passive hostname must be a valid IP address');
}
return this;
}
public configureFileSystem(fs: NodeFileSystem) {
this.fs = fs;
return this;
}
public registerPlugin(plugin: CommandPlugin) {
if (!plugin.at) plugin.at = 'after';
this.plugins.add(plugin);
}
/**
* @returns `230` Login complete
* @returns `331` Username okay, awaiting password
* @returns `530` Login failed
*/
public registerCommand(identifier: 'USER', registration: Partial<CommandRegistration>): this;
public registerCommand(identifier: string, registration: Partial<CommandRegistration>) {
identifier = identifier.toLocaleUpperCase();
const existingHandler = this.commandHandlers.get(identifier);
if (existingHandler) {
registration = {
...existingHandler,
...registration
};
}
if (!registration.handler) {
throw new Error('Cannot register a command without a handler');
}
if (!registration.description) {
throw new Error('Cannot register a command without a description');
}
this.commandHandlers.set(identifier, registration as CommandRegistration);
return this;
}
public listen() {
const connectionHandler = async (socket: Socket) => {
const connection = new Connection(socket);
socket.on('data', async (data) => {
let command: Command;
try {
command = parseCommandBuffer(data);
} catch {
await connection.sendReply(500);
await connection.close();
return;
}
const registration = this.commandHandlers.get(command.identifier);
if (!registration) {
await connection.sendReply(502);
return;
}
if (registration.arguments && registration.arguments.length > 0) {
if (!command.argument) {
await connection.sendReply(501);
return;
}
}
const plugins = [...this.plugins].filter((plug) => {
return plug.command === command.identifier;
});
const beforePlugins = plugins.filter((plug) => plug.at === 'before');
const afterPlugins = plugins.filter((plug) => plug.at === 'after');
try {
// Cache this?
const handles = [
...beforePlugins.map((plug) => plug.handler),
registration.handler,
...afterPlugins.map((plug) => plug.handler)
];
const reply = (() => {
let value: CommandReply = [500];
return {
get: () => [...value] as CommandReply,
set: (v: CommandReply) => {
value = [...v] as CommandReply;
}
}
})();
for (const handle of handles) {
await handle({server: this, connection, command, reply});
}
const [replyCode, ...lines] = reply.get();
await connection.sendReply(replyCode, ...lines);
} catch {
await connection.sendReply(500);
}
});
socket.resume();
await connection.sendReply(200);
}
const serverOptions = {pauseOnConnect: true};
if (this.encryption && this.encryption.type === 'IMPLICIT') {
this.instance = createSecureServer({...serverOptions, ...this.encryption.context}, connectionHandler);
} else {
this.instance = createServer(serverOptions, connectionHandler);
}
this.instance.once('listening', () => {});
this.instance.once('close', () => {});
this.instance.on('error', (err: Error) => {});
this.instance.listen(this.config.port, this.config.hostname);
}
public async close() {
}
}

10
src/types.ts Normal file
View File

@@ -0,0 +1,10 @@
export type OrPromise<T> = T | Promise<T>;
export interface RecordMap<Props extends Record<string, any>> extends Map<keyof Props, Props[keyof Props]> {
delete<K extends keyof Props>(key: K): boolean;
forEach<K extends keyof Props, V extends Props[K]>(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
get<K extends keyof Props>(key : K) : Props[K];
has<K extends keyof Props>(key: K): boolean;
set<K extends keyof Props, V extends Props[K]>(key: K, value: V): this;
}

View File

@@ -1,13 +1,23 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ES2015",
"noImplicitAny": true,
"moduleResolution": "node",
"declaration": true,
"strict": true,
"outDir": "dist"
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/@types/*"
],
"~/*": ["src/*"]
}
},
"include": [
"src/**/*.ts"
"src/**/*"
]
}
}