Compare commits

..

1 Commits

Author SHA1 Message Date
Tyler Stewart
5508c2346c fix: disallow PORT connections to alternate hosts
Ensure the data socket that the server connects to from the PORT command is the same IP as the current command socket.

* fix: add error handling to additional connection commands
2020-08-17 14:01:12 -06:00
167 changed files with 10239 additions and 12336 deletions

103
.circleci/config.yml Normal file
View File

@@ -0,0 +1,103 @@
version: 2
create-cache-file: &create-cache-file
run:
name: Setup cache
command: echo "$NODE_VERSION" > _cache_node_version
package-json-cache: &package-json-cache
key: npm-install-{{ checksum "_cache_node_version" }}-{{ checksum "package-lock.json" }}
base-build: &base-build
steps:
- checkout
- <<: *create-cache-file
- restore_cache:
<<: *package-json-cache
- run:
name: Install
command: npm install
- save_cache:
<<: *package-json-cache
paths:
- node_modules
- run:
name: Lint
command: npm run verify:js
- run:
name: Test
command: npm run test:once
jobs:
test_node_10:
docker:
- image: circleci/node:10
environment:
- NODE_VERSION: 10
<<: *base-build
test_node_8:
docker:
- image: circleci/node:8
environment:
- NODE_VERSION: 8
<<: *base-build
test_node_6:
docker:
- image: circleci/node:6
environment:
- NODE_VERSION: 6
<<: *base-build
release:
docker:
- image: circleci/node:8
environment:
- NODE_VERSION: 8
steps:
- checkout
- <<: *create-cache-file
- restore_cache:
<<: *package-json-cache
- deploy:
name: Semantic Release
command: |
npm run semantic-release
workflows:
version: 2
test_and_tag:
jobs:
- test_node_10:
filters:
branches:
only: master
- test_node_8:
filters:
branches:
only: master
- test_node_6:
filters:
branches:
only: master
- release:
requires:
- test_node_6
- test_node_8
- test_node_10
build_and_test:
jobs:
- test_node_10:
filters:
branches:
ignore: master
- test_node_8:
filters:
branches:
ignore: master
- test_node_6:
filters:
branches:
ignore: master

9
.eslintignore Normal file
View File

@@ -0,0 +1,9 @@
# START_CONFIT_GENERATED_CONTENT
# Common folders to ignore
node_modules/*
bower_components/*
# Config folder (optional - you might want to lint this...)
config/*
# END_CONFIT_GENERATED_CONTENT

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
node_modules/
dist/
reports/
npm-debug.log
.nyc_output/
test_tmp/

21
.vscode/launch.json vendored
View File

@@ -1,21 +0,0 @@
{
// 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
View File

@@ -1,12 +0,0 @@
{
// 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"
}
]
}

View File

@@ -16,10 +16,5 @@
- Any new fixes are features should include new or updated [tests](/test).
- Commits follow the [AngularJS Git Commit Message Conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit), please review and commit accordingly
- Submit your pull requests to the `master` branch, these will normally be merged into a separate branch for any finally changes before being merged into `master`.
- Submit any bugs or requests to the issues page in Github.
## Setup
- Clone the repository `git clone`
- Install dependencies `npm install`
- Submit your pull requests to the `master` branch, these will normally be merged into a seperate branch for any finally changes before being merged into `master`.
- Submit any bugs or requests to the issues page in Github.

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 Tyler Stewart
Copyright (c) 2018 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

378
README.md
View File

@@ -1,48 +1,352 @@
```ts
const loginMiddleware = () => (client) => {
let username;
let password;
<p align="center">
<a href="https://github.com/trs/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="600px" />
</a>
</p>
return {
USER(client, command) => {
username = command.arg;
},
PASS(client, command) => {
password = command.arg;
}
};
}
const fileSystemMiddleware = () => (client) => {
return {
CWD(client, command) => {},
CDUP(client) => {},
}
}
<p align="center">
Modern, extensible FTP Server
</p>
const transferMiddleware = () => (client) => {
<p align="center">
<a href="https://www.npmjs.com/package/ftp-srv">
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
</a>
}
<a href="https://circleci.com/gh/trs/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/trs/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
client.use(loginMiddleware());
---
- [Overview](#overview)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [CLI](#cli)
- [Events](#events)
- [Supported Commands](#supported-commands)
- [File System](#file-system)
- [Contributing](#contributing)
- [License](#license)
## Overview
`ftp-srv` is a modern and extensible FTP server designed to be simple yet configurable.
## Features
- Extensible [file systems](#file-system) per connection
- Passive and active transfers
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
- Promise based API
## Install
`npm install ftp-srv --save`
## Usage
```js
// Quick start
const FtpSrv = require('ftp-srv');
const ftpServer = new FtpSrv({ options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
5.1. MINIMUM IMPLEMENTATION
## API
In order to make FTP workable without needless error messages, the
following minimum implementation is required for all servers:
### `new FtpSrv({options})`
#### url
[URL string](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) indicating the protocol, hostname, and port to listen on for connections.
Supported protocols:
- `ftp` Plain FTP
- `ftps` Implicit FTP over TLS
TYPE - ASCII Non-print
MODE - Stream
STRUCTURE - File, Record
COMMANDS - USER, QUIT, PORT,
TYPE, MODE, STRU,
for the default values
RETR, STOR,
NOOP.
_Note:_ The hostname must be the external IP address to accept external connections. `0.0.0.0` will listen on any available hosts for server and passive connections.
__Default:__ `"ftp://127.0.0.1:21"`
The default values for transfer parameters are:
#### `pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`). This defaults to the provided `url` hostname.
TYPE - ASCII Non-print
MODE - Stream
STRU - File
_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`
Tne starting port to accept passive connections.
__Default:__ `1024`
#### `pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
#### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
__Default:__ `false`
#### `anonymous`
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
Can also set as a string which allows users to authenticate using the username provided.
The `login` event is then sent with the provided username and `@anonymous` as the password.
__Default:__ `false`
#### `blacklist`
Array of commands that are not allowed.
Response code `502` is sent to clients sending one of these commands.
__Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files.
__Default:__ `[]`
#### `whitelist`
Array of commands that are only allowed.
Response code `502` is sent to clients sending any other command.
__Default:__ `[]`
#### `file_format`
Sets the format to use for file stat queries such as `LIST`.
__Default:__ `"ls"`
__Allowable values:__
- `ls` [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
- `ep` [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
- `function () {}` A custom function returning a format or promise for one.
- Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter
#### `log`
A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default.
## CLI
`ftp-srv` also comes with a builtin CLI.
```bash
$ ftp-srv [url] [options]
```
```bash
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
```
#### `url`
Set the listening URL.
Defaults to `ftp://127.0.0.1:21`
#### `--root` / `-r`
Set the default root directory for users.
Defaults to the current directory.
#### `--credentials` / `-c`
Set the path to a json credentials file.
Format:
```js
[
{
"username": "...",
"password": "...",
"root": "..." // Root directory
},
...
]
```
#### `--username`
Set the username for the only user. Do not provide an argument to allow anonymous login.
#### `--password`
Set the password for the given `username`.
## Events
The `FtpSrv` class extends the [node net.Server](https://nodejs.org/api/net.html#net_class_net_server). Some custom events can be resolved or rejected, such as `login`.
### `login`
```js
ftpServer.on('login', ({connection, username, password}, resolve, reject) => { ... });
```
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
`connection` [client class object](src/connection.js)
`username` string of username from `USER` command
`password` string of password from `PASS` command
`resolve` takes an object of arguments:
- `fs`
- Set a custom file system class for this connection to use.
- See [File System](#file-system) for implementation details.
- `root`
- If `fs` is not provided, this will set the root directory for the connection.
- The user cannot traverse lower than this directory.
- `cwd`
- If `fs` is not provided, will set the starting directory for the connection
- This is relative to the `root` directory.
- `blacklist`
- Commands that are forbidden for only this connection
- `whitelist`
- If set, this connection will only be able to use the provided commands
`reject` takes an error object
### `client-error`
```js
ftpServer.on('client-error', ({connection, context, error}) => { ... });
```
Occurs when an error arises in the client connection.
`connection` [client class object](src/connection.js)
`context` string of where the error occurred
`error` error object
### `RETR`
```js
connection.on('RETR', (error, filePath) => { ... });
```
Occurs when a file is downloaded.
`error` if successful, will be `null`
`filePath` location to which file was downloaded
### `STOR`
```js
connection.on('STOR', (error, fileName) => { ... });
```
Occurs when a file is uploaded.
`error` if successful, will be `null`
`fileName` name of the file that was uploaded
### `RNTO`
```js
connection.on('RNTO', (error, fileName) => { ... });
```
Occurs when a file is renamed.
`error` if successful, will be `null`
`fileName` name of the file that was renamed
## Supported Commands
See the [command registry](src/commands/registration) for a list of all implemented FTP commands.
## File System
The default [file system](src/fs.js) can be overwritten to use your own implementation.
This can allow for virtual file systems, and more.
Each connection can set it's own file system based on the user.
The default file system is exported and can be extended as needed:
```js
const {FtpSrv, FileSystem} = require('ftp-srv');
class MyFileSystem extends FileSystem {
constructor() {
super(...arguments);
}
get(fileName) {
...
}
}
```
Custom file systems can implement the following variables depending on the developers needs:
### Methods
#### [`currentDirectory()`](src/fs.js#L40)
Returns a string of the current working directory
__Used in:__ `PWD`
#### [`get(fileName)`](src/fs.js#L44)
Returns a file stat object of file or directory
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
#### [`list(path)`](src/fs.js#L50)
Returns array of file and directory stat objects
__Used in:__ `LIST`, `NLST`, `STAT`
#### [`chdir(path)`](src/fs.js#L67)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
#### [`mkdir(path)`](src/fs.js#L114)
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append, start})`](src/fs.js#L79)
Returns a writable stream
Options:
`append` if true, append to existing file
`start` if set, specifies the byte offset to write to
__Used in:__ `STOR`, `APPE`
#### [`read(fileName, {start})`](src/fs.js#L90)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L105)
Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L120)
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L126)
Modifies a file or directory's permissions
__Used in:__ `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L131)
Returns a unique file name to write to
__Used in:__ `STOU`
<!--[RM_CONTRIBUTING]-->
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
<!--[]-->
## Contributors
- [OzairP](https://github.com/OzairP)
- [qchar](https://github.com/qchar)
- [jorinvo](https://github.com/jorinvo)
- [voxsoftware](https://github.com/voxsoftware)
- [pkeuter](https://github.com/pkeuter)
- [TimLuq](https://github.com/TimLuq)
- [edin-mg](https://github.com/edin-m)
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
- [Johnnyrook777](https://github.com/Johnnyrook777)
<!--[RM_LICENSE]-->
## License
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
<!--[]-->
## References
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)

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

@@ -113,15 +113,11 @@ function setupState(_args) {
}
function startFtpServer(_state) {
// Remove null/undefined options so they get set to defaults, below
for (const key in _state) {
if (_state[key] === undefined) delete _state[key];
}
function checkLogin(data, resolve, reject) {
const user = _state.credentials[data.username];
if (_state.anonymous || user && user.password === data.password) {
return resolve({root: user && user.root || _state.root});
const user = _state.credentials[data.username]
if (_state.anonymous || (user && user.password === data.password)) {
return resolve({root: (user && user.root) || _state.root});
}
return reject(new errors.GeneralError('Invalid username or password', 401));

View File

@@ -0,0 +1,38 @@
'use strict';
module.exports = {
types: [
{value: 'feat', name: 'feat: A new feature'},
{value: 'fix', name: 'fix: A bug fix'},
{value: 'docs', name: 'docs: Documentation only changes'},
{value: 'style', name: 'style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)'},
{value: 'refactor', name: 'refactor: A code change that neither fixes a bug nor adds a feature'},
{value: 'perf', name: 'perf: A code change that improves performance'},
{value: 'test', name: 'test: Adding missing tests'},
{value: 'chore', name: 'chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation'},
{value: 'revert', name: 'revert: Revert to a commit'},
{value: 'WIP', name: 'WIP: Work in progress'}
],
scopes: [],
// it needs to match the value for field type. Eg.: 'fix'
/*
scopeOverrides: {
fix: [
{name: 'merge'},
{name: 'style'},
{name: 'e2eTest'},
{name: 'unitTest'}
]
},
*/
allowCustomScopes: true,
allowBreakingChanges: ['feat', 'fix'],
// Appends the branch name to the footer of the commit. Useful for tracking commits after branches have been merged
appendBranchNameToCommitMessage: false
};

