Skip to content

Commit 4365439

Browse files
committed
feat: recover from DNS failures
This adds support for recovery from DNS lookup failures, and support recovery for all HTTP Codes >= 400 Right now the only use is to support .eth TLD via .eth.link gateway, however in the future this could provide means of working around censorship (eg. by executing DNSLink lookup over libp2p as a fallback)
1 parent 614da95 commit 4365439

File tree

3 files changed

+84
-85
lines changed

3 files changed

+84
-85
lines changed

add-on/src/lib/dnslink.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,7 @@ module.exports = function createDnslinkResolver (getState) {
180180
if (typeof url === 'string') {
181181
url = new URL(url)
182182
}
183-
const fqdn = url.hostname
184-
return `/ipns/${fqdn}${url.pathname}${url.search}${url.hash}`
183+
return `/ipns/${url.hostname}${url.pathname}${url.search}${url.hash}`
185184
},
186185

187186
// Test if URL contains a valid DNSLink FQDN

add-on/src/lib/ipfs-request.js

Lines changed: 55 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,20 @@ const LRU = require('lru-cache')
99
const IsIpfs = require('is-ipfs')
1010
const isFQDN = require('is-fqdn')
1111
const { pathAtHttpGateway } = require('./ipfs-path')
12+
1213
const redirectOptOutHint = 'x-ipfs-companion-no-redirect'
13-
const recoverableErrors = new Set([
14+
const recoverableNetworkErrors = new Set([
1415
// Firefox
16+
'NS_ERROR_UNKNOWN_HOST', // dns failure
1517
'NS_ERROR_NET_TIMEOUT', // eg. httpd is offline
1618
'NS_ERROR_NET_RESET', // failed to load because the server kept reseting the connection
1719
'NS_ERROR_NET_ON_RESOLVED', // no network
1820
// Chrome
21+
'net::ERR_NAME_NOT_RESOLVED', // dns failure
1922
'net::ERR_CONNECTION_TIMED_OUT', // eg. httpd is offline
2023
'net::ERR_INTERNET_DISCONNECTED' // no network
2124
])
22-
23-
const recoverableErrorCodes = new Set([
24-
404,
25-
408,
26-
410,
27-
415,
28-
451,
29-
500,
30-
502,
31-
503,
32-
504,
33-
509,
34-
520,
35-
521,
36-
522,
37-
523,
38-
524,
39-
525,
40-
526
41-
])
25+
const recoverableHttpError = (code) => code && code >= 400
4226

4327
// Request modifier provides event listeners for the various stages of making an HTTP request
4428
// API Details: https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest
@@ -171,11 +155,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
171155
// This is a good place to listen if you want to modify HTTP request headers.
172156
onBeforeSendHeaders (request) {
173157
const state = getState()
174-
175-
// Skip if IPFS integrations are inactive
176-
if (!state.active) {
177-
return
178-
}
158+
if (!state.active) return
179159

180160
// Special handling of requests made to API
181161
if (request.url.startsWith(state.apiURLString)) {
@@ -286,11 +266,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
286266
// You can use this event to modify HTTP response headers or do a very late redirect.
287267
onHeadersReceived (request) {
288268
const state = getState()
289-
290-
// Skip if IPFS integrations are inactive
291-
if (!state.active) {
292-
return
293-
}
269+
if (!state.active) return
294270

295271
// Special handling of requests made to API
296272
if (request.url.startsWith(state.apiURLString)) {
@@ -387,58 +363,48 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
387363
},
388364

389365
// browser.webRequest.onErrorOccurred
390-
// Fired when a request could not be processed due to an error:
391-
// for example, a lack of Internet connectivity.
366+
// Fired when a request could not be processed due to an error on network level.
367+
// For example: TCP timeout, DNS lookup failure
392368
async onErrorOccurred (request) {
393369
const state = getState()
394-
395-
// Skip if IPFS integrations are inactive or request is marked as ignored
396-
if (!state.active || isIgnored(request.requestId)) {
397-
return
370+
if (!state.active) return
371+
372+
// Check if error can be recovered via DNSLink
373+
if (isRecoverableViaDNSLink(request, state, dnslinkResolver)) {
374+
const url = new URL(request.url)
375+
let dnslink = dnslinkResolver.readAndCacheDnslink(url.hostname)
376+
if (!dnslink && url.hostname.endsWith('.eth')) {
377+
// negative for .eth usually means the lack of support for the tld
378+
// we retry via EthDNS at eth.link as a fallback
379+
url.hostname = `${url.hostname}.link` // TODO this is needed until go-ipfs v0.5.0 ships with https://github.com/ipfs/go-ipfs/pull/6448
380+
dnslink = dnslinkResolver.readAndCacheDnslink(url.hostname)
381+
}
382+
const redirect = dnslinkResolver.dnslinkRedirect(url.toString(), dnslink)
383+
log(`onErrorOccurred: attempting to recover from network error (${request.error}) using dnslink for ${url.toString()}`, redirect.redirectUrl)
384+
return createTabWithURL(redirect, browser)
398385
}
399386

400-
// console.log('onErrorOccurred:' + request.error)
401-
// console.log('onErrorOccurred', request)
402-
// Check if error is final and can be recovered via DNSLink
403-
let redirect
404-
const recoverableViaDnslink =
405-
state.dnslinkPolicy &&
406-
request.type === 'main_frame' &&
407-
recoverableErrors.has(request.error)
408-
if (recoverableViaDnslink && dnslinkResolver.canLookupURL(request.url)) {
409-
// Explicit call to ignore global DNSLink policy and force DNS TXT lookup
410-
const cachedDnslink = dnslinkResolver.readAndCacheDnslink(new URL(request.url).hostname)
411-
redirect = dnslinkResolver.dnslinkRedirect(request.url, cachedDnslink)
412-
log(`onErrorOccurred: attempting to recover using dnslink for ${request.url}`, redirect)
413-
}
414-
// if error cannot be recovered via DNSLink
415-
// direct the request to the public gateway
416-
const recoverable = isRecoverable(request, state, ipfsPathValidator)
417-
if (!redirect && recoverable) {
387+
// Check if error can be recovered by opening same content-addresed path
388+
// using active gateway (public or local, depending on redirect state)
389+
if (isRecoverable(request, state, ipfsPathValidator)) {
418390
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
419-
redirect = { redirectUrl }
420-
log(`onErrorOccurred: attempting to recover failed request for ${request.url}`, redirect)
421-
}
422-
// We can't redirect in onErrorOccurred, so if DNSLink is present
423-
// recover by opening IPNS version in a new tab
424-
// TODO: add tests and demo
425-
if (redirect) {
426-
createTabWithURL(redirect, browser)
391+
log(`onErrorOccurred: attempting to recover from network error (${request.error}) for ${request.url}`, redirectUrl)
392+
return createTabWithURL({ redirectUrl }, browser)
427393
}
428394
},
429395

396+
// browser.webRequest.onCompleted
397+
// Fired when HTTP request is completed (successfully or with an error code)
430398
async onCompleted (request) {
431399
const state = getState()
432-
433-
const recoverable =
434-
isRecoverable(request, state, ipfsPathValidator) &&
435-
recoverableErrorCodes.has(request.statusCode)
436-
if (recoverable) {
400+
if (!state.active) return
401+
if (request.statusCode === 200) return // finish if no error to recover from
402+
if (isRecoverable(request, state, ipfsPathValidator)) {
437403
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
438404
const redirect = { redirectUrl }
439405
if (redirect) {
440-
log(`onCompleted: attempting to recover failed request for ${request.url}`, redirect)
441-
createTabWithURL(redirect, browser)
406+
log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url}`, redirect)
407+
return createTabWithURL(redirect, browser)
442408
}
443409
}
444410
}
@@ -548,18 +514,32 @@ function findHeaderIndex (name, headers) {
548514
return headers.findIndex(x => x.name && x.name.toLowerCase() === name.toLowerCase())
549515
}
550516

551-
// utility functions for handling redirects
552-
// from onErrorOccurred and onCompleted
517+
// RECOVERY OF FAILED REQUESTS
518+
// ===================================================================
519+
520+
// Recovery check for onErrorOccurred (request.error) and onCompleted (request.statusCode)
553521
function isRecoverable (request, state, ipfsPathValidator) {
554522
return state.recoverFailedHttpRequests &&
523+
request.type === 'main_frame' &&
524+
(recoverableNetworkErrors.has(request.error) || recoverableHttpError(request.statusCode)) &&
555525
ipfsPathValidator.publicIpfsOrIpnsResource(request.url) &&
556-
!request.url.startsWith(state.pubGwURLString) &&
557-
request.type === 'main_frame'
526+
!request.url.startsWith(state.pubGwURLString)
527+
}
528+
529+
// Recovery check for onErrorOccurred (request.error)
530+
function isRecoverableViaDNSLink (request, state, dnslinkResolver) {
531+
const recoverableViaDnslink =
532+
request.type === 'main_frame' &&
533+
state.dnslinkPolicy &&
534+
recoverableNetworkErrors.has(request.error)
535+
return recoverableViaDnslink && dnslinkResolver.canLookupURL(request.url)
558536
}
559537

538+
// We can't redirect in onErrorOccurred/onCompleted
539+
// Indead, we recover by opening URL in a new tab that replaces the failed one
560540
async function createTabWithURL (redirect, browser) {
561541
const currentTabId = await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0].id)
562-
await browser.tabs.create({
542+
return browser.tabs.create({
563543
active: true,
564544
openerTabId: currentTabId,
565545
url: redirect.redirectUrl

test/functional/lib/ipfs-request-gateway-recover.test.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const urlRequestWithStatus = (url, statusCode = 200, type = 'main_frame') => {
1818
return { ...url2request(url, type), statusCode }
1919
}
2020

21-
describe('requestHandler.onCompleted:', function () {
21+
const urlRequestWithNetworkError = (url, error = 'net::ERR_CONNECTION_TIMED_OUT', type = 'main_frame') => {
22+
return { ...url2request(url, type), error }
23+
}
24+
25+
describe('requestHandler.onCompleted:', function () { // HTTP-level errors
2226
let state, dnslinkResolver, ipfsPathValidator, requestHandler, runtime
2327

2428
before(function () {
@@ -40,6 +44,7 @@ describe('requestHandler.onCompleted:', function () {
4044
describe('with recoverFailedHttpRequests=true', function () {
4145
beforeEach(function () {
4246
state.recoverFailedHttpRequests = true
47+
state.dnslinkPolicy = false
4348
})
4449
it('should do nothing if broken request is a non-IPFS request', async function () {
4550
const request = urlRequestWithStatus('https://wikipedia.org', 500)
@@ -71,6 +76,7 @@ describe('requestHandler.onCompleted:', function () {
7176
describe('with recoverFailedHttpRequests=false', function () {
7277
beforeEach(function () {
7378
state.recoverFailedHttpRequests = false
79+
state.dnslinkPolicy = false
7480
})
7581
it('should do nothing on broken non-default public gateway IPFS request', async function () {
7682
const request = urlRequestWithStatus('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
@@ -90,7 +96,7 @@ describe('requestHandler.onCompleted:', function () {
9096
})
9197
})
9298

93-
describe('requestHandler.onErrorOccurred:', function () {
99+
describe('requestHandler.onErrorOccurred:', function () { // network errors
94100
let state, dnslinkResolver, ipfsPathValidator, requestHandler, runtime
95101

96102
before(function () {
@@ -112,40 +118,54 @@ describe('requestHandler.onErrorOccurred:', function () {
112118
describe('with recoverFailedHttpRequests=true', function () {
113119
beforeEach(function () {
114120
state.recoverFailedHttpRequests = true
121+
state.dnslinkPolicy = false
115122
})
116123
it('should do nothing if failed request is a non-IPFS request', async function () {
117-
const request = url2request('https://wikipedia.org', 500)
124+
const request = urlRequestWithNetworkError('https://wikipedia.org')
118125
await requestHandler.onErrorOccurred(request)
119126
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
120127
})
121128
it('should do nothing if failed request is a non-public IPFS request', async function () {
122-
const request = url2request('http://127.0.0.1:8080/ipfs/QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
129+
const request = urlRequestWithNetworkError('http://127.0.0.1:8080/ipfs/QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
123130
await requestHandler.onErrorOccurred(request)
124131
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
125132
})
126133
it('should do nothing if failed request is to the default public gateway', async function () {
127-
const request = url2request('https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
134+
const request = urlRequestWithNetworkError('https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
128135
await requestHandler.onErrorOccurred(request)
129136
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
130137
})
131138
it('should do nothing if failed request is not a \'main_frame\' request', async function () {
132-
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 'stylesheet')
139+
const requestType = 'stylesheet'
140+
const request = urlRequestWithNetworkError('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 'net::ERR_NAME_NOT_RESOLVED', requestType)
133141
await requestHandler.onErrorOccurred(request)
134142
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
135143
})
136144
it('should redirect failed non-default public gateway IPFS request to public gateway', async function () {
137-
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
145+
const request = urlRequestWithNetworkError('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
138146
await requestHandler.onErrorOccurred(request)
139147
assert.ok(browser.tabs.create.withArgs({ url: 'https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with IPFS default public gateway URL')
140148
})
149+
it('should redirect failed .eth requests to .eth.link on public gateway', async function () {
150+
state.dnslinkPolicy = 'best-effort'
151+
dnslinkResolver.setDnslink('almonit.eth', false)
152+
dnslinkResolver.setDnslink('almonit.eth.link', '/ipfs/QmPH7VMnfFKvrr7kLXNRwuxjYRLWnfcxPvnWs8ipyWAQK2')
153+
const dnsFailure = 'net::ERR_NAME_NOT_RESOLVED' // chrome code
154+
const expectedUrl = 'http://127.0.0.1:8080/ipns/almonit.eth.link/'
155+
const request = urlRequestWithNetworkError('https://almonit.eth', dnsFailure)
156+
await requestHandler.onErrorOccurred(request)
157+
assert.ok(browser.tabs.create.withArgs({ url: expectedUrl, active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with ENS resource on local gateway URL')
158+
dnslinkResolver.clearCache()
159+
})
141160
})
142161

143162
describe('with recoverFailedHttpRequests=false', function () {
144163
beforeEach(function () {
145164
state.recoverFailedHttpRequests = false
165+
state.dnslinkPolicy = false
146166
})
147167
it('should do nothing on failed non-default public gateway IPFS request', async function () {
148-
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
168+
const request = urlRequestWithNetworkError('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
149169
await requestHandler.onErrorOccurred(request)
150170
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
151171
})

0 commit comments

Comments
 (0)