Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions lib/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ async function holepunch(c, opts) {
let { relayAddress, serverAddress, clientAddress, payload } = c.connect

const remoteHolepunchable = !!(payload.holepunch && payload.holepunch.relays.length)

const relayed = diffAddress(serverAddress, relayAddress)

if (payload.firewall === FIREWALL.OPEN || (relayed && !remoteHolepunchable)) {
Expand All @@ -243,7 +242,9 @@ async function holepunch(c, opts) {
return
}

// TODO: would be better to just try local addrs in the background whilst continuing with other strategies...
// Race the LAN shortcut with normal holepunch when possible. A blocked LAN ping
// should not abort a connection that can still holepunch. If the remote is not
// holepunchable, the LAN ping is the only remaining path.
if (c.lan && relayed && clientAddress.host === serverAddress.host) {
const serverAddresses = payload.addresses4.filter(onlyNonReserved)

Expand All @@ -252,14 +253,21 @@ async function holepunch(c, opts) {
const addr = Holepuncher.matchAddress(myAddresses, serverAddresses) || serverAddresses[0]

const socket = c.dht.io.serverSocket
try {
await c.dht.ping(addr)
} catch {
maybeDestroyEncryptedSocket(c, HOLEPUNCH_ABORTED())
return
c.dht.ping(addr).then(onlanconnect, onlanerror)
if (!remoteHolepunchable) return

function onlanconnect() {
// LAN and holepunch completions both converge through onsocket,
// which claims c.rawStream once and ignores later winners.
if (isDone(c)) return
c.onsocket(socket, addr.port, addr.host)
}

function onlanerror() {
if (!remoteHolepunchable) {
maybeDestroyEncryptedSocket(c, HOLEPUNCH_ABORTED())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing at @marcus-pousette-hp's request...

For context, I'm familiar with this NAT traversal IP candidate optimization problem, but not yet familiar with hyperdht's implementation. First time poking around this code.

I think this change is a clever race which AFAICT solves the problem correctly.

Only thing I wasn't sure about is the condition when holepunching is mid-flight when the LAN ping succeeds, and so isDone(c) = false. But I guess c.onsocket is the guard that resolves the race there?

Zooming out: the race paths are now pretty subtle and challenging to reason about, probably a bit too subtle for my taste. But I suppose a different structure would trigger a much larger refactor, so this seems like a reasonable compromise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d7db4f1

I added a comment here, both isDone and c.onsocket correctly drop the other. The only real "issue" I would say here worth thinking about is that if the fallback (normal) Holepunch path wins, the LAN shortcut path will be ignored. In theory this would mean that we would take a little bit longer path than what the LAN shortcut path would offer

Context:
LAN shortcut
Uses the peer’s advertised local address, like 192.168.x.x / 10.x.x.x / loopback in the test. If both peers are truly reachable on that LAN address, this is usually the best path: fewer NAT hops, lower latency.

Normal holepunch path
Uses the externally observed/NAT address learned through relays/bootstrap nodes. If both peers share a public IP and the NAT supports hairpinning, this can still connect directly, but traffic may go through the router’s NAT hairpin path rather than straight peer-to-peer LAN addressing.

This PR makes the race and the faster wins, and there is an assumption here that if LAN shortcut is the fastest path it would win anyway here, but the shortcoming of this PR is perhaps it does not prove for example a bad scenario where the normal holepunch path would win the race, even though it is slower later when data is transmitting. My own judgement finds this unlikely, which is the reason why I went with this solution

}
c.onsocket(socket, addr.port, addr.host)
return
}
}

Expand Down
19 changes: 0 additions & 19 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const {
closeRelayConnection,
destroyRelayConnection
} = require('./relay-connection')
const { isPrivate } = require('bogon')
const { ALREADY_LISTENING, NODE_DESTROYED, KEYPAIR_ALREADY_USED } = require('./errors')

const HANDSHAKE_CLEAR_WAIT = 10000
Expand Down Expand Up @@ -423,20 +422,6 @@ module.exports = class Server extends EventEmitter {
if (hs.relayToken === null) hs.rawStream.destroy()
}

if (!direct && clientAddress.host === serverAddress.host) {
const clientAddresses = remotePayload.addresses4.filter(onlyPrivateHosts)

if (clientAddresses.length > 0 && this._shareLocalAddress) {
const myAddresses = await this._localAddresses()
const addr = Holepuncher.matchAddress(myAddresses, clientAddresses)

if (addr) {
hs.prepunching = setTimeout(onabort, HANDSHAKE_INITIAL_TIMEOUT)
return hs
}
}
}

if (this._closing || this.suspended) return null

if (ourRemoteAddr || this._neverPunch) {
Expand Down Expand Up @@ -714,10 +699,6 @@ function defaultCreateSecretStream(isInitiator, rawStream, opts) {
return new NoiseSecretStream(isInitiator, rawStream, opts)
}

function onlyPrivateHosts(addr) {
return isPrivate(addr.host)
}

function isRelay(relaySocket, socket, port, host) {
const stream = relaySocket.rawStream
if (!stream) return false
Expand Down
70 changes: 70 additions & 0 deletions test/connections.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,76 @@ test('createServer + connect - force holepunch', async function (t) {
await b.destroy()
})

test('createServer + connect - failed LAN ping falls back to holepunch', async function (t) {
const [boot] = await swarm(t)

// Use loopback to deterministically enter the same-LAN shortcut in CI.
// This does not model NAT; the injected ping failure below verifies that
// a blocked LAN shortcut still falls through to normal holepunch.
const bootstrap = [{ host: '127.0.0.1', port: boot.address().port }]
Comment thread
marcus-pousette-hp marked this conversation as resolved.
Outdated
const a = createDHT({ bootstrap, quickFirewall: false, ephemeral: true })
const b = createDHT({ bootstrap, quickFirewall: false, ephemeral: true })

await a.fullyBootstrapped()
await b.fullyBootstrapped()

const lc = t.test('socket lifecycle')
lc.plan(5)

const server = a.createServer(function (socket) {
lc.pass('server side opened')

socket.once('data', function (data) {
lc.alike(data, Buffer.from('hello'))
socket.end('world')
})
})

await server.listen()

const serverPort = a.io.serverSocket.address().port
const ping = b.ping.bind(b)
let blockedLanPing = false

b.ping = function (addr, opts) {
if (addr.host === '127.0.0.1' && addr.port === serverPort) {
blockedLanPing = true
return Promise.reject(new Error('LAN ping blocked'))
}
return ping(addr, opts)
}

const socket = b.connect(server.publicKey, {
fastOpen: false,
holepunch() {
lc.pass('client used normal holepunch')
return true
}
})

socket.once('open', function () {
lc.pass('client side opened')
socket.write('hello')
})

socket.once('data', function (data) {
lc.alike(data, Buffer.from('world'))
socket.end()
})

socket.once('error', function (err) {
lc.fail('client should not error: ' + err.code)
})

await lc

t.ok(blockedLanPing, 'exercised the LAN ping failure path')

await server.close()
await a.destroy()
await b.destroy()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the test is correct, but I do get a little nervous whenever I see this sort of thing tested against loopback IP. I think it's just out of domain, in the sense that the fix was designed to solve the opposite case -- the case where a NATed peer's public IP is not the same as its LAN candidate.

(I think here 127.0.0.1 winds up in localAddresses which is what makes the test work, right?)

Not suggesting any changes, it was just a bit challenging to reason about whether this test was actually exercising the right paths. Compromises for testing...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your concern. I added a clarifying comment in 0c06e40 about the test purpose

(I think here 127.0.0.1 winds up in localAddresses which is what makes the test work, right?)

Yeps! In this test 127.0.0.1 is intentionally used so Holepuncher.localAddresses(...) returns the loopback address and we deterministically enter the same LAN shortcut branch

test('server choosing to abort holepunch', async function (t) {
const [boot] = await swarm(t)

Expand Down
Loading