Compare commits
20 Commits
typescript
...
observable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30956e1d64 | ||
|
|
c59e191a39 | ||
|
|
b2b1b2a0d3 | ||
|
|
81fa7fcb89 | ||
|
|
a18841d770 | ||
|
|
0dbb7f9070 | ||
|
|
0b9167e1e4 | ||
|
|
484409d2eb | ||
|
|
5ffcef3312 | ||
|
|
290769a042 | ||
|
|
a1c7f2ffda | ||
|
|
7153ffab4d | ||
|
|
c0e132b70e | ||
|
|
e661bd10e2 | ||
|
|
bece42a0c9 | ||
|
|
b1fe56826c | ||
|
|
16dbc7895c | ||
|
|
94f0b893e4 | ||
|
|
79d7bd9062 | ||
|
|
44999c714d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,2 @@
|
||||
test_tmp/
|
||||
|
||||
node_modules/
|
||||
|
||||
dist/
|
||||
npm-debug.log
|
||||
|
||||
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal 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
12
.vscode/tasks.json
vendored
Normal 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
21
LICENSE
Normal 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.
|
||||
48
README.md
48
README.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
0
.gitattributes → old/.gitattributes
vendored
0
.gitattributes → old/.gitattributes
vendored
6
old/.gitignore
vendored
Normal file
6
old/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
test_tmp/
|
||||
|
||||
node_modules/
|
||||
|
||||
dist/
|
||||
npm-debug.log
|
||||
@@ -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
0
old/bin/index.js
Executable file → Normal file
109
old/ftp-srv.d.ts
vendored
109
old/ftp-srv.d.ts
vendored
@@ -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
7884
old/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
18
old/src/helpers/fs-async.js
Normal file
18
old/src/helpers/fs-async.js
Normal 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;
|
||||
}, {});
|
||||
3
old/src/helpers/is-local.js
Normal file
3
old/src/helpers/is-local.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports.isLocalIP = function(ip) {
|
||||
return ip === '127.0.0.1' || ip == '::1';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
9692
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -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
113
src/client.ts
Normal 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);
|
||||
// }
|
||||
@@ -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
|
||||
};
|
||||
37
src/command/definitions/ACCT.ts
Normal file
37
src/command/definitions/ACCT.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/command/definitions/PASS.ts
Normal file
34
src/command/definitions/PASS.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/command/definitions/PASV.ts
Normal file
10
src/command/definitions/PASV.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CommandDefinition } from '~/command/types';
|
||||
import { CommandError } from '~/error';
|
||||
|
||||
export const PASV: CommandDefinition<'PASV'> = () => {
|
||||
return {
|
||||
setup(command) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/command/definitions/PORT.ts
Normal file
22
src/command/definitions/PORT.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/command/definitions/QUIT.ts
Normal file
17
src/command/definitions/QUIT.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/command/definitions/REIN.ts
Normal file
17
src/command/definitions/REIN.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/command/definitions/SYST.ts
Normal file
13
src/command/definitions/SYST.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/command/definitions/USER.ts
Normal file
29
src/command/definitions/USER.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
};
|
||||
17
src/command/definitions/index.ts
Normal file
17
src/command/definitions/index.ts
Normal 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;
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
54
src/command/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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
101
src/connection/command.ts
Normal 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
60
src/connection/data.ts
Normal 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();
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
export default class DataSocket {
|
||||
|
||||
}
|
||||
17
src/error.ts
Normal file
17
src/error.ts
Normal 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
16
src/example.ts
Normal 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();
|
||||
@@ -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
42
src/index.ts
Normal 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);
|
||||
}
|
||||
17
src/middleware/filesystem/index.ts
Normal file
17
src/middleware/filesystem/index.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
12
src/middleware/greeting/index.ts
Normal file
12
src/middleware/greeting/index.ts
Normal 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
0
src/middleware/index.ts
Normal file
70
src/middleware/login/index.ts
Normal file
70
src/middleware/login/index.ts
Normal 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
9
src/middleware/types.ts
Normal 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>;
|
||||
}
|
||||
75
src/reply.ts
75
src/reply.ts
@@ -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
32
src/reply/index.ts
Normal 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
71
src/reply/types.ts
Normal 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.
|
||||
*/
|
||||
226
src/server.ts
226
src/server.ts
@@ -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
10
src/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user