ftp-srv-esm
Version:
Modern, extensible FTP server (daemon) for Node.js with ESM support. Based on ftp-srv.
188 lines (158 loc) • 5.77 kB
JavaScript
import { readFileSync } from 'fs';
import winston from 'winston';
import net from 'net';
import tls from 'tls';
import EventEmitter from 'events';
import Connection from './connection.js';
import { getNextPortFactory } from './helpers/find-port.js';
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)).toString());
class FtpServer extends EventEmitter {
constructor(options = {}) {
super();
this.options = Object.assign({
log: winston.createLogger({
name: packageJson.name,
silent: true,
format: winston.format.simple(),
transports: [new winston.transports.Console({ level: 'silly' })]
}),
url: 'ftp://127.0.0.1:21',
pasv_min: 1024,
pasv_max: 65535,
pasv_hostname: null,
anonymous: false,
list_format: 'ls',
blacklist: [],
whitelist: [],
greeting: null,
tls: false,
timeout: 0,
endOnProcessSignal: true
}, Object.fromEntries(Object.entries(options).filter(([_, v]) => v !== undefined)));
this._greeting = this.setupGreeting(this.options.greeting);
this._features = this.setupFeaturesMessage();
delete this.options.greeting;
this.connections = {};
this.log = this.options.log;
this.url = new URL(this.options.url);
this.getNextPasvPort = getNextPortFactory(
this.url?.hostname,
this.options?.pasv_min,
this.options?.pasv_max);
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const serverConnectionHandler = (socket) => {
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)
.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('[Event] error', {error: err});
this.emit('server-error', {error: err});
});
const quit = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(() => this.quit(), 100);
};
})();
if (this.options.endOnProcessSignal) {
process.on('SIGTERM', quit);
process.on('SIGINT', quit);
process.on('SIGQUIT', quit);
}
}
get isTLS() {
return this.url.protocol === 'ftps:' && this.options.tls;
}
listen() {
if (!this.options.pasv_hostname) {
this.log.warn('Passive URL not set. Passive connections not available.');
}
return new Promise((resolve, reject) => {
this.server.once('error', reject);
this.server.listen(this.url.port, this.url.hostname, (err) => {
this.server.removeListener('error', reject);
if (err) return reject(err);
this.log.info('Listening', {
protocol: this.url.protocol.replace(/\W/g, ''),
ip: this.url.hostname,
port: this.url.port
});
resolve('Listening');
});
});
}
emitPromise(action, ...data) {
return new Promise((resolve, reject) => {
const params = [...data, resolve, reject];
this.emit.call(this, action, ...params);
});
}
setupGreeting(greet) {
if (!greet) return [];
const greeting = Array.isArray(greet) ? greet : greet.split('\n');
return greeting;
}
setupFeaturesMessage() {
let features = [];
if (this.options.anonymous) features.push('a');
if (features.length) {
features.unshift('Features:');
features.push('.');
}
return features.length ? features.join(' ') : 'Ready';
}
disconnectClient(id) {
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 || 1e3)
try {
client.close(0);
} catch (err) {
this.log.error('Error closing connection: '+ err, {id});
}
resolve('Disconnected');
});
}
quit() {
return this.close()
.then(() => process.exit(0));
}
close() {
this.server.maxConnections = 0;
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('Error closing server', {error: err});
resolve('Closed');
});
}))
.then(() => {
this.log.debug('Removing event listeners...')
this.emit('closed', {});
this.removeAllListeners();
return;
});
}
}
export default FtpServer;