Skip to content
electermPublic
forked from QuorumDMS/ftp-srv

About

๐Ÿ“ฎ Modern FTP Server

Resources

Stars

0 stars

Watchers

0 watching

Forks

ย 
ย 

Latest commit

ย 

History

15 Commits

Folders and files

Repository files navigation

@electerm/ftp-srv

English ไธญๆ–‡

Modern, extensible FTP Server (TypeScript version).

This is a TypeScript fork of ftp-srv, published in both ESM and CJS formats โ€” with security vulnerabilities from the original removed. The API is backward-compatible with the original ftp-srv.

All credit for the original implementation goes to Tyler Stewart. See CREDITS for details.

Install

npm install @electerm/ftp-srv

Usage

// ESM
import FtpServer from '@electerm/ftp-srv';

// CJS
const { FtpServer } = require('@electerm/ftp-srv');

// Backward-compat alias (original ftp-srv API)
const { FtpSrv } = require('@electerm/ftp-srv');

Basic example

import FtpServer from '@electerm/ftp-srv';

const server = new FtpServer({
  url: 'ftp://0.0.0.0:21',
  anonymous: false,
});

server.on('login', ({ connection, username, password }, resolve, reject) => {
  if (username === 'admin' && password === 'secret') {
    return resolve({ root: '/srv/ftp' });
  }
  return reject(new Error('Invalid credentials'));
});

await server.listen();
console.log('FTP server listening on port 21');

// Graceful shutdown
await server.close();

With authentication and custom root per user

const server = new FtpServer({
  url: 'ftp://0.0.0.0:2121',
  pasv_min: 10000,
  pasv_max: 10100,
  anonymous: false,
});

server.on('login', ({ username, password }, resolve, reject) => {
  if (username === 'alice' && password === 'pass') {
    return resolve({
      root: '/home/alice',
      cwd: '/uploads',  // initial working directory within root
    });
  }
  return reject(new Error('Bad credentials'));
});

await server.listen();

Anonymous access

const server = new FtpServer({
  url: 'ftp://0.0.0.0:21',
  anonymous: true,   // accepts any username/password
  root: '/srv/ftp',  // default root for all connections
});

server.on('login', (_, resolve) => resolve({}));

await server.listen();

TLS (FTPS)

import fs from 'fs';

const server = new FtpServer({
  url: 'ftps://0.0.0.0:990',
  tls: {
    key: fs.readFileSync('server.key'),
    cert: fs.readFileSync('server.crt'),
  },
});

Both flavours are supported once tls is configured:

  • Implicit FTPS โ€” url: 'ftps://...', the whole control channel is TLS from the first byte.
  • Explicit FTPS (FTPES) โ€” url: 'ftp://...', the client sends AUTH TLS and the control channel is upgraded in place (RFC 4217), then PBSZ 0 / PROT P before transferring. Data connections are encrypted as well. FEAT only advertises AUTH TLS / PBSZ / PROT when tls is configured; without tls the server answers 502 instead of pretending to upgrade.

Security notes

  • FTP bounce protection โ€” PORT / EPRT are only honoured when the supplied address is the client's own (the peer address of the control connection), and a passive data connection coming from any other host is rejected with 550 and dropped. Both commands require a logged-in session.
  • Passive ports are released โ€” sending PASV / EPSV again tears down the previous listener, and an idle passive listener is closed after 30s, so a client that never dials in cannot exhaust the port range.
  • Encrypted data channels โ€” with tls configured, the passive/active data connections are TLS too, not just the control channel.

API

new FtpServer(options)

