Skip to content

Commit

Permalink
Wrap up support for IPv6
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickjuchli committed May 11, 2018
1 parent 7ee6dd9 commit 13caed7
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 68 deletions.
6 changes: 3 additions & 3 deletions README.md
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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<void>` 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<void>` and its job is to set `ftp.dataSocket`. The section below about extending functionality explains what `FTPContext` is.

`get/set client.parseList`

Expand Down
1 change: 1 addition & 0 deletions lib/FtpContext.js
Expand Up @@ -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.
Expand Down
132 changes: 74 additions & 58 deletions 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");
Expand Down Expand Up @@ -62,7 +62,7 @@ class Client {
* @return {Promise<PositiveResponse>}
*/
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);
Expand Down Expand Up @@ -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<tls.TLSSocket>}
*/
Expand All @@ -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<PositiveResponse>}
* @returns {Promise<PositiveResponse>} 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.");
}

/**
Expand All @@ -550,22 +557,38 @@ async function prepareTransferAutoDetect(client) {
* @returns {Promise<PositiveResponse>}
*/
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<PositiveResponse>}
*/
async function enterPassiveModeIPv4_NEW(client) {
async function enterPassiveModeIPv4(client) {
const res = await client.send("PASV");
const target = parseIPv4PasvResponse(res.message);
if (!target) {
Expand All @@ -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.
Expand Down Expand Up @@ -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:
*
Expand Down
8 changes: 4 additions & 4 deletions test/clientSpec.js
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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"}));
Expand Down
7 changes: 4 additions & 3 deletions test/downloadSpec.js
Expand Up @@ -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 <DIR> myDir");
const expList = [
(f = new FileInfo("myDir"),
Expand All @@ -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();
});
Expand Down

0 comments on commit 13caed7

Please sign in to comment.