-
Notifications
You must be signed in to change notification settings - Fork 102
Expand file tree
/
Copy pathFtpContext.ts
More file actions
439 lines (406 loc) · 16.8 KB
/
FtpContext.ts
File metadata and controls
439 lines (406 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
import { Socket } from "net"
import { ConnectionOptions as TLSConnectionOptions, TLSSocket } from "tls"
import { parseControlResponse } from "./parseControlResponse"
import { StringEncoding } from "./StringEncoding"
interface Task {
/** Handles a response for a task. */
readonly responseHandler: ResponseHandler
/** Resolves or rejects a task. */
readonly resolver: TaskResolver
/** Call stack when task was run. */
readonly stack: string
}
export interface TaskResolver {
resolve(args: any): void
reject(err: Error): void
}
export interface FTPResponse {
/** FTP response code */
readonly code: number
/** Whole response including response code */
readonly message: string
}
export type ResponseHandler = (response: Error | FTPResponse, task: TaskResolver) => void
/**
* Describes an FTP server error response including the FTP response code.
*/
export class FTPError extends Error {
/** FTP response code */
readonly code: number
constructor(res: FTPResponse) {
super(res.message)
this.name = this.constructor.name
this.code = res.code
}
}
function doNothing() {
/** Do nothing */
}
// Limit the accepted size of the control response.
const maxControlResponseLength = 2 ** 16
/**
* FTPContext holds the control and data sockets of an FTP connection and provides a
* simplified way to interact with an FTP server, handle responses, errors and timeouts.
*
* It doesn't implement or use any FTP commands. It's only a foundation to make writing an FTP
* client as easy as possible. You won't usually instantiate this, but use `Client`.
*/
export class FTPContext {
/** Debug-level logging of all socket communication. */
verbose = false
/** IP version to prefer (4: IPv4, 6: IPv6, undefined: automatic). */
ipFamily: number | undefined = undefined
/** Options for TLS connections. */
tlsOptions: TLSConnectionOptions = {}
/** Most recent TLS session from the control connection, used to resume the session on data connections. */
tlsSessionStore: Buffer | undefined = undefined
/** Current task to be resolved or rejected. */
protected _task: Task | undefined
/** A multiline response might be received as multiple chunks. */
protected _partialResponse = ""
/** The reason why a context has been closed. */
protected _closingError: NodeJS.ErrnoException | undefined
/** Encoding supported by Node applied to commands, responses and directory listing data. */
protected _encoding: StringEncoding
/** FTP control connection */
protected _socket: Socket | TLSSocket
/** FTP data connection */
protected _dataSocket: Socket | TLSSocket | undefined
/**
* Instantiate an FTP context.
*
* @param timeout - Timeout in milliseconds to apply to control and data connections. Use 0 for no timeout.
* @param encoding - Encoding to use for control connection. UTF-8 by default. Use "latin1" for older servers.
*/
constructor(readonly timeout = 0, encoding: StringEncoding = "utf8") {
this._encoding = encoding
// Help Typescript understand that we do indeed set _socket in the constructor but use the setter method to do so.
this._socket = this.socket = this._newSocket()
this._dataSocket = undefined
}
/**
* Close the context.
*/
close() {
// Internally, closing a context is always described with an error. If there is still a task running, it will
// abort with an exception that the user closed the client during a task. If no task is running, no exception is
// thrown but all newly submitted tasks after that will abort the exception that the client has been closed.
// In addition the user will get a stack trace pointing to where exactly the client has been closed. So in any
// case use _closingError to determine whether a context is closed. This also allows us to have a single code-path
// for closing a context making the implementation easier.
const message = this._task ? "User closed client during task" : "User closed client"
const err = new Error(message)
this.closeWithError(err)
}
/**
* Close the context with an error.
*/
closeWithError(err: Error) {
// If this context already has been closed, don't overwrite the reason.
if (this._closingError) {
return
}
this._closingError = err
// Close the sockets but don't fully reset this context to preserve `this._closingError`.
this._closeControlSocket()
this._closeSocket(this._dataSocket)
// Give the user's task a chance to react, maybe cleanup resources.
this._passToHandler(err)
// The task might not have been rejected by the user after receiving the error.
this._stopTrackingTask()
}
/**
* Returns true if this context has been closed or hasn't been connected yet. You can reopen it with `access`.
*/
get closed(): boolean {
return this.socket.remoteAddress === undefined || this._closingError !== undefined
}
/**
* Reset this contex and all of its state.
*/
reset() {
this.socket = this._newSocket()
}
/**
* Get the FTP control socket.
*/
get socket(): Socket | TLSSocket {
return this._socket
}
/**
* Set the socket for the control connection. This will only close the current control socket
* if the new one is not an upgrade to the current one.
*/
set socket(socket: Socket | TLSSocket) {
// No data socket should be open in any case where the control socket is set or upgraded.
this.dataSocket = undefined
// This being a reset, reset any other state apart from the socket.
this.tlsOptions = {}
this.tlsSessionStore = undefined
this._partialResponse = ""
if (this._socket) {
const newSocketUpgradesExisting = socket.localPort === this._socket.localPort
if (newSocketUpgradesExisting) {
this._removeSocketListeners(this.socket)
} else {
this._closeControlSocket()
}
}
if (socket) {
// Setting a completely new control socket is in essence something like a reset. That's
// why we also close any open data connection above. We can go one step further and reset
// a possible closing error. That means that a closed FTPContext can be "reopened" by
// setting a new control socket.
this._closingError = undefined
// Don't set a timeout yet. Timeout for control sockets is only active during a task, see handle() below.
socket.setTimeout(0)
socket.setEncoding(this._encoding)
socket.setKeepAlive(true)
socket.on("data", data => this._onControlSocketData(data))
// Server sending a FIN packet is treated as an error.
socket.on("end", () => this.closeWithError(new Error("Server sent FIN packet unexpectedly, closing connection.")))
// Control being closed without error by server is treated as an error.
socket.on("close", hadError => { if (!hadError) this.closeWithError(new Error("Server closed connection unexpectedly.")) })
this._setupDefaultErrorHandlers(socket, "control socket")
if (socket instanceof TLSSocket) {
socket.on("session", session => { this.tlsSessionStore = session })
}
}
this._socket = socket
}
/**
* Get the current FTP data connection if present.
*/
get dataSocket(): Socket | TLSSocket | undefined {
return this._dataSocket
}
/**
* Set the socket for the data connection. This will automatically close the former data socket.
*/
set dataSocket(socket: Socket | TLSSocket | undefined) {
this._closeSocket(this._dataSocket)
if (socket) {
// Don't set a timeout yet. Timeout data socket should be activated when data transmission starts
// and timeout on control socket is deactivated.
socket.setTimeout(0)
this._setupDefaultErrorHandlers(socket, "data socket")
}
this._dataSocket = socket
}
/**
* Get the currently used encoding.
*/
get encoding(): StringEncoding {
return this._encoding
}
/**
* Set the encoding used for the control socket.
*
* See https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings for what encodings
* are supported by Node.
*/
set encoding(encoding: StringEncoding) {
this._encoding = encoding
if (this.socket) {
this.socket.setEncoding(encoding)
}
}
/**
* Send an FTP command without waiting for or handling the result.
*/
send(command: string) {
// Reject control character injection attempts.
if (/[\r\n\0]/.test(command)) {
throw new Error(`Invalid command: Contains control characters. (${command})`);
}
const containsPassword = command.startsWith("PASS")
const message = containsPassword ? "> PASS ###" : `> ${command}`
this.log(message)
this._socket.write(command + "\r\n", this.encoding)
}
/**
* Send an FTP command and handle the first response. Use this if you have a simple
* request-response situation.
*/
request(command: string): Promise<FTPResponse> {
return this.handle(command, (res, task) => {
if (res instanceof Error) {
task.reject(res)
}
else {
task.resolve(res)
}
})
}
/**
* Send an FTP command and handle any response until you resolve/reject. Use this if you expect multiple responses
* to a request. This returns a Promise that will hold whatever the response handler passed on when resolving/rejecting its task.
*/
handle(command: string | undefined, responseHandler: ResponseHandler): Promise<any> {
if (this._task) {
const err = new Error("User launched a task while another one is still running. Forgot to use 'await' or '.then()'?")
err.stack += `\nRunning task launched at: ${this._task.stack}`
this.closeWithError(err)
// Don't return here, continue with returning the Promise that will then be rejected
// because the context closed already. That way, users will receive an exception where
// they called this method by mistake.
}
return new Promise((resolveTask, rejectTask) => {
this._task = {
stack: new Error().stack || "Unknown call stack",
responseHandler,
resolver: {
resolve: arg => {
this._stopTrackingTask()
resolveTask(arg)
},
reject: err => {
this._stopTrackingTask()
rejectTask(err)
}
}
}
if (this._closingError) {
// This client has been closed. Provide an error that describes this one as being caused
// by `_closingError`, include stack traces for both.
const err = new Error(`Client is closed because ${this._closingError.message}`) as NodeJS.ErrnoException // Type 'Error' is not correctly defined, doesn't have 'code'.
err.stack += `\nClosing reason: ${this._closingError.stack}`
err.code = this._closingError.code !== undefined ? this._closingError.code : "0"
this._passToHandler(err)
return
}
// Only track control socket timeout during the lifecycle of a task. This avoids timeouts on idle sockets,
// the default socket behaviour which is not expected by most users.
this.socket.setTimeout(this.timeout)
if (command) {
this.send(command)
}
})
}
/**
* Log message if set to be verbose.
*/
log(message: string) {
if (this.verbose) {
// tslint:disable-next-line no-console
console.log(message)
}
}
/**
* Return true if the control socket is using TLS. This does not mean that a session
* has already been negotiated.
*/
get hasTLS(): boolean {
return "encrypted" in this._socket
}
/**
* Removes reference to current task and handler. This won't resolve or reject the task.
* @protected
*/
protected _stopTrackingTask() {
// Disable timeout on control socket if there is no task active.
this.socket.setTimeout(0)
this._task = undefined
}
/**
* Handle incoming data on the control socket. The chunk is going to be of type `string`
* because we let `socket` handle encoding with `setEncoding`.
* @protected
*/
protected _onControlSocketData(chunk: string) {
this.log(`< ${chunk}`)
// This chunk might complete an earlier partial response. Protect against unbounded response attack.
if (this._partialResponse.length + chunk.length > maxControlResponseLength) {
this.closeWithError(new Error("FTP control response exceeded maximum allowed size"))
return
}
const completeResponse = this._partialResponse + chunk
const parsed = parseControlResponse(completeResponse)
// Remember any incomplete remainder.
this._partialResponse = parsed.rest
// Each response group is passed along individually.
for (const message of parsed.messages) {
const code = parseInt(message.substr(0, 3), 10)
const response = { code, message }
const err = code >= 400 ? new FTPError(response) : undefined
this._passToHandler(err ? err : response)
}
}
/**
* Send the current handler a response. This is usually a control socket response
* or a socket event, like an error or timeout.
* @protected
*/
protected _passToHandler(response: Error | FTPResponse) {
if (this._task) {
this._task.responseHandler(response, this._task.resolver)
}
// Errors other than FTPError always close the client. If there isn't an active task to handle the error,
// the next one submitted will receive it using `_closingError`.
// There is only one edge-case: If there is an FTPError while no task is active, the error will be dropped.
// But that means that the user sent an FTP command with no intention of handling the result. So why should the
// error be handled? Maybe log it at least? Debug logging will already do that and the client stays useable after
// FTPError. So maybe no need to do anything here.
}
/**
* Setup all error handlers for a socket.
* @protected
*/
protected _setupDefaultErrorHandlers(socket: Socket, identifier: string) {
socket.once("error", error => {
error.message += ` (${identifier})`
this.closeWithError(error)
})
socket.once("close", hadError => {
if (hadError) {
this.closeWithError(new Error(`Socket closed due to transmission error (${identifier})`))
}
})
socket.once("timeout", () => {
socket.destroy()
this.closeWithError(new Error(`Timeout (${identifier})`))
})
}
/**
* Close the control socket. Sends QUIT, then FIN, and ignores any response or error.
*/
protected _closeControlSocket() {
this._removeSocketListeners(this._socket)
this._socket.on("error", doNothing)
this.send("QUIT")
this._closeSocket(this._socket)
}
/**
* Close a socket, ignores any error.
* @protected
*/
protected _closeSocket(socket: Socket | undefined) {
if (socket) {
this._removeSocketListeners(socket)
socket.on("error", doNothing)
socket.destroy()
}
}
/**
* Remove all default listeners for socket.
* @protected
*/
protected _removeSocketListeners(socket: Socket) {
socket.removeAllListeners()
// Before Node.js 10.3.0, using `socket.removeAllListeners()` without any name did not work: https://github.com/nodejs/node/issues/20923.
socket.removeAllListeners("timeout")
socket.removeAllListeners("data")
socket.removeAllListeners("end")
socket.removeAllListeners("error")
socket.removeAllListeners("close")
socket.removeAllListeners("connect")
}
/**
* Provide a new socket instance.
*
* Internal use only, replaced for unit tests.
*/
_newSocket(): Socket {
return new Socket()
}
}