-
Notifications
You must be signed in to change notification settings - Fork 6
/
pcp.js
291 lines (286 loc) · 11.7 KB
/
pcp.js
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
'use strict'
const utils = require('./utils')
const ipaddr = require('ipaddr.js')
const dgram = require('dgram')
/**
* Probe if PCP is supported by the router
* @public
* @method probeSupport
* @param {object} activeMappings Table of active Mappings
* @param {Array<string>} routerIpCache Router IPs that have previously worked
* @return {Promise<boolean>} A promise for a boolean
*/
var probeSupport = function (activeMappings, routerIpCache) {
return addMapping(utils.PCP_PROBE_PORT, utils.PCP_PROBE_PORT, 120,
activeMappings, routerIpCache)
.then(function (mapping) {
return mapping.externalPort !== -1
})
}
/**
* Makes a port mapping in the NAT with PCP,
* and automatically refresh the mapping every two minutes
* @public
* @method addMapping
* @param {number} intPort The internal port on the computer to map to
* @param {number} extPort The external port on the router to map to
* @param {number} lifetime Seconds that the mapping will last
* 0 is infinity, i.e. a refresh every 24 hours
* @param {object} activeMappings Table of active Mappings
* @param {Array<string>} routerIpCache Router IPs that have previously worked
* @return {Promise<Mapping>} A promise for the port mapping object
* mapping.externalPort is -1 on failure
*/
var addMapping = function (intPort, extPort, lifetime, activeMappings, routerIpCache) {
var mapping = new utils.Mapping()
mapping.internalPort = intPort
mapping.protocol = 'pcp'
// If lifetime is zero, we want to refresh every 24 hours
var reqLifetime = (lifetime === 0) ? 24 * 60 * 60 : lifetime
// Send PCP requests to a list of router IPs and parse the first response
function _sendPcpRequests (routerIps) {
return utils.getPrivateIps().then(function (privateIps) {
// Construct an array of ArrayBuffers, which are the responses of
// sendPcpRequest() calls on all the router IPs. An error result
// is caught and re-passed as null.
return Promise.all(routerIps.map(function (routerIp) {
// Choose a privateIp based on the currently selected routerIp,
// using a longest prefix match, and send a PCP request with that IP
var privateIp = utils.longestPrefixMatch(privateIps, routerIp)
return sendPcpRequest(routerIp, privateIp, intPort, extPort,
reqLifetime)
.then(function (pcpResponse) {
return {
pcpResponse: pcpResponse,
privateIp: privateIp
}
})
.catch(function (err) {
return null
})
}))
}).then(function (responses) {
// Check if any of the responses are successful (not null), and return
// it as a Mapping object
for (var i = 0; i < responses.length; i++) {
if (responses[i] !== null) {
var responseView = new DataView(responses[i].pcpResponse.buffer)
var ipOctets = [responseView.getUint8(56), responseView.getUint8(57),
responseView.getUint8(58), responseView.getUint8(59)
]
var extIp = ipOctets.join('.')
mapping.externalPort = responseView.getUint16(42)
mapping.externalIp = extIp
mapping.internalIp = responses[i].privateIp
mapping.lifetime = responseView.getUint32(4)
mapping.nonce = [responseView.getUint32(24),
responseView.getUint32(28),
responseView.getUint32(32)
]
if (routerIpCache.indexOf(routerIps[i]) === -1) {
routerIpCache.push(routerIps[i])
}
}
}
return mapping
}).catch(function (err) {
return mapping
})
}
// Basically calls _sendPcpRequests on matchedRouterIps first, and if that
// doesn't work, calls it on otherRouterIps
function _sendPcpRequestsInWaves () {
return utils.getPrivateIps().then(function (privateIps) {
// Try matchedRouterIps first (routerIpCache + router IPs that match the
// user's IPs), then otherRouterIps if it doesn't work. This avoids flooding
// the local network with PCP requests
var matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps))
var otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps)
return _sendPcpRequests(matchedRouterIps).then(function (mapping) {
if (mapping.externalPort !== -1) {
return mapping
}
return _sendPcpRequests(otherRouterIps)
})
})
}
// Compare our requested parameters for the mapping with the response,
// setting a refresh if necessary, and a timeout for deletion, and saving the
// mapping object to activeMappings if the mapping succeeded
function _saveAndRefreshMapping (mapping) {
// If the actual lifetime is less than the requested lifetime,
// setTimeout to refresh the mapping when it expires
var dLifetime = reqLifetime - mapping.lifetime
if (mapping.externalPort !== -1 && dLifetime > 0) {
mapping.timeoutId = setTimeout(addMapping.bind({}, intPort,
mapping.externalPort, dLifetime, activeMappings), mapping.lifetime * 1000)
}
// If the original lifetime is 0, refresh every 24 hrs indefinitely
else if (mapping.externalPort !== -1 && lifetime === 0) {
mapping.timeoutId = setTimeout(addMapping.bind({}, intPort,
mapping.externalPort, 0, activeMappings), 24 * 60 * 60 * 1000)
}
// If we're not refreshing, delete the entry in activeMapping at expiration
else if (mapping.externalPort !== -1) {
setTimeout(function () {
delete activeMappings[mapping.externalPort]
},
mapping.lifetime * 1000)
}
// If mapping succeeded, attach a deleter function and add to activeMappings
if (mapping.externalPort !== -1) {
mapping.deleter = deleteMapping.bind({}, mapping.externalPort,
activeMappings, routerIpCache)
activeMappings[mapping.externalPort] = mapping
}
return mapping
}
// Try PCP requests to matchedRouterIps, then otherRouterIps.
// After receiving a PCP response, set timeouts to delete/refresh the
// mapping, add it to activeMappings, and return the mapping object
return _sendPcpRequestsInWaves().then(_saveAndRefreshMapping)
}
/**
* Deletes a port mapping in the NAT with PCP
* @public
* @method deleteMapping
* @param {number} extPort The external port of the mapping to delete
* @param {object} activeMappings Table of active Mappings
* @param {Array<string>} routerIpCache Router IPs that have previously worked
* @return {Promise<boolean>} True on success, false on failure
*/
var deleteMapping = function (extPort, activeMappings, routerIpCache) {
// Send PCP requests to a list of router IPs and parse the first response
function _sendDeletionRequests (routerIps) {
return utils.getPrivateIps().then(function (privateIps) {
// Get the internal port and nonce for this mapping; this may error
var intPort = activeMappings[extPort].internalPort
var nonce = activeMappings[extPort].nonce
// Construct an array of ArrayBuffers, which are the responses of
// sendPmpRequest() calls on all the router IPs. An error result
// is caught and re-passed as null.
return Promise.all(routerIps.map(function (routerIp) {
// Choose a privateIp based on the currently selected routerIp,
// using a longest prefix match, and send a PCP request with that IP
var privateIp = utils.longestPrefixMatch(privateIps, routerIp)
return sendPcpRequest(routerIp, privateIp, intPort, 0, 0, nonce)
.then(function (pcpResponse) {
return pcpResponse
})
.catch(function (err) {
return null
})
}))
})
}
// Basically calls _sendDeletionRequests on matchedRouterIps first, and if that
// doesn't work, calls it on otherRouterIps
function _sendDeletionRequestsInWaves () {
return utils.getPrivateIps().then(function (privateIps) {
// Try matchedRouterIps first (routerIpCache + router IPs that match the
// user's IPs), then otherRouterIps if it doesn't work. This avoids flooding
// the local network with PCP requests
var matchedRouterIps = utils.arrAdd(routerIpCache, utils.filterRouterIps(privateIps))
var otherRouterIps = utils.arrDiff(utils.ROUTER_IPS, matchedRouterIps)
return _sendDeletionRequests(matchedRouterIps).then(function (mapping) {
if (mapping.externalPort !== -1) {
return mapping
}
return _sendDeletionRequests(otherRouterIps)
})
})
}
// If any of the PCP responses were successful, delete the entry from
// activeMappings and return true
function _deleteFromActiveMappings (responses) {
for (var i = 0; i < responses.length; i++) {
if (responses[i] !== null) {
// Success code 8 (NO_RESOURCES) may denote that the mapping does not
// exist on the router, so we accept it as well
var responseView = new DataView(responses[i])
var successCode = responseView.getUint8(3)
if (successCode === 0 || successCode === 8) {
clearTimeout(activeMappings[extPort].timeoutId)
delete activeMappings[extPort]
return true
}
}
}
return false
}
// Send PCP deletion requests to matchedRouterIps, then otherRouterIps;
// if that succeeds, delete the corresponding Mapping from activeMappings
return _sendDeletionRequestsInWaves()
.then(_deleteFromActiveMappings)
.catch(function (err) {
return false
})
}
/**
* Send a PCP request to the router to map a port
* @private
* @method sendPcpRequest
* @param {string} routerIp The IP address that the router can be reached at
* @param {string} privateIp The private IP address of the user's computer
* @param {number} intPort The internal port on the computer to map to
* @param {number} extPort The external port on the router to map to
* @param {number} lifetime Seconds that the mapping will last
* @param {array} nonce (Optional) A specified nonce for the PCP request
* @return {Promise<ArrayBuffer>} A promise that fulfills with the PCP response
* or rejects on timeout
*/
var sendPcpRequest = function (routerIp, privateIp, intPort, extPort, lifetime,
nonce) {
var socket
// Pre-process nonce and privateIp arguments
if (nonce === undefined) {
nonce = [utils.randInt(0, 0xffffffff),
utils.randInt(0, 0xffffffff),
utils.randInt(0, 0xffffffff)
]
}
var ipOctets = ipaddr.IPv4.parse(privateIp).octets
// Bind a socket and send the PCP request from that socket to routerIp
var _sendPcpRequest = new Promise(function (F, R) {
socket = dgram.createSocket('udp4')
// Fulfill when we get any reply (failure is on timeout in wrapper function)
socket.on('message', function (pcpResponse) {
// utils.closeSocket(socket)
F(pcpResponse)
})
// Bind a UDP port and send a PCP request
socket.bind('0.0.0.0', 0, err => {
if (err) return
// PCP packet structure: https://tools.ietf.org/html/rfc6887#section-11.1
var pcpBuffer = utils.createArrayBuffer(60, [
[32, 0, 0x2010000],
[32, 4, lifetime],
[16, 18, 0xffff],
[8, 20, ipOctets[0]],
[8, 21, ipOctets[1]],
[8, 22, ipOctets[2]],
[8, 23, ipOctets[3]],
[32, 24, nonce[0]],
[32, 28, nonce[1]],
[32, 32, nonce[2]],
[8, 36, 17],
[16, 40, intPort],
[16, 42, extPort],
[16, 54, 0xffff]
])
socket.send(pcpBuffer, 5351, routerIp)
})
})
// Give _sendPcpRequest 2 seconds before timing out
return Promise.race([
utils.countdownReject(2000, 'No PCP response', function () {
utils.closeSocket(socket)
}),
_sendPcpRequest
])
}
module.exports = {
probeSupport: probeSupport,
addMapping: addMapping,
deleteMapping: deleteMapping
}