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.
npm install @electerm/ftp-srv// 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');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();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();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();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 sendsAUTH TLSand the control channel is upgraded in place (RFC 4217), thenPBSZ 0/PROT Pbefore transferring. Data connections are encrypted as well.FEATonly advertisesAUTH TLS/PBSZ/PROTwhentlsis configured; withouttlsthe server answers502instead of pretending to upgrade.
- FTP bounce protection โ
PORT/EPRTare 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 with550and dropped. Both commands require a logged-in session. - Passive ports are released โ sending
PASV/EPSVagain 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
tlsconfigured, the passive/active data connections are TLS too, not just the control channel.
| 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. |
Starts the server and begins accepting connections.
Gracefully closes all connections and stops the server.
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'));
});Fired when a new client socket connects (before login).
server.on('connect', ({ connection, id, newConnectionCount }) => {});Fired when a client disconnects.
server.on('disconnect', ({ connection, id, newConnectionCount }) => {});Fired when an error occurs on a client connection.
server.on('client-error', ({ connection, context, error }) => {});Fired when the underlying TCP server emits an error.
server.on('server-error', ({ error }) => {});Fired when close() is called and when it completes.
server.on('closing', () => {});
server.on('closed', () => {});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). |
import FtpServer, {
FtpServer, // named export (same class)
FtpSrv, // backward-compat alias for the original ftp-srv package
FileSystem,
FtpConnection,
errors,
} from '@electerm/ftp-srv';npx electermftpsrv ftp://0.0.0.0:9876 --root ~/DocumentsMIT