View File

@@ -0,0 +1,5 @@
test/**/*.spec.js
--reporter mocha-multi-reporters
--reporter-options configFile=config/testUnit/reporters.json
--ui bdd
--bail

View File

@@ -0,0 +1,6 @@
{
"reporterEnabled": "spec",
"mochaJunitReporterReporterOptions": {
"mochaFile": "reports/junit.xml"
}
}

162
config/verify/.eslintrc Normal file
View File

@@ -0,0 +1,162 @@
{
"extends": "eslint:recommended",
"env": {
"node": true,
"mocha": true,
"es6": true
},
"plugins": [
"mocha",
"node"
],
"rules": {
"mocha/no-exclusive-tests": 2,
"no-warning-comments": [
1,
{
"terms": ["todo", "fixme", "xxx"],
"location": "start"
},
],
"object-curly-spacing": [
2,
"never"
],
"array-bracket-spacing": [
2,
"never"
],
"brace-style": [
2,
"1tbs"
],
"consistent-return": 0,
"indent": [
"error",
2,
{
"SwitchCase": 1,
"MemberExpression": "off"
}
],
"no-multiple-empty-lines": [
2,
{
"max": 2
}
],
"no-use-before-define": [
2,
"nofunc"
],
"one-var": [
2,
"never"
],
"quote-props": [
2,
"as-needed"
],
"quotes": [
2,
"single"
],
"keyword-spacing": 2,
"space-before-function-paren": [
2,
{
"anonymous": "always",
"named": "never"
}
],
"space-in-parens": [
2,
"never"
],
"strict": [
2,
"global"
],
"curly": [
2,
"multi-line"
],
"eol-last": 2,
"key-spacing": [
2,
{
"beforeColon": false,
"afterColon": true
}
],
"no-eval": 2,
"no-with": 2,
"space-infix-ops": 2,
"dot-notation": [
2,
{
"allowKeywords": true
}
],
"eqeqeq": 2,
"no-alert": 2,
"no-caller": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-implied-eval": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-loop-func": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-native-reassign": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-octal-escape": 2,
"no-proto": 2,
"no-return-assign": 2,
"no-script-url": 2,
"no-sequences": 2,
"no-unused-expressions": 2,
"yoda": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-undef-init": 2,
"no-console": 1,
"camelcase": [
0,
{
"properties": "never"
}
],
"comma-spacing": 2,
"comma-dangle": 1,
"new-cap": 2,
"new-parens": 2,
"arrow-parens": [2, "always"],
"no-array-constructor": 2,
"array-callback-return": 1,
"no-extra-parens": 2,
"no-new-object": 2,
"no-spaced-func": 2,
"no-trailing-spaces": 2,
"no-underscore-dangle": 0,
"no-fallthrough": 0,
"semi": 2,
"semi-spacing": [
2,
{
"before": false,
"after": true
}
]
},
"parserOptions": {
"emcaVersion": 6,
"sourceType": "module",
"impliedStrict": true
}
}

View File

@@ -69,8 +69,7 @@ export interface FtpServerOptions {
blacklist?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log?: any,
timeout?: number
log?: any,
}
export class FtpServer extends EventEmitter {
@@ -112,13 +111,6 @@ export class FtpServer extends EventEmitter {
whitelist?: Array<string>
}) => void,
reject: (err?: Error) => void
) => void): this;
on(event: "disconnect", listener: (
data: {
connection: FtpConnection,
id: string
}
) => void): this;
on(event: "client-error", listener: (

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,97 +0,0 @@
version: 2
jobs:
build:
docker:
- image: &node-image circleci/node:lts
steps:
- checkout
- restore_cache:
keys:
- &npm-cache-key npm-cache-{{ .Branch }}-{{ .Revision }}
- npm-cache-{{ .Branch }}
- npm-cache
- run:
name: Install
command: npm ci
- persist_to_workspace:
root: .
paths:
- node_modules
- save_cache:
key: *npm-cache-key
paths:
- ~/.npm/_cacache
lint:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Lint
command: npm run verify
test:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Test
command: |
npm run test
release_dry_run:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Dry Release
command: |
npm run semantic-release -- --dry-run
release:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Release
command: |
npm run semantic-release
workflows:
version: 2
publish:
jobs:
- build
- lint:
requires:
- build
- test:
requires:
- build
- release_dry_run:
filters:
branches:
only: master
requires:
- test
- lint
- hold_release:
type: approval
requires:
- release_dry_run
- release:
requires:
- hold_release

View File

@@ -1,2 +0,0 @@
node_modules/*
bower_components/*

6
old/.gitignore vendored
View File

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

View File

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

View File

@@ -1,357 +0,0 @@
<p align="center">
<a href="https://github.com/trs/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="600px" />
</a>
</p>
<p align="center">
Modern, extensible FTP Server
</p>
<p align="center">
<a href="https://www.npmjs.com/package/ftp-srv">
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
</a>
<a href="https://circleci.com/gh/trs/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/trs/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
---
- [Overview](#overview)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [CLI](#cli)
- [Events](#events)
- [Supported Commands](#supported-commands)
- [File System](#file-system)
- [Contributing](#contributing)
- [License](#license)
## Overview
`ftp-srv` is a modern and extensible FTP server designed to be simple yet configurable.
## Features
- Extensible [file systems](#file-system) per connection
- Passive and active transfers
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
- Promise based API
## Install
`npm install ftp-srv --save`
## Usage
```js
// Quick start
const FtpSrv = require('ftp-srv');
const ftpServer = new FtpSrv({ options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
## API
### `new FtpSrv({options})`
#### url
[URL string](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) indicating the protocol, hostname, and port to listen on for connections.
Supported protocols:
- `ftp` Plain FTP
- `ftps` Implicit FTP over TLS
_Note:_ The hostname must be the external IP address to accept external connections. `0.0.0.0` will listen on any available hosts for server and passive connections.
__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.
__Default:__ `"127.0.0.1"`
#### `pasv_min`
Tne starting port to accept passive connections.
__Default:__ `1024`
#### `pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
#### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
__Default:__ `false`
#### `anonymous`
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
Can also set as a string which allows users to authenticate using the username provided.
The `login` event is then sent with the provided username and `@anonymous` as the password.
__Default:__ `false`
#### `blacklist`
Array of commands that are not allowed.
Response code `502` is sent to clients sending one of these commands.
__Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files.
__Default:__ `[]`
#### `whitelist`
Array of commands that are only allowed.
Response code `502` is sent to clients sending any other command.
__Default:__ `[]`
#### `file_format`
Sets the format to use for file stat queries such as `LIST`.
__Default:__ `"ls"`
__Allowable values:__
- `ls` [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
- `ep` [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
- `function () {}` A custom function returning a format or promise for one.
- Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter
#### `log`
A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default.
#### `timeout`
Sets the timeout (in ms) after that an idle connection is closed by the server
__Default:__ `0`
## CLI
`ftp-srv` also comes with a builtin CLI.
```bash
$ ftp-srv [url] [options]
```
```bash
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
```
#### `url`
Set the listening URL.
Defaults to `ftp://127.0.0.1:21`
#### `--root` / `-r`
Set the default root directory for users.
Defaults to the current directory.
#### `--credentials` / `-c`
Set the path to a json credentials file.
Format:
```js
[
{
"username": "...",
"password": "...",
"root": "..." // Root directory
},
...
]
```
#### `--username`
Set the username for the only user. Do not provide an argument to allow anonymous login.
#### `--password`
Set the password for the given `username`.
## Events
The `FtpSrv` class extends the [node net.Server](https://nodejs.org/api/net.html#net_class_net_server). Some custom events can be resolved or rejected, such as `login`.
### `login`
```js
ftpServer.on('login', ({connection, username, password}, resolve, reject) => { ... });
```
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
`connection` [client class object](src/connection.js)
`username` string of username from `USER` command
`password` string of password from `PASS` command
`resolve` takes an object of arguments:
- `fs`
- Set a custom file system class for this connection to use.
- See [File System](#file-system) for implementation details.
- `root`
- If `fs` is not provided, this will set the root directory for the connection.
- The user cannot traverse lower than this directory.
- `cwd`
- If `fs` is not provided, will set the starting directory for the connection
- This is relative to the `root` directory.
- `blacklist`
- Commands that are forbidden for only this connection
- `whitelist`
- If set, this connection will only be able to use the provided commands
`reject` takes an error object
### `client-error`
```js
ftpServer.on('client-error', ({connection, context, error}) => { ... });
```
Occurs when an error arises in the client connection.
`connection` [client class object](src/connection.js)
`context` string of where the error occurred
`error` error object
### `RETR`
```js
connection.on('RETR', (error, filePath) => { ... });
```
Occurs when a file is downloaded.
`error` if successful, will be `null`
`filePath` location to which file was downloaded
### `STOR`
```js
connection.on('STOR', (error, fileName) => { ... });
```
Occurs when a file is uploaded.
`error` if successful, will be `null`
`fileName` name of the file that was uploaded
### `RNTO`
```js
connection.on('RNTO', (error, fileName) => { ... });
```
Occurs when a file is renamed.
`error` if successful, will be `null`
`fileName` name of the file that was renamed
## Supported Commands
See the [command registry](src/commands/registration) for a list of all implemented FTP commands.
## File System
The default [file system](src/fs.js) can be overwritten to use your own implementation.
This can allow for virtual file systems, and more.
Each connection can set it's own file system based on the user.
The default file system is exported and can be extended as needed:
```js
const {FtpSrv, FileSystem} = require('ftp-srv');
class MyFileSystem extends FileSystem {
constructor() {
super(...arguments);
}
get(fileName) {
...
}
}
```
Custom file systems can implement the following variables depending on the developers needs:
### Methods
#### [`currentDirectory()`](src/fs.js#L40)
Returns a string of the current working directory
__Used in:__ `PWD`
#### [`get(fileName)`](src/fs.js#L44)
Returns a file stat object of file or directory
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
#### [`list(path)`](src/fs.js#L50)
Returns array of file and directory stat objects
__Used in:__ `LIST`, `NLST`, `STAT`
#### [`chdir(path)`](src/fs.js#L67)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
#### [`mkdir(path)`](src/fs.js#L114)
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append, start})`](src/fs.js#L79)
Returns a writable stream
Options:
`append` if true, append to existing file
`start` if set, specifies the byte offset to write to
__Used in:__ `STOR`, `APPE`
#### [`read(fileName, {start})`](src/fs.js#L90)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L105)
Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L120)
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L126)
Modifies a file or directory's permissions
__Used in:__ `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L131)
Returns a unique file name to write to
__Used in:__ `STOU`
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Contributors
- [OzairP](https://github.com/OzairP)
- [TimLuq](https://github.com/TimLuq)
- [crabl](https://github.com/crabl)
- [hirviid](https://github.com/hirviid)
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
- [edin-m](https://github.com/edin-m)
- [voxsoftware](https://github.com/voxsoftware)
- [jorinvo](https://github.com/jorinvo)
- [Johnnyrook777](https://github.com/Johnnyrook777)
- [qchar](https://github.com/qchar)
- [mikejestes](https://github.com/mikejestes)
- [pkeuter](https://github.com/pkeuter)
- [qiansc](https://github.com/qiansc)
- [broofa](https://github.com/broofa)
- [lafin](https://github.com/lafin)
- [alancnet](https://github.com/alancnet)
- [zgwit](https://github.com/zgwit)
## License
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
## References
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)

View File

@@ -1,27 +0,0 @@
const {get} = require('https');
get('https://api.github.com/repos/trs/ftp-srv/contributors', {
headers: {
'User-Agent': 'Chrome'
}
}, (res) => {
let response = '';
res.on('data', (data) => {
response += data;
});
res.on('end', () => {
const contributors = JSON.parse(response)
.filter((contributor) => contributor.type === 'User');
for (const contributor of contributors) {
const url = contributor.html_url;
const username = contributor.login;
const markdown = `- [${username}](${url})\n`;
process.stdout.write(markdown);
}
});
}).on('error', (err) => {
process.stderr.write(err);
});

10366
old/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +0,0 @@
{
"name": "ftp-srv",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"keywords": [
"ftp",
"ftp-server",
"ftp-srv",
"ftp-svr",
"ftpd",
"ftpserver",
"server"
],
"license": "MIT",
"files": [
"src",
"bin",
"ftp-srv.d.ts"
],
"main": "ftp-srv.js",
"bin": "./bin/index.js",
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/trs/ftp-srv"
},
"scripts": {
"pre-release": "npm run verify",
"semantic-release": "semantic-release",
"test": "mocha test/**/*.spec.js test/*.spec.js --ui bdd",
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
},
"release": {
"verifyConditions": "condition-circle"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"eslintConfig": {
"extends": "eslint:recommended",
"env": {
"node": true,
"mocha": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
}
},
"dependencies": {
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"ip": "^1.1.5",
"lodash": "^4.17.15",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@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",
"eslint": "^5.14.1",
"husky": "^1.3.1",
"lint-staged": "^8.1.4",
"mocha": "^5.2.0",
"rimraf": "^2.6.1",
"semantic-release": "^15.13.24",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x"
}
}

View File

@@ -1,61 +0,0 @@
const net = require('net');
const errors = require('../errors');
const MAX_PORT = 65535;
const MAX_PORT_CHECK_ATTEMPT = 5;
function* portNumberGenerator(min, max = MAX_PORT) {
let current = min;
while (true) {
if (current > MAX_PORT || current > max) {
current = min;
}
yield current++;
}
}
function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK_ATTEMPT) {
const nextPortNumber = portNumberGenerator(portMin, portMax);
return () => new Promise((resolve, reject) => {
const portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
let attemptCount = 0;
const tryGetPort = () => {
attemptCount++;
if (attemptCount > maxAttempts) {
reject(new errors.ConnectorError('Unable to find valid port'));
return;
}
const {value: port} = nextPortNumber.next();
portCheckServer.removeAllListeners();
portCheckServer.once('error', (err) => {
if (['EADDRINUSE'].includes(err.code)) {
tryGetPort();
} else {
reject(err);
}
});
portCheckServer.once('listening', () => {
portCheckServer.removeAllListeners();
portCheckServer.close(() => resolve(port));
});
try {
portCheckServer.listen(port, host);
} catch (err) {
reject(err);
}
};
tryGetPort();
});
}
module.exports = {
getNextPortFactory,
portNumberGenerator
};

View File

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

View File

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

View File

@@ -1,55 +0,0 @@
/* eslint no-unused-expressions: 0 */
const {expect} = require('chai');
const net = require('net');
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('keeps trying new ports', () => {
let getNextPort;
let serverAlreadyRunning;
beforeEach((done) => {
getNextPort = getNextPortFactory('::', 8821);
serverAlreadyRunning = net.createServer();
serverAlreadyRunning.listen(8821, () => done());
});
afterEach((done) => {
serverAlreadyRunning.close(() => done());
});
it('test', () => {
return getNextPort()
.then((port) => {
expect(port).to.equal(8822);
});
});
});
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));
});
});
});

