From 13caed7addf8ae4341724cadadb21bc59a22571d Mon Sep 17 00:00:00 2001 From: Patrick Juchli Date: Fri, 11 May 2018 17:46:47 +0200 Subject: [PATCH] Wrap up support for IPv6 --- README.md | 6 +- lib/FtpContext.js | 1 + lib/ftp.js | 132 ++++++++++++++++++++++++------------------- test/clientSpec.js | 8 +-- test/downloadSpec.js | 7 ++- 5 files changed, 86 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 13361a6..9dee6cf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/patrickjuchli/basic-ftp.svg?branch=master)](https://travis-ci.org/patrickjuchli/basic-ftp) [![npm version](https://img.shields.io/npm/v/basic-ftp.svg)](https://www.npmjs.com/package/basic-ftp) -This is an FTP client for Node.js. It supports explicit FTPS over TLS, has a Promise-based API and offers methods to operate on whole directories. +This is an FTP client for Node.js. It supports explicit FTPS over TLS, IPv6, has a Promise-based API, and offers methods to operate on whole directories. ## Goals @@ -23,7 +23,7 @@ async function example() { const client = new ftp.Client() try { await client.access({ - host: "192.168.0.10", + host: "myftpserver.com", user: "very" password: "password", secure: true @@ -225,7 +225,7 @@ The `Client` offers extension points that allow you to change a detail while sti `get/set client.prepareTransfer` -FTP creates a socket connection for each single data transfer. Data transfers include directory listings, file uploads and downloads. This property holds the function that prepares this connection. Currently, this library only offers Passive Mode over IPv4, but this extension point makes support for Active Mode or IPv6 possible. The signature of the function is `(ftp: FTPContext) => Promise` and its job is to set `ftp.dataSocket`. The section below about extending functionality explains what `FTPContext` is. +FTP creates a socket connection for each single data transfer. Data transfers include directory listings, file uploads and downloads. This property holds the function that prepares this connection. Currently, this library offers Passive Mode over IPv4 (PASV) and IPv6 (EPSV) but this extension point makes support for Active Mode possible. The signature of the function is `(ftp: FTPContext) => Promise` and its job is to set `ftp.dataSocket`. The section below about extending functionality explains what `FTPContext` is. `get/set client.parseList` diff --git a/lib/FtpContext.js b/lib/FtpContext.js index 210046d..f4a59c0 100644 --- a/lib/FtpContext.js +++ b/lib/FtpContext.js @@ -25,6 +25,7 @@ module.exports = class FTPContext { this._partialResponse = ""; // A multiline response might be received as multiple chunks. this.encoding = encoding; // The encoding used when reading from and writing on the control socket. this.tlsOptions = {}; // Options for TLS connections. + this.ipFamily = 6; // IP version to prefer (4: IPv4, 6: IPv6). this.verbose = false; // The client can log every outgoing and incoming message. this.socket = new Socket(); // The control connection to the FTP server. this.dataSocket = undefined; // The data connection to the FTP server. diff --git a/lib/ftp.js b/lib/ftp.js index 1ac69b9..4753438 100644 --- a/lib/ftp.js +++ b/lib/ftp.js @@ -1,6 +1,6 @@ "use strict"; -const Socket = require("net").Socket; +const net = require("net"); const tls = require("tls"); const fs = require("fs"); const path = require("path"); @@ -62,7 +62,7 @@ class Client { * @return {Promise} */ connect(host = "localhost", port = 21) { - this.ftp.socket.connect(port, host); + this.ftp.socket.connect({ host, port, family: this.ftp.ipFamily }, () => this.ftp.log(`Connected to ${this.ftp.socket.remoteAddress}`)); return this.ftp.handle(undefined, (res, task) => { if (positiveCompletion(res.code)) { task.resolve(res); @@ -499,7 +499,7 @@ function describeTLS(socket) { /** * Upgrade a socket connection with TLS. * - * @param {Socket} socket + * @param {net.Socket} socket * @param {Object} options Same options as in `tls.connect(options)` * @returns {Promise} */ @@ -526,21 +526,28 @@ function upgradeSocket(socket, options) { } /** - * Replace `client.prepareTransfer` with a transfer strategy that works. + * Try all available transfer strategies and pick the first one that works. Update `client` to + * use the working strategy for all successive transfer requests. * * @param {Client} client - * @returns {Promise} + * @returns {Promise} the response of the first successful strategy. */ async function prepareTransferAutoDetect(client) { - for (const strategy of [enterPassiveModeIPv6, enterPassiveModeIPv4_NEW]) { + client.ftp.log("Trying to find optimal transfer strategy..."); + for (const strategy of [ enterPassiveModeIPv6, enterPassiveModeIPv4 ]) { try { const res = await strategy(client); - client.prepareTransfer = strategy; + client.ftp.log("Optimal transfer strategy found."); + client.prepareTransfer = strategy; // First strategy that works will be used from now on. return res; } - catch(err) { /* Do nothing, try next strategy */ } + catch(err) { + if (!err.code) { // Don't log out FTP response error again. + client.ftp.log(err.toString()); + } + } } - throw new Error("Can't negotiate a transfer connection."); + throw new Error("None of the available transfer strategies work."); } /** @@ -550,22 +557,38 @@ async function prepareTransferAutoDetect(client) { * @returns {Promise} */ async function enterPassiveModeIPv6(client) { + const controlIP = client.ftp.socket.remoteAddress; + if (!net.isIPv6(controlIP)) { + throw new Error(`EPSV not possible, control connection is not using IPv6: ${controlIP}`); + } const res = await client.send("EPSV"); const port = parseIPv6PasvResponse(res.message); if (!port) { throw new Error("Can't parse EPSV response: " + res.message); } - await connectForPassiveTransfer(client.ftp.socket.remoteAddress, port, client.ftp); + await connectForPassiveTransfer(controlIP, port, client.ftp); return res; } +/** + * Parse an EPSV response. Returns only the port as in EPSV the host of the control connection is used. + * + * @param {string} message + * @returns {number} port + */ +function parseIPv6PasvResponse(message) { + // Get port from EPSV response, e.g. "229 Entering Extended Passive Mode (|||6446|)" + const groups = message.match(/\|{3}(.+)\|/); + return groups[1] ? parseInt(groups[1], 10) : undefined; +} + /** * Prepare a data socket using passive mode over IPv6. * * @param {Client} client * @returns {Promise} */ -async function enterPassiveModeIPv4_NEW(client) { +async function enterPassiveModeIPv4(client) { const res = await client.send("PASV"); const target = parseIPv4PasvResponse(res.message); if (!target) { @@ -575,21 +598,57 @@ async function enterPassiveModeIPv4_NEW(client) { // we assume a NAT issue and use the IP of the control connection as the target for the data connection. // We can't always perform this replacement because it's possible (although unlikely) that the FTP server // indeed uses a different host for data connections. - if (ipIsPrivateAddress(target.host) && !ipIsPrivateAddress(client.ftp.socket.remoteAddress)) { + if (ipIsPrivateV4Address(target.host) && !ipIsPrivateV4Address(client.ftp.socket.remoteAddress)) { target.host = client.ftp.socket.remoteAddress; } await connectForPassiveTransfer(target.host, target.port, client.ftp); return res; } +/** + * Parse a PASV response. + * + * @param {string} message + * @returns {{host: string, port: number}} + */ +function parseIPv4PasvResponse(message) { + // Get host and port from PASV response, e.g. "227 Entering Passive Mode (192,168,1,100,10,229)" + const groups = message.match(/([-\d]+,[-\d]+,[-\d]+,[-\d]+),([-\d]+),([-\d]+)/); + if (!groups || groups.length !== 4) { + return undefined; + } + return { + host: groups[1].replace(/,/g, "."), + port: (parseInt(groups[2], 10) & 255) * 256 + (parseInt(groups[3], 10) & 255) + }; +} + +/** + * Returns true if an IP is a private address according to https://tools.ietf.org/html/rfc1918#section-3. + * This will handle IPv4-mapped IPv6 addresses correctly but return false for all other IPv6 addresses. + * + * @param {string} ip The IP as a string, e.g. "192.168.0.1" + * @returns {boolean} true if the ip is local. + */ +function ipIsPrivateV4Address(ip = "") { + // Handle IPv4-mapped IPv6 addresses like ::ffff:192.168.0.1 + if (ip.startsWith("::ffff:")) { + ip = ip.substr(7); // Strip ::ffff: prefix + } + const octets = ip.split(".").map(o => parseInt(o, 10)); + return octets[0] === 10 // 10.0.0.0 - 10.255.255.255 + || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) // 172.16.0.0 - 172.31.255.255 + || (octets[0] === 192 && octets[1] === 168); // 192.168.0.0 - 192.168.255.255 +} + function connectForPassiveTransfer(host, port, ftp) { return new Promise((resolve, reject) => { const handleConnErr = function(err) { - reject("Can't open data connection in passive mode: " + err.message) + reject("Can't open data connection in passive mode: " + err.message); }; - let socket = new Socket(); + let socket = new net.Socket(); socket.on("error", handleConnErr); - socket.connect(port, host, () => { + socket.connect({ port, host, family: ftp.ipFamily }, () => { if (ftp.hasTLS) { socket = tls.connect(Object.assign({}, ftp.tlsOptions, { // Upgrade the existing socket connection. @@ -617,49 +676,6 @@ function connectForPassiveTransfer(host, port, ftp) { }); } -/** - * Parse a PASV response. - * - * @param {string} message - * @returns {{host: string, port: number}} - */ -function parseIPv4PasvResponse(message) { - // From something like "227 Entering Passive Mode (192,168,1,100,10,229)", - // extract host and port. - const groups = message.match(/([-\d]+,[-\d]+,[-\d]+,[-\d]+),([-\d]+),([-\d]+)/); - if (!groups || groups.length !== 4) { - return undefined; - } - return { - host: groups[1].replace(/,/g, "."), - port: (parseInt(groups[2], 10) & 255) * 256 + (parseInt(groups[3], 10) & 255) - }; -} - -/** - * Parse an EPSV response. Returns only the port as in EPSV the host of the control connection is used. - * - * @param {string} message - * @returns {number} port - */ -function parseIPv6PasvResponse(message) { - // TODO implement - return 0; -} - -/** - * Returns true if an IP is a private address according to https://tools.ietf.org/html/rfc1918#section-3 - * - * @param {string} ip The IP as a string, e.g. "192.168.0.1" - * @returns {boolean} true if the ip is local. - */ -function ipIsPrivateAddress(ip = "") { - const octets = ip.split(".").map(o => parseInt(o, 10)); - return octets[0] === 10 // 10.0.0.0 - 10.255.255.255 - || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) // 172.16.0.0 - 172.31.255.255 - || (octets[0] === 192 && octets[1] === 168); // 192.168.0.0 - 192.168.255.255 -} - /** * Upload stream data as a file. For example: * diff --git a/test/clientSpec.js b/test/clientSpec.js index 50ac227..e7f9751 100644 --- a/test/clientSpec.js +++ b/test/clientSpec.js @@ -25,7 +25,7 @@ describe("Convenience API", function() { beforeEach(function() { client = new Client(); - client.prepareTransfer = () => {}; // Don't change + client.prepareTransfer = () => Promise.resolve(); // Don't change client.ftp.socket = new SocketMock(); client.ftp.dataSocket = new SocketMock(); }); @@ -149,9 +149,9 @@ describe("Convenience API", function() { }); it("can connect", function() { - client.ftp.socket.connect = (port, host) => { - assert.equal(host, "host", "Socket host"); - assert.equal(port, 22, "Socket port"); + client.ftp.socket.connect = (options) => { + assert.equal(options.host, "host"); + assert.equal(options.port, 22, "Socket port"); setTimeout(() => client.ftp.socket.emit("data", Buffer.from("200 OK"))); } return client.connect("host", 22).then(result => assert.deepEqual(result, { code: 200, message: "200 OK"})); diff --git a/test/downloadSpec.js b/test/downloadSpec.js index 235b7be..08b9510 100644 --- a/test/downloadSpec.js +++ b/test/downloadSpec.js @@ -9,7 +9,7 @@ const SocketMock = require("./SocketMock"); */ describe("Download directory listing", function() { this.timeout(100); - + var f; const bufList = Buffer.from("12-05-96 05:03PM myDir"); const expList = [ (f = new FileInfo("myDir"), @@ -22,8 +22,9 @@ describe("Download directory listing", function() { let client; beforeEach(function() { client = new Client(); - client.prepareTransfer = ftp => { - ftp.dataSocket = new SocketMock(); + client.prepareTransfer = client => { + client.ftp.dataSocket = new SocketMock(); + return Promise.resolve(); }; client.ftp.socket = new SocketMock(); });