Compare commits

..

85 Commits

Author SHA1 Message Date
Paul
e8efe32126 fix: add errors to typescript declarations (#337)
fixes #336
2023-10-03 09:33:45 -06:00
botovance
18277e93ea chore(templates): update pull request template 2022-06-28 09:26:33 -06:00
Amr Ibrahim
a8cdcb0eb3 fix: replace .finally with .then (#322) 2022-06-24 10:55:10 -06:00
simultsop
4ba583420a refactor: reject with Error object (#319)
* refactor: reject with Error object

* fix: use error constructor

Co-authored-by: Matt Forster <hey@mattforster.ca>
2022-06-24 10:47:18 -06:00
bartbutenaers
51049f631a New event emitters (connect, server-error, closing, closed) (#314) 2022-06-24 09:03:51 -04:00
Matt Forster
50e8c455d6 docs(readme): update prime login example (#326) 2022-06-23 16:09:38 -04:00
Matt Forster
1af62a7c4f fix: update engine (#298)
This will prevent users of node 10  and below from installing and using >= v4.6
2022-05-06 10:18:08 -06:00
botovance
0b65a22296 chore(codeowners): update CODEOWNERS 2022-04-27 19:25:08 -06:00
Botovance
f59857e34a chore(codeowners): update CODEOWNERS 2022-04-19 14:07:30 -06:00
Amr Ibrahim
1ad45fc757 fix pasv_url resolverFunction docs (#297) 2022-04-18 15:50:49 -06:00
Matt Forster
bc8abb14da feat: npm updates, security zero, test corrections (#296) 2022-04-18 15:50:14 -06:00
dependabot[bot]
0b55b3f79d chore(deps): bump ssri from 6.0.1 to 6.0.2 (#293)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-14 14:44:51 -06:00
dependabot[bot]
4f4a6c25a5 chore(deps): bump moment from 2.22.2 to 2.29.2 (#288)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-14 14:44:41 -06:00
dependabot[bot]
45a4bf15bf chore(deps): bump trim-off-newlines from 1.0.1 to 1.0.3 (#289)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-14 14:44:32 -06:00
dependabot[bot]
a1be4416a7 chore(deps): bump node-fetch from 2.6.1 to 2.6.7 (#290)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-14 14:44:08 -06:00
Bas Broerse
32a0750e2c feat: allow mkd command to create directories recursively (#294)
Co-authored-by: Bas Broerse <b.broerse@sector-orange.com>
2022-04-14 14:43:33 -06:00
Matt Forster
4eb17015f1 chore: update CI context (#295) 2022-04-14 14:40:36 -06:00
dependabot[bot]
d2566e7745 chore(deps): bump npm-user-validate from 1.0.0 to 1.0.1 (#292)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-09 19:50:41 +00:00
dependabot[bot]
f8cd1e8f64 chore(deps): bump tar from 4.4.13 to 4.4.19 (#291)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-09 13:48:56 -06:00
dependabot[bot]
e0e676e7e9 chore(deps): bump pathval from 1.1.0 to 1.1.1 (#285)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-09 13:46:14 -06:00
dependabot[bot]
a7775a46ae chore(deps): bump minimist from 1.2.5 to 1.2.6 (#287)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-09 13:44:16 -06:00
Chen WeiJian
f9c81b162a Provide more friendly example code. (#282)
* Update README.md

* Update README.md
2021-12-06 09:32:59 -07:00
dependabot[bot]
e1f1aa09cd chore(deps): bump path-parse from 1.0.6 to 1.0.7 (#274)
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-04 14:50:26 -06:00
Victor
b0174bb24e chore: make fileName available in getUniqueName (#273) 2021-11-04 14:49:50 -06:00
Matt Forster
5852851ded Update README.md 2021-08-09 13:51:31 -06:00
Matt Forster
1c5db00a5e chore: update readme 2021-08-09 13:50:38 -06:00
Victor
02227d653e feat: allow dynamic pasv_url depending on remote address (#269)
Co-authored-by: Matt Forster <hey@mattforster.ca>
2021-08-09 13:49:34 -06:00
dependabot[bot]
bf44cbba58 chore(deps): bump glob-parent from 5.1.1 to 5.1.2 (#262)
Bumps [glob-parent](https://github.com/gulpjs/glob-parent) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/gulpjs/glob-parent/releases)
- [Changelog](https://github.com/gulpjs/glob-parent/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gulpjs/glob-parent/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: glob-parent
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-09 09:07:50 -06:00
dependabot[bot]
80ff71655e chore(deps): bump trim-newlines from 3.0.0 to 3.0.1 (#263)
Bumps [trim-newlines](https://github.com/sindresorhus/trim-newlines) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/sindresorhus/trim-newlines/releases)
- [Commits](https://github.com/sindresorhus/trim-newlines/commits)

---
updated-dependencies:
- dependency-name: trim-newlines
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-09 09:07:39 -06:00
dependabot[bot]
eb8e3d837f chore(deps): bump normalize-url from 5.3.0 to 5.3.1 (#265)
Bumps [normalize-url](https://github.com/sindresorhus/normalize-url) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/sindresorhus/normalize-url/releases)
- [Commits](https://github.com/sindresorhus/normalize-url/commits)

---
updated-dependencies:
- dependency-name: normalize-url
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-09 09:07:25 -06:00
dependabot[bot]
dc37c9c435 chore(deps): bump hosted-git-info from 2.7.1 to 2.8.9 (#256)
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.7.1 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.7.1...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-10 09:06:58 -06:00
dependabot[bot]
6a94a20f64 chore(deps): bump lodash from 4.17.19 to 4.17.21 (#255)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.19 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.19...4.17.21)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-06 10:47:57 -06:00
dependabot[bot]
02355eda28 chore(deps): bump handlebars from 4.7.6 to 4.7.7 (#254)
Bumps [handlebars](https://github.com/wycats/handlebars.js) from 4.7.6 to 4.7.7.
- [Release notes](https://github.com/wycats/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/master/release-notes.md)
- [Commits](https://github.com/wycats/handlebars.js/compare/v4.7.6...v4.7.7)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-06 10:43:03 -06:00
Andres Watson
8b32be7acc Update README.md (#253)
* Update README.md

Based on https://github.com/autovance/ftp-srv/discussions/250 I propose to explain that is not a HOST, must be an IP Address.

* Update README.md

* Update README.md

Co-authored-by: Matt Forster <hey@mattforster.ca>

Co-authored-by: Matt Forster <hey@mattforster.ca>
2021-05-05 09:44:58 -06:00
dependabot[bot]
5daaa9883c chore(deps): bump y18n from 4.0.0 to 4.0.1 (#247)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-29 10:28:31 -06:00
Connor Skees
02798e094f chore: small readme typo (#242) 2021-01-18 08:51:57 -07:00
Tyler Stewart
720c93d088 fix(feat): lazy require registry (#238)
* chore: give mocha explicit test paths

Mocha doesn't seem to find nested paths with **

* fix(feat): lazy require registry

If requiring the registry from the module scope the result is an empty object. I assume this is caused by the circular nature of this dep
2021-01-04 16:51:02 -07:00
Ricardas Jonaitis
bffcc35299 fix: dns lookup fail with IP with zeros padding (#237) 2021-01-04 08:34:40 -07:00
Matt Forster
78de22f518 chore: fix semantic release dry run 2020-12-16 15:36:11 -07:00
Matt Forster
2b140ecb0d chore: condition-circle using 'branch' 2020-12-16 15:27:40 -07:00
Matt Forster
57ddfb5e08 chore: set master as release branch 2020-12-16 15:22:56 -07:00
Matt Forster
beef19af30 chore: set release branch 2020-12-16 15:15:55 -07:00
Matt Forster
e7c5f83311 chore: setup release branch 2020-12-16 15:07:06 -07:00
Matt Forster
1ab793c04e chore: add install to release steps
requires some dev deps
2020-12-16 15:00:53 -07:00
Matt Forster
24f7126acf chore: improve ci flows (#227)
current release flow doesn't work
improve supported version testing
2020-12-16 14:58:20 -07:00
Matt Forster
457b859450 fix(fs): check resolved path against root (#224)
* fix(fs): check resolved path against root

This should prevent paths from being resolved above the root.

Should affect all commands that utilize the FS functions.

Fixes #167

* test: use __dirname for relative certs

* fix: improve path resolution

* chore: remove unused package

* fix: normalize resolve path if absolute

Otherwise join will normalize

Co-authored-by: Tyler Stewart <tyler@autovance.com>
2020-12-16 10:19:28 -07:00
Matt Forster
722da60a82 chore: set deploy context 2020-12-15 12:04:35 -07:00
dependabot[bot]
9f95d60916 chore(deps): bump ini from 1.3.5 to 1.3.7 (#223)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-10 10:46:24 -07:00
techmunk
4cd88b129c fix: ensure commandSocket is set before retrieving ip address of connection (#222) 2020-12-08 11:05:50 -07:00
dependabot[bot]
db49063b0d chore(deps-dev): bump semantic-release from 15.14.0 to 17.2.3 (#220)
Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 15.14.0 to 17.2.3.
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v15.14.0...v17.2.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-11-25 09:03:08 -07:00
dependabot[bot]
b55557292e chore(deps): bump node-fetch from 2.6.0 to 2.6.1 (#218)
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-09-11 08:28:39 -06:00
Devonte
c87ce2fef6 fix: log errors, improved messages (#156)
Spelling errors, improved error logs.
2020-09-08 08:38:14 -06:00
Yaser
05a68cfb08 feat: pipe stream on fs write (#206)
* pipe allloewd on write stream

* code readable

* fix: 'if' statment missed curly braces

* Update src/commands/registration/stor.js

Replacing write data with pipe on STOR command

Co-authored-by: Tyler Stewart <hello@tylerstewart.ca>

* fix: socket on data replaced with socket on pipe

* fix: extra space

Co-authored-by: Tyler Stewart <hello@tylerstewart.ca>
Co-authored-by: Tyler Stewart <tyler@autovance.com>
2020-08-25 15:44:37 -06:00
Matt Forster
31290fc964 chore: security.md (#215)
* chore: security.md

* Update SECURITY.md
2020-08-18 11:44:53 -06:00
Matt Forster
a598fab03c fix: packages updates - npm advisories (#214)
Correct NPM advisories by updating package-lock.json and package
dependencies.

Major upgrade for yargs, but did not affect our code directly.
2020-08-17 15:44:23 -06:00
Matt Forster
87e8ac6ca8 chore: fix maintenance version regex 2020-08-17 13:43:27 -06:00
Matt Forster
75e34988f4 chore: add release support for backport branches (#213) 2020-08-17 13:37:49 -06:00
Tyler Stewart
e449e75219 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 13:15:06 -06:00
Tyler Stewart
296573ae01 fix: cli arg consistency 2020-08-13 08:38:01 -06:00
Tyler Stewart
be579f65d0 docs: clarify passive options and behaviour 2020-08-13 08:38:01 -06:00
dependabot[bot]
c440050945 chore(deps): bump npm from 6.13.4 to 6.14.6 (#203)
Bumps [npm](https://github.com/npm/cli) from 6.13.4 to 6.14.6.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v6.13.4...v6.14.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tyler Stewart <tyler@autovance.com>
2020-07-21 10:05:34 -06:00
Tyler Stewart
ca576acf2e fix: correctly destroy socket on close (#208)
* chore: update owner references

* fix: correctly destroy socket on close

This fixes an issue where the client would never close, hanging the server and preventing shutdown

* fix: only set timeout if greater than 0

* fix: move dependency to top

* fix: notify of command that caused error

* fix: emit disconnect event on client close
2020-07-20 16:30:04 -06:00
dependabot[bot]
649d582f30 chore(deps): bump lodash from 4.17.15 to 4.17.19 (#205)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-17 13:59:28 -06:00
dependabot[bot]
e26caad783 chore(deps): bump https-proxy-agent from 2.2.2 to 2.2.4 (#198) 2020-05-01 09:04:06 -06:00
dependabot[bot]
2470db6482 chore(deps): bump handlebars from 4.1.2 to 4.5.3 (#186) 2020-05-01 09:01:50 -06:00
dependabot[bot]
d78fae0ce6 chore(deps): bump acorn from 6.1.0 to 6.4.1 (#195) 2020-05-01 08:59:54 -06:00
Tyler Stewart
c59e191a39 fix: correct OPTS error code (#190)
If an unknown option is given, the response should be 501
2020-01-07 09:39:22 -07:00
Tyler Stewart
b2b1b2a0d3 fix(commands): 502 error on unsupported command (#185)
* fix(commands): 502 error on unsupported command

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

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

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

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

View File

@@ -1,97 +1,112 @@
version: 2
version: 2.1
orbs:
node: circleci/node@5.0.2
commands:
setup_git_bot:
description: set up the bot git user to make changes
steps:
- run:
name: "Git: Botovance"
command: |
git config --global user.name "Bot Vance"
git config --global user.email bot@autovance.com
executors:
node-lts:
parameters:
node-version:
type: string
default: lts
docker:
- image: cimg/node:<< parameters.node-version >>
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
executor: node-lts
steps:
- checkout
- attach_workspace:
at: .
- node/install-packages
- 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
executor: node-lts
steps:
- checkout
- attach_workspace:
at: .
- node/install-packages
- setup_git_bot
- deploy:
name: Dry Release
command: |
npm run semantic-release -- --dry-run
git branch -u "origin/${CIRCLE_BRANCH}"
npx semantic-release --dry-run
release:
docker:
- image: *node-image
executor: node-lts
steps:
- checkout
- attach_workspace:
at: .
- node/install-packages
- setup_git_bot
- deploy:
name: Release
command: |
npm run semantic-release
git branch -u "origin/${CIRCLE_BRANCH}"
npx semantic-release
workflows:
version: 2
publish:
release_scheduled:
triggers:
# 6:03 UTC (mornings) 1 monday
- schedule:
cron: "3 6 * * 1"
filters:
branches:
only:
- main
jobs:
- build
- lint:
- lint
- node/test:
matrix:
parameters:
version:
- '12.22'
- '14.19'
- '16.14'
- 'current'
- release:
context: npm-deploy-av
requires:
- build
- test:
requires:
- build
- node/test
- lint
test:
jobs:
- lint
- node/test:
matrix:
parameters:
version:
- '12.22'
- '14.19'
- '16.14'
- 'current'
- release_dry_run:
filters:
branches:
only: master
only: main
requires:
- test
- node/test
- lint
- hold_release:
type: approval
requires:
- release_dry_run
- release:
context: npm-deploy-av
requires:
- hold_release

19
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,19 @@
---
### Acceptance Checklist
- [ ] **Story**: Code is focused on the linked stories and solves a problem
- One of:
- [ ] **For Bugs**: A unit test is added or an existing one modified
- [ ] **For Features**: New unit tests are added covering the new functions or modifications
- [ ] Code Documentation changes are included for public interfaces and important / complex additions
- [ ] External Documentation is included for API changes, or other external facing interfaces
### Review Checklist
- [ ] The code does not duplicate existing functionality that exists elsewhere
- [ ] The code has been linted and follows team practices and style guidelines
- [ ] The changes in the PR are relevant to the title
- changes not related should be moved to a different PR
- [ ] All errors or error handling is actionable, and informs the viewer on how to correct it

5
CODEOWNERS Normal file
View File

@@ -0,0 +1,5 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
# Order is important. The last matching pattern has the most precedence.
* @quorumdms/team-gbt

416
README.md
View File

@@ -0,0 +1,416 @@
<p align="center">
<a href="https://github.com/autovance/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/autovance/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/autovance/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
---
## 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, create an active ftp server.
const FtpSrv = require('ftp-srv');
const port=21;
const ftpServer = new FtpSrv({
url: "ftp://0.0.0.0:" + port,
anonymous: true
});
ftpServer.on('login', ({ connection, username, password }, resolve, reject) => {
if(username === 'anonymous' && password === 'anonymous'){
return resolve({ root:"/" });
}
return reject(new errors.GeneralError('Invalid username or password', 401));
});
ftpServer.listen().then(() => {
console.log('Ftp server is starting...')
});
```
## 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`
`FTP-srv` provides an IP address to the client when a `PASV` command is received in the handshake for a passive connection. Reference [PASV verb](https://cr.yp.to/ftp/retr.html#pasv). This can be one of two options:
- A function which takes one parameter containing the remote IP address of the FTP client. This can be useful when the user wants to return a different IP address depending if the user is connecting from Internet or from an LAN address.
Example:
```js
const { networkInterfaces } = require('os');
const { Netmask } = require('netmask');
const nets = networkInterfaces();
function getNetworks() {
let networks = {};
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
networks[net.address + "/24"] = net.address
}
}
}
return networks;
}
const resolverFunction = (address) => {
// const networks = {
// '$GATEWAY_IP/32': `${public_ip}`,
// '10.0.0.0/8' : `${lan_ip}`
// }
const networks = getNetworks();
for (const network in networks) {
if (new Netmask(network).contains(address)) {
return networks[network];
}
}
return "127.0.0.1";
}
new FtpSrv({pasv_url: resolverFunction});
```
- A static IP address (ie. an external WAN **IP address** that the FTP server is bound to). In this case, only connections from localhost are handled differently returning `127.0.0.1` to the client.
If not provided, clients can only connect using an `Active` connection.
#### `pasv_min`
The 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`
#### `--pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`).
If not provided, clients can only connect using an `Active` connection.
#### `--pasv_min`
The 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`
#### `--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`.
#### `--read-only`
Disable write actions such as upload, delete, etc.
## 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`.
### `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
### `disconnect`
```js
ftpServer.on('disconnect', ({connection, id, newConnectionCount}) => { ... });
```
Occurs when a client has disconnected.
`connection` [client class object](src/connection.js)
`id` string of the disconnected connection id
`id` number of the new connection count (exclusive the disconnected client connection)
### `closed`
```js
ftpServer.on('closed', ({}) => { ... });
```
Occurs when the FTP server has been closed.
### `closing`
```js
ftpServer.on('closing', ({}) => { ... });
```
Occurs when the FTP server has started closing.
### `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
### `server-error`
```js
ftpServer.on('server-error', ({error}) => { ... });
```
Occurs when an error arises in the FTP server.
`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(fileName)`](src/fs.js#L131)
Returns a unique file name to write to. Client requested filename available if you want to base your function on it.
__Used in:__ `STOU`
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## 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)

16
SECURITY.md Normal file
View File

@@ -0,0 +1,16 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 4.x | :white_check_mark: |
| 3.x | :white_check_mark: |
| < 3.0 | :x: |
__Critical vulnerabilities will be ported as far back as possible.__
## Reporting a Vulnerability
Report a security vulnerability directly to the maintainers by sending an email to security@autovance.com
or by reporting a vulnerability to the [NPM and Github security teams](https://docs.npmjs.com/reporting-a-vulnerability-in-an-npm-package).

View File

@@ -37,19 +37,22 @@ function setupYargs() {
boolean: true,
default: false
})
.option('pasv_url', {
.option('pasv-url', {
describe: 'URL to provide for passive connections',
type: 'string'
type: 'string',
alias: 'pasv_url'
})
.option('pasv_min', {
.option('pasv-min', {
describe: 'Starting point to use when creating passive connections',
type: 'number',
default: 1024
default: 1024,
alias: 'pasv_min'
})
.option('pasv_max', {
.option('pasv-max', {
describe: 'Ending port to use when creating passive connections',
type: 'number',
default: 65535
default: 65535,
alias: 'pasv_max'
})
.parse();
}

View File

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

166
ftp-srv.d.ts vendored Normal file
View File

@@ -0,0 +1,166 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
cwd: any;
});
currentDirectory(): string;
get(fileName: string): Promise<any>;
list(path?: string): Promise<any>;
chdir(path?: string): Promise<string>;
write(fileName: string, {append, start}?: {
append?: boolean;
start?: any;
}): any;
read(fileName: string, {start}?: {
start?: any;
}): Promise<any>;
delete(path: string): Promise<any>;
mkdir(path: string): Promise<any>;
rename(from: string, to: string): Promise<any>;
chmod(path: string, mode: string): Promise<any>;
getUniqueName(fileName: string): string;
}
export class GeneralError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number);
}
export class SocketError extends Error {
/**
* @param message The error message.
* @param code Default value is `500`.
*/
constructor(message: string, code?: number);
}
export class FileSystemError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number);
}
export class ConnectorError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number);
}
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
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?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log?: any,
timeout?: number
}
export class FtpServer extends EventEmitter {
constructor(options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
close(): any;
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;
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};
export default FtpServer;

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,358 +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.
_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.
#### `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)

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

@@ -1,127 +0,0 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
cwd: any;
});
currentDirectory(): string;
get(fileName: string): Promise<any>;
list(path?: string): Promise<any>;
chdir(path?: string): Promise<string>;
write(fileName: string, {append, start}?: {
append?: boolean;
start?: any;
}): any;
read(fileName: string, {start}?: {
start?: any;
}): Promise<any>;
delete(path: string): Promise<any>;
mkdir(path: string): Promise<any>;
rename(from: string, to: string): Promise<any>;
chmod(path: string, mode: string): Promise<any>;
getUniqueName(): string;
}
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
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Record<string, any>, ...letters: 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;
}
export class FtpServer extends EventEmitter {
constructor(options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string;
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
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[];
}) => void,
reject: (err?: Error) => void
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection;
context: string;
error: Error;
}
) => void): this;
}
export {FtpServer as FtpSrv};
export default FtpServer;

9976
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 **/*.spec.js --ui bdd --bail",
"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.10",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.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.10.6",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x"
}
}

View File

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

View File

@@ -1,29 +0,0 @@
/* eslint no-unused-expressions: 0 */
const {expect} = require('chai');
const net = require('net');
const {getNextPortFactory} = require('../../src/helpers/find-port');
describe('helpers // find-port', function () {
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);
});
});
});
});

23261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,7 @@
{
"name": "ftp-srv",
"version": "5.0.0",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"main": "build/index",
"scripts": {
"lint": "eslint src/**/*.ts",
"lint:fix": "npm run lint -- --fix"
},
"jest": {
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"transform": {
".ts": "ts-jest"
},
"testRegex": "\\.test\\.ts",
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": "plugin:@typescript-eslint/recommended"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"keywords": [
"ftp",
"ftp-server",
@@ -61,32 +11,83 @@
"ftpserver",
"server"
],
"author": "Tyler Stewart",
"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": "git+https://github.com/trs/ftp-srv.git"
"url": "https://github.com/autovance/ftp-srv"
},
"bugs": {
"url": "https://github.com/trs/ftp-srv/issues"
"scripts": {
"pre-release": "npm run verify",
"test": "mocha test/*/*/*.spec.js test/*/*.spec.js test/*.spec.js",
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
},
"release": {
"verifyConditions": "condition-circle",
"branch": "main",
"branches": [
"main"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"eslint --fix"
]
},
"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": "^15.4.1"
},
"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",
"@commitlint/cli": "^10.0.0",
"@commitlint/config-conventional": "^16.2.1",
"@icetee/ftp": "^1.0.2",
"chai": "^4.2.0",
"condition-circle": "^2.0.2",
"eslint": "^5.14.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"
"lint-staged": "^12.3.7",
"mocha": "^9.2.2",
"rimraf": "^2.6.1",
"semantic-release": "^19.0.2",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=12"
}
}

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
import CommandSocket from "../commandSocket";
import FTPServer from "../server";
import { ReplyCode } from "../reply";
export interface Command {
identifier: string;
argument: string;
argumentParts: string[];
}
export type CommandReply = [ReplyCode, ...string[]];
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
};
}

View File

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

View File

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

View File

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

View File

@@ -45,30 +45,30 @@ 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: ${command.directive}`);
}
if (_.includes(this.blacklist, command.directive)) {
return this.connection.reply(502, 'Command blacklisted');
return this.connection.reply(502, `Command blacklisted: ${command.directive}`);
}
if (this.whitelist.length > 0 && !_.includes(this.whitelist, command.directive)) {
return this.connection.reply(502, 'Command not whitelisted');
return this.connection.reply(502, `Command not whitelisted: ${command.directive}`);
}
const commandRegister = REGISTRY[command.directive];
const commandFlags = _.get(commandRegister, 'flags', {});
if (!commandFlags.no_auth && !this.connection.authenticated) {
return this.connection.reply(530, 'Command requires authentication');
return this.connection.reply(530, `Command requires authentication: ${command.directive}`);
}
if (!commandRegister.handler) {
return this.connection.reply(502, 'Handler not set on command');
return this.connection.reply(502, `Handler not set on command: ${command.directive}`);
}
const handler = commandRegister.handler.bind(this.connection);
return Promise.resolve(handler({log, command, previous_command: this.previousCommand}))
.finally(() => {
.then(() => {
this.previousCommand = _.clone(command);
});
}

View File

@@ -7,7 +7,7 @@ module.exports = {
.then(() => this.reply(226));
})
.catch(() => this.reply(225))
.finally(() => this.connector.end());
.then(() => this.connector.end());
},
syntax: '{{cmd}}',
description: 'Abort an active file transfer'

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,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) => {
@@ -46,7 +47,7 @@ module.exports = {
log.error(err);
return this.reply(451, err.message || 'No directory');
})
.finally(() => {
.then(() => {
this.connector.end();
this.commandSocket.resume();
});

View File

@@ -7,7 +7,7 @@ module.exports = {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.mkdir) return this.reply(402, 'Not supported by file system');
return Promise.try(() => this.fs.mkdir(command.arg))
return Promise.try(() => this.fs.mkdir(command.arg, { recursive: true }))
.then((dir) => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);

View File

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

View File

@@ -1,4 +1,6 @@
const Promise = require('bluebird');
const PassiveConnector = require('../../connector/passive');
const {isLocalIP} = require('../../helpers/is-local');
module.exports = {
directive: 'PASV',
@@ -10,8 +12,17 @@ module.exports = {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
const address = this.server.options.pasv_url;
const {port} = server.address();
let pasvAddress = this.server.options.pasv_url;
if (typeof pasvAddress === "function") {
return Promise.try(() => pasvAddress(this.ip))
.then((address) => ({address, port}));
}
// Allow connecting from local
if (isLocalIP(this.ip)) pasvAddress = this.ip;
return {address: pasvAddress, port};
})
.then(({address, port}) => {
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;
const portByte2 = port % 256;
@@ -20,7 +31,7 @@ module.exports = {
})
.catch((err) => {
log.error(err);
return this.reply(425);
return this.reply(err.code || 425, err.message);
});
},
syntax: '{{cmd}}',

View File

@@ -1,7 +1,7 @@
module.exports = {
directive: 'PBSZ',
handler: function ({command} = {}) {
if (!this.secure) return this.reply(202, 'Not suppored');
if (!this.secure) return this.reply(202, 'Not supported');
this.bufferSize = parseInt(command.arg, 10);
return this.reply(200, this.bufferSize === 0 ? 'OK' : 'Buffer too large: PBSZ=0');
},

View File

@@ -9,7 +9,7 @@ module.exports = {
const rawConnection = _.get(command, 'arg', '').split(',');
if (rawConnection.length !== 6) return this.reply(425);
const ip = rawConnection.slice(0, 4).join('.');
const ip = rawConnection.slice(0, 4).map((b) => parseInt(b)).join('.');
const portBytes = rawConnection.slice(4).map((p) => parseInt(p));
const port = portBytes[0] * 256 + portBytes[1];
@@ -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

@@ -3,7 +3,7 @@ const _ = require('lodash');
module.exports = {
directive: 'PROT',
handler: function ({command} = {}) {
if (!this.secure) return this.reply(202, 'Not suppored');
if (!this.secure) return this.reply(202, 'Not supported');
if (!this.bufferSize && typeof this.bufferSize !== 'number') return this.reply(503);
switch (_.toUpper(command.arg)) {

View File

@@ -28,7 +28,7 @@ module.exports = {
stream.on('data', (data) => {
if (stream) stream.pause();
if (this.connector.socket) {
this.connector.socket.write(data, this.transferType, () => stream && stream.resume());
this.connector.socket.write(data, () => stream && stream.resume());
}
});
stream.once('end', () => resolve());
@@ -43,7 +43,7 @@ module.exports = {
.then(() => eventsPromise)
.tap(() => this.emit('RETR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
.then(() => stream.destroy && stream.destroy());
})
.catch(Promise.TimeoutError, (err) => {
log.error(err);
@@ -54,7 +54,7 @@ module.exports = {
this.emit('RETR', err);
return this.reply(551, err.message);
})
.finally(() => {
.then(() => {
this.connector.end();
this.commandSocket.resume();
});

View File

@@ -21,7 +21,7 @@ module.exports = {
this.emit('RNTO', err);
return this.reply(550, err.message);
})
.finally(() => {
.then(() => {
delete this.renameFrom;
});
},

View File

@@ -21,11 +21,14 @@ module.exports = {
const serverPath = stream.path || fileName;
const destroyConnection = (connection, reject) => (err) => {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
try {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
}
} finally {
reject(err);
}
reject(err);
};
const streamPromise = new Promise((resolve, reject) => {
@@ -34,12 +37,7 @@ module.exports = {
});
const socketPromise = new Promise((resolve, reject) => {
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());
}
});
this.connector.socket.pipe(stream, {end: false});
this.connector.socket.once('end', () => {
if (stream.listenerCount('close')) stream.emit('close');
else stream.end();
@@ -50,11 +48,11 @@ module.exports = {
this.restByteCount = 0;
return this.reply(150).then(() => this.connector.socket.resume())
return this.reply(150).then(() => this.connector.socket && this.connector.socket.resume())
.then(() => Promise.all([streamPromise, socketPromise]))
.tap(() => this.emit('STOR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
.then(() => stream.destroy && stream.destroy());
})
.catch(Promise.TimeoutError, (err) => {
log.error(err);
@@ -65,7 +63,7 @@ module.exports = {
this.emit('STOR', err);
return this.reply(550, err.message);
})
.finally(() => {
.then(() => {
this.connector.end();
this.commandSocket.resume();
});

View File

@@ -9,7 +9,7 @@ module.exports = {
const fileName = args.command.arg;
return Promise.try(() => this.fs.get(fileName))
.then(() => Promise.try(() => this.fs.getUniqueName()))
.then(() => Promise.try(() => this.fs.getUniqueName(fileName)))
.catch(() => fileName)
.then((name) => {
args.command.arg = name;

View File

@@ -14,6 +14,7 @@ class FtpConnection extends EventEmitter {
super();
this.server = server;
this.id = uuid.v4();
this.commandSocket = options.socket;
this.log = options.log.child({id: this.id, ip: this.ip});
this.commands = new Commands(this);
this.transferType = 'binary';
@@ -24,7 +25,6 @@ class FtpConnection extends EventEmitter {
this.connector = new BaseConnector(this);
this.commandSocket = options.socket;
this.commandSocket.on('error', (err) => {
this.log.error(err, 'Client error');
this.server.emit('client-error', {connection: this, context: 'commandSocket', error: err});
@@ -32,7 +32,7 @@ class FtpConnection extends EventEmitter {
this.commandSocket.on('data', this._handleData.bind(this));
this.commandSocket.on('timeout', () => {
this.log.trace('Client timeout');
this.close().catch((e) => this.log.trace(e, 'Client close error'));
this.close();
});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
@@ -72,7 +72,7 @@ class FtpConnection extends EventEmitter {
close(code = 421, message = 'Closing connection') {
return Promise.resolve(code)
.then((_code) => _code && this.reply(_code, message))
.then(() => this.commandSocket && this.commandSocket.end());
.then(() => this.commandSocket && this.commandSocket.destroy());
}
login(username, password) {
@@ -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;
});
});
@@ -123,14 +129,17 @@ class FtpConnection extends EventEmitter {
return new Promise((resolve, reject) => {
if (letter.socket && letter.socket.writable) {
this.log.trace({port: letter.socket.address().port, encoding: letter.encoding, message: letter.message}, 'Reply');
letter.socket.write(letter.message + '\r\n', letter.encoding, (err) => {
if (err) {
this.log.error(err);
return reject(err);
letter.socket.write(letter.message + '\r\n', letter.encoding, (error) => {
if (error) {
this.log.error('[Process Letter] Socket Write Error', { error: error.message });
return reject(error);
}
resolve();
});
} else reject(new errors.SocketError('Socket not writable'));
} else {
this.log.trace({message: letter.message}, 'Could not write message');
reject(new errors.SocketError('Socket not writable'));
}
});
};
@@ -138,8 +147,8 @@ class FtpConnection extends EventEmitter {
.then((satisfiedLetters) => Promise.mapSeries(satisfiedLetters, (letter, index) => {
return processLetter(letter, index);
}))
.catch((err) => {
this.log.error(err);
.catch((error) => {
this.log.error('Satisfy Parameters Error', { error: error.message });
});
}
}

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,8 +29,11 @@ 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,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({
@@ -39,21 +44,21 @@ class Passive extends Connector {
socket.destroy();
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
.then(() => 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,11 +79,17 @@ 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);
}
});
});
})
.catch((error) => {
this.log.trace(error.message);
throw error;
});
}

View File

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

View File

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

View File

@@ -2,13 +2,17 @@ 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');
const UNIX_SEP_REGEX = /\//g;
const WIN_SEP_REGEX = /\\/g;
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep;
this.cwd = nodePath.normalize((cwd || '/').replace(WIN_SEP_REGEX, '/'));
this._root = nodePath.resolve(root || process.cwd());
}
@@ -17,19 +21,21 @@ class FileSystem {
}
_resolvePath(path = '.') {
const clientPath = (() => {
path = nodePath.normalize(path);
if (nodePath.isAbsolute(path)) {
return nodePath.join(path);
} else {
return nodePath.join(this.cwd, path);
}
})();
// Unix separators normalize nicer on both unix and win platforms
const resolvedPath = path.replace(WIN_SEP_REGEX, '/');
const fsPath = (() => {
const resolvedPath = nodePath.resolve(this.root, `.${nodePath.sep}${clientPath}`);
return nodePath.join(resolvedPath);
})();
// Join cwd with new path
const joinedPath = nodePath.isAbsolute(resolvedPath)
? nodePath.normalize(resolvedPath)
: nodePath.join('/', this.cwd, resolvedPath);
// Create local filesystem path using the platform separator
const fsPath = nodePath.resolve(nodePath.join(this.root, joinedPath)
.replace(UNIX_SEP_REGEX, nodePath.sep)
.replace(WIN_SEP_REGEX, nodePath.sep));
// Create FTP client path using unix separator
const clientPath = joinedPath.replace(WIN_SEP_REGEX, '/');
return {
clientPath,
@@ -43,19 +49,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 +72,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 +84,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 +95,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 +110,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, { recursive: true })
.then(() => fsPath);
}
rename(from, to) {
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return fs.renameAsync(fromPath, toPath);
return fsAsync.rename(fromPath, toPath);
}
chmod(path, mode) {
const {fsPath} = this._resolvePath(path);
return fs.chmodAsync(fsPath, mode);
return fsAsync.chmod(fsPath, mode);
}
getUniqueName() {

View File

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

18
src/helpers/fs-async.js Normal file
View File

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

3
src/helpers/is-local.js Normal file
View File

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

View File

@@ -40,26 +40,34 @@ 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);
this.options.timeout > 0 && socket.setTimeout(this.options.timeout);
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
socket.on('close', () => this.disconnectClient(connection.id));
socket.once('close', () => {
this.emit('disconnect', {connection, id: connection.id, newConnectionCount: Object.keys(this.connections).length});
})
this.emit('connect', {connection, id: connection.id, newConnectionCount: Object.keys(this.connections).length});
const greeting = this._greeting || [];
const features = this._features || 'Ready';
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
.then(() => socket.resume());
};
const serverOptions = Object.assign({}, this.isTLS ? this.options.tls : {}, {pauseOnConnect: true});
this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler);
this.server.on('error', (err) => this.log.error(err, '[Event] error'));
this.server.on('error', (err) => {
this.log.error(err, '[Event] error');
this.emit('server-error', {error: err});
});
const quit = _.debounce(this.quit.bind(this), 100);
process.on('SIGTERM', quit);
@@ -116,36 +124,49 @@ class FtpServer extends EventEmitter {
}
disconnectClient(id) {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const client = this.connections[id];
if (!client) return resolve();
delete this.connections[id];
setTimeout(() => {
reject(new Error('Timed out disconnecting the client'))
}, this.options.timeout || 1000)
try {
client.close(0);
} catch (err) {
this.log.error(err, 'Error closing connection', {id});
} finally {
resolve('Disconnected');
}
resolve('Disconnected');
});
}
quit() {
return this.close()
.finally(() => process.exit(0));
.then(() => process.exit(0));
}
close() {
this.log.info('Server closing...');
this.server.maxConnections = 0;
return Promise.map(Object.keys(this.connections), (id) => Promise.try(this.disconnectClient.bind(this, id)))
this.emit('closing');
this.log.info('Closing connections:', Object.keys(this.connections).length);
return Promise.all(Object.keys(this.connections).map((id) => this.disconnectClient(id)))
.then(() => new Promise((resolve) => {
this.server.close((err) => {
this.log.info('Server closing...');
if (err) this.log.error(err, 'Error closing server');
resolve('Closed');
});
}))
.then(() => this.removeAllListeners());
.then(() => {
this.log.debug('Removing event listeners...')
this.emit('closed', {});
this.removeAllListeners();
return;
});
}
}

56
src/messages.js Normal file
View File

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

View File

@@ -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}`;
}

View File

@@ -1,204 +0,0 @@
import { SecureContextOptions, createServer as createSecureServer } from 'tls';
import { Server, isIP, createServer, Socket } from 'net';
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;
}
interface PassiveConfig {
hostname: string;
port_min: number;
port_max: number;
}
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() {
}
}

View File

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

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