9243
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ftp-srv",
"version": "0.0.0",
"version": "0.0.0-development",
"description": "Modern, extensible FTP Server",
"keywords": [
"ftp",
@@ -13,24 +13,74 @@
],
"license": "MIT",
"files": [
"dist"
"src",
"bin",
"ftp-srv.d.ts"
],
"main": "dist/index",
"main": "ftp-srv.js",
"bin": "./bin/index.js",
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/trs/ftp-srv"
},
"scripts": {
"build": "ncc build src/example.ts -o dist -s",
"dev": "ncc run src/example.ts"
"pre-release": "npm run verify",
"commitmsg": "cz-customizable-ghooks",
"dev": "cross-env NODE_ENV=development npm run verify:watch",
"prepush": "npm run verify && npm run test:once --silent",
"semantic-release": "semantic-release",
"start": "npm run dev",
"test": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts -w",
"test:once": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts",
"verify": "npm run verify:js --silent",
"verify:js": "eslint -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js success",
"verify:js:fix": "eslint --fix -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js:fix success"
},
"devDependencies": {
"@types/node": "^13.13.2",
"@zeit/ncc": "^0.22.2",
"typescript": "^3.9.3"
"release": {
"verifyConditions": "condition-circle"
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
},
"cz-customizable": {
"config": "config/release/commitMessageConfig.js"
}
},
"dependencies": {
"ix": "^3.0.2",
"rxjs": "^6.5.5"
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"ip": "^1.1.5",
"lodash": "^4.17.10",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@icetee/ftp": "^1.0.2",
"chai": "^4.0.2",
"condition-circle": "^1.6.0",
"cross-env": "3.1.4",
"cz-customizable": "5.2.0",
"cz-customizable-ghooks": "1.5.0",
"dotenv": "^4.0.0",
"eslint": "4.5.0",
"eslint-config-google": "0.8.0",
"eslint-friendly-formatter": "3.0.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-node": "5.1.1",
"husky": "0.13.3",
"istanbul": "0.4.5",
"mocha": "^5.2.0",
"mocha-junit-reporter": "1.13.0",
"mocha-multi-reporters": "1.1.5",
"rimraf": "2.6.1",
"semantic-release": "^15.10.6",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x",
"npm": ">=5.x"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import { Command, CommandDirective } from './types';
import definitions from './definitions';
import { UnsupportedCommandError } from "~/error";
import { CommandConnection } from "~/connection/command";
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 const getCommandContext = (client: CommandConnection, command: Command) => {
const createDefinition = definitions.get(command.directive);
if (!createDefinition) {
throw new UnsupportedCommandError(command.directive);
}
const definition = createDefinition(client);
const context = 'setup' in definition ? definition.setup(command) : undefined;
return context;
}

View File

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

View File

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

View File

@@ -8,14 +8,18 @@ const FAMILY = {
module.exports = {
directive: 'EPRT',
handler: function ({command} = {}) {
handler: function ({log, command} = {}) {
const [, protocol, ip, port] = _.chain(command).get('arg', '').split('|').value();
const family = FAMILY[protocol];
if (!family) return this.reply(504, 'Unknown network protocol');
this.connector = new ActiveConnector(this);
return this.connector.setupConnection(ip, port, family)
.then(() => this.reply(200));
.then(() => this.reply(200))
.catch((err) => {
log.error(err);
return this.reply(err.code || 425, err.message);
});
},
syntax: '{{cmd}} |<protocol>|<address>|<port>|',
description: 'Specifies an address and port to which the server should connect'

View File

@@ -2,13 +2,17 @@ const PassiveConnector = require('../../connector/passive');
module.exports = {
directive: 'EPSV',
handler: function () {
handler: function ({log}) {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
const {port} = server.address();
return this.reply(229, `EPSV OK (|||${port}|)`);
})
.catch((err) => {
log.error(err);
return this.reply(err.code || 425, err.message);
});
},
syntax: '{{cmd}} [<protocol>]',

View File

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

View File

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

View File

@@ -1,21 +1,12 @@
const PassiveConnector = require('../../connector/passive');
const {isLocalIP} = require('../../helpers/is-local');
module.exports = {
directive: 'PASV',
handler: function ({log} = {}) {
if (!this.server.options.pasv_url) {
return this.reply(502);
}
handler: function () {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
let address = this.server.options.pasv_url;
// Allow connecting from local
if (isLocalIP(this.ip)) {
address = this.ip;
}
const address = this.server.options.pasv_url;
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;
@@ -25,7 +16,7 @@ module.exports = {
})
.catch((err) => {
log.error(err);
return this.reply(425);
return this.reply(err.code || 425, err.message);
});
},
syntax: '{{cmd}}',

View File

@@ -3,7 +3,7 @@ const ActiveConnector = require('../../connector/active');
module.exports = {
directive: 'PORT',
handler: function ({log, command} = {}) {
handler: function ({command} = {}) {
this.connector = new ActiveConnector(this);
const rawConnection = _.get(command, 'arg', '').split(',');
@@ -17,7 +17,7 @@ module.exports = {
.then(() => this.reply(200))
.catch((err) => {
log.error(err);
return this.reply(425);
return this.reply(err.code || 425, err.message);
});
},
syntax: '{{cmd}} <x>,<x>,<x>,<x>,<y>,<y>',

View File

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

View File

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

View File

@@ -30,8 +30,8 @@ class FtpConnection extends EventEmitter {
this.server.emit('client-error', {connection: this, context: 'commandSocket', error: err});
});
this.commandSocket.on('data', this._handleData.bind(this));
this.commandSocket.on('timeout', () => {
this.log.trace('Client timeout');
this.commandSocket.on('timeout', (err) => {
this.log.trace(err, 'Client timeout');
this.close().catch((e) => this.log.trace(e, 'Client close error'));
});
this.commandSocket.on('close', () => {
@@ -104,21 +104,15 @@ 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 (!options.useEmptyMessage) {
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
}
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) => {
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 = '';
}
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;
return letter;
});
});

View File

@@ -1,101 +0,0 @@
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();
};

View File

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

View File

@@ -1,7 +1,9 @@
const {Socket} = require('net');
const tls = require('tls');
const ip = require('ip');
const Promise = require('bluebird');
const Connector = require('./base');
const {SocketError} = require('../errors');
class Active extends Connector {
constructor(connection) {
@@ -27,7 +29,12 @@ class Active extends Connector {
return closeExistingServer()
.then(() => {
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, host)) {
throw new SocketError('The given address is not yours', 500);
}
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.connect({host, port, family}, () => {
this.dataSocket.pause();

View File

@@ -29,7 +29,7 @@ class Connector {
closeSocket() {
if (this.dataSocket) {
const socket = this.dataSocket;
this.dataSocket.end(() => socket.destroy());
this.dataSocket.end(() => socket && socket.destroy());
this.dataSocket = null;
}
}

View File

@@ -6,8 +6,6 @@ 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);
@@ -32,9 +30,6 @@ 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({
@@ -46,19 +41,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;
@@ -79,8 +74,6 @@ class Passive extends Connector {
this.dataServer.listen(port, this.server.url.hostname, (err) => {
if (err) reject(err);
else {
idleServerTimeout = setTimeout(() => this.closeServer(), CONNECT_TIMEOUT);
this.log.debug({port}, 'Passive connection listening');
resolve(this.dataServer);
}

View File

@@ -1,17 +0,0 @@
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);
}
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More