Option Type Default Description
url string 'ftp://127.0.0.1:21' Protocol, hostname, and port to listen on. Use ftp:// or ftps://.
root string โ€” Default root directory for all connections (used when login resolve does not specify one).
pasv_url string | (clientIp: string) => string auto-detected IP address to advertise for passive connections. When omitted, the local address of the control connection (the address the client actually reached us on) is used, falling back to the bound address / an interface on the client's subnet.
pasv_min number 1024 First port in the passive port range.
pasv_max number 65535 Last port in the passive port range.
anonymous boolean false Allow anonymous logins (any username/password accepted).
blacklist string[] [] FTP commands that are rejected globally.
whitelist string[] [] FTP commands that are allowed globally (all others rejected).
greeting string | string[] โ€” Message(s) sent to the client on connect.
tls object โ€” Node.js TLS options (key, cert, etc.) for ftps:// connections.
file_format 'ls' | 'ep' | function 'ls' Format for LIST responses. 'ls' = Unix style, 'ep' = EPLF, or a custom (stat) => string function.
log Logger bunyan logger A bunyan-compatible logger (trace, debug, info, warn, error, fatal, child).
timeout number 0 Idle socket timeout in milliseconds. 0 = disabled.
endOnProcessSignal boolean true Call close() and process.exit(0) on SIGTERM/SIGINT/SIGQUIT.

server.listen() โ†’ Promise<string>

Starts the server and begins accepting connections.

server.close() โ†’ Promise<void>

Gracefully closes all connections and stops the server.

Events

login

Fired when a client attempts to log in. You must listen to this event and call resolve or reject.

server.on('login', ({ connection, username, password }, resolve, reject) => {
  // resolve with per-user options:
  resolve({
    root: '/srv/ftp/alice',  // root directory for this session
    cwd: '/',                // initial working directory (relative to root)
    fs: customFsInstance,    // custom FileSystem instance (optional)
    blacklist: ['DELE'],     // per-connection command blacklist
    whitelist: [],           // per-connection command whitelist
  });

  // or reject with an error:
  reject(new Error('Invalid credentials'));
});

connect

Fired when a new client socket connects (before login).

server.on('connect', ({ connection, id, newConnectionCount }) => {});

disconnect

Fired when a client disconnects.

server.on('disconnect', ({ connection, id, newConnectionCount }) => {});

client-error

Fired when an error occurs on a client connection.

server.on('client-error', ({ connection, context, error }) => {});

server-error

Fired when the underlying TCP server emits an error.

server.on('server-error', ({ error }) => {});

closing / closed

Fired when close() is called and when it completes.

server.on('closing', () => {});
server.on('closed', () => {});

Custom File System

Extend FileSystem to override how the server reads and writes files.

import { FileSystem } from '@electerm/ftp-srv';

class MyFileSystem extends FileSystem {
  async list(dirPath = '.') {
    // Return array of fs.Stats-like objects with a `name` property
    const entries = await super.list(dirPath);
    return entries.filter(e => !e.name.startsWith('.'));  // hide dotfiles
  }

  async get(fileName) {
    return super.get(fileName);
  }
}

server.on('login', (_, resolve) => {
  resolve({ fs: new MyFileSystem(connection, { root: '/srv/ftp' }) });
});

The full interface that can be overridden:

Method Description
currentDirectory() Returns the current working directory path.
get(fileName) Returns an fs.Stats-like object with a name property.
list(dirPath) Returns an array of fs.Stats-like objects with name properties.
chdir(dirPath) Changes the working directory. Returns the new path.
write(fileName, options) Returns { stream, clientPath } for writing.
read(fileName, options) Returns { stream, clientPath } for reading.
delete(fileName) Deletes a file.
mkdir(dirPath) Creates a directory.
rename(from, to) Renames a file or directory.
chmod(fileName, mode) Changes file permissions.
getUniqueName(fileName) Returns a unique file name (for STOU).

Exports

import FtpServer, {
  FtpServer,       // named export (same class)
  FtpSrv,          // backward-compat alias for the original ftp-srv package
  FileSystem,
  FtpConnection,
  errors,
} from '@electerm/ftp-srv';

CLI

npx electermftpsrv ftp://0.0.0.0:9876 --root ~/Documents

License

MIT

About

๐Ÿ“ฎ Modern FTP Server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages