Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: *.localhost subdomain gateway support with http proxy #853

Merged
merged 12 commits into from
Apr 5, 2020
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ build
npm-debug.log
yarn-error.log
crowdin.yml
.connect-deps*
.*~
add-on/dist
add-on/webui/
Expand Down
10 changes: 9 additions & 1 deletion add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@
"message": "Redirect requests for IPFS resources to the Custom gateway",
"description": "An option description on the Preferences screen (option_useCustomGateway_description)"
},
"option_useSubdomains_title": {
"message": "Use Subdomains",
"description": "An option title on the Preferences screen (option_useSubdomains_title)"
},
"option_useSubdomains_description": {
"message": "Isolate content roots from each other by loading them from subdomains at *.localhost and creating a unique Origin for each CID, IPNS or DNSLink record. Requires a local go-ipfs 0.5.0 or later.",
"description": "An option description on the Preferences screen (option_useSubdomains_description)"
},
Comment on lines +282 to +289
Copy link
Member Author

Choose a reason for hiding this comment

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

On Preferences screen the above labels look like this:

2020-04-03--02-42-59

@jessicaschilling @autonome I suspect this could be phrased in a more human way, but this is the best I could do for next Beta release. Suggestions welcome before we ship to Stable.

This is a description of a toggle that lets people to switch to old path-based gateway on 127.0.0.1 if they choose to do so for some reason. (disabling it stops HTTP proxy and swaps Custom Gateway URL to use raw IP)

"option_dnslinkRedirect_title": {
"message": "Load websites from Custom Gateway",
"description": "An option title on the Preferences screen (option_dnslinkRedirect_title)"
Expand All @@ -296,7 +304,7 @@
"description": "An option description on the Preferences screen (option_dnslinkDataPreload_description)"
},
"option_dnslinkRedirect_warning": {
"message": "Redirecting to a path-based gateway breaks Origin-based security isolation of DNSLink websites. Make sure you understand related risks.",
"message": "Avoid using this if your IPFS Node does not support *.ipfs.localhost. Redirecting to a path-based gateway breaks Origin-based security isolation of DNSLink websites. Make sure you understand related risks.",
"description": "A warning on the Preferences screen, displayed when URL does not belong to Secure Context (option_customGatewayUrl_warning)"
},
"option_noIntegrationsHostnames_title": {
Expand Down
2 changes: 1 addition & 1 deletion add-on/_locales/nl/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@
"description": "An option description on the Preferences screen (option_customGatewayUrl_description)"
},
"option_customGatewayUrl_warning": {
"message": "IPFS content will be blocked from loading on HTTPS websites unless your gateway URL starts with “http://127.0.0.1”, “http://[::1]” or “https://”",
"message": "IPFS content will be blocked from loading on HTTPS websites unless your gateway URL starts with “http://localhost”, “http://127.0.0.1”, “http://[::1]” or “https://”",
"description": "A warning on the Preferences screen, displayed when URL does not belong to Secure Context (option_customGatewayUrl_warning)"
},
"option_useCustomGateway_title": {
Expand Down
13 changes: 13 additions & 0 deletions add-on/manifest.chromium.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
{
"minimum_chrome_version": "72",
"permissions": [
"<all_urls>",
"idle",
"tabs",
"notifications",
"storage",
"unlimitedStorage",
"contextMenus",
"clipboardWrite",
"webNavigation",
"webRequest",
"webRequestBlocking"
],
"incognito": "not_allowed"
}
13 changes: 0 additions & 13 deletions add-on/manifest.common.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,6 @@
"38": "icons/png/ipfs-logo-on_38.png",
"128": "icons/png/ipfs-logo-on_128.png"
},
"permissions": [
"<all_urls>",
"idle",
"tabs",
"notifications",
"storage",
"unlimitedStorage",
"contextMenus",
"clipboardWrite",
"webNavigation",
"webRequest",
"webRequestBlocking"
],
"background": {
"page": "dist/background/background.html"
},
Expand Down
14 changes: 14 additions & 0 deletions add-on/manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@
"default_title": "__MSG_pageAction_titleNonIpfs__",
"default_popup": "dist/popup/page-action/index.html"
},
"permissions": [
"<all_urls>",
"idle",
"tabs",
"notifications",
"proxy",
"storage",
"unlimitedStorage",
"contextMenus",
"clipboardWrite",
"webNavigation",
"webRequest",
"webRequestBlocking"
],
"content_scripts": [ ],
"protocol_handlers": [
{
Expand Down
37 changes: 21 additions & 16 deletions add-on/src/lib/dnslink.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ const IsIpfs = require('is-ipfs')
const LRU = require('lru-cache')
const { default: PQueue } = require('p-queue')
const { offlinePeerCount } = require('./state')
const { pathAtHttpGateway } = require('./ipfs-path')

// TODO: add Preferences toggle to disable redirect of DNSLink websites (while keeping async dnslink lookup)
const { ipfsContentPath, sameGateway, pathAtHttpGateway } = require('./ipfs-path')

module.exports = function createDnslinkResolver (getState) {
// DNSLink lookup result cache
Expand Down Expand Up @@ -47,11 +45,11 @@ module.exports = function createDnslinkResolver (getState) {
return state.dnslinkPolicy &&
requestUrl.startsWith('http') &&
!IsIpfs.url(requestUrl) &&
!requestUrl.startsWith(state.apiURLString) &&
!requestUrl.startsWith(state.gwURLString)
!sameGateway(requestUrl, state.apiURL) &&
!sameGateway(requestUrl, state.gwURL)
},

dnslinkRedirect (url, dnslink) {
dnslinkAtGateway (url, dnslink) {
if (typeof url === 'string') {
url = new URL(url)
}
Expand All @@ -61,9 +59,8 @@ module.exports = function createDnslinkResolver (getState) {
// to load the correct path from IPFS
// - https://github.com/ipfs/ipfs-companion/issues/298
const ipnsPath = dnslinkResolver.convertToIpnsPath(url)
const gateway = state.ipfsNodeType === 'embedded' ? state.pubGwURLString : state.gwURLString
// TODO: redirect to `ipns://` if hasNativeProtocolHandler === true
return { redirectUrl: pathAtHttpGateway(ipnsPath, gateway) }
const gateway = state.localGwAvailable ? state.gwURLString : state.pubGwURLString
return pathAtHttpGateway(ipnsPath, gateway)
}
},

Expand Down Expand Up @@ -111,7 +108,7 @@ module.exports = function createDnslinkResolver (getState) {
preloadUrlCache.set(url, true)
const dnslink = await dnslinkResolver.resolve(url)
if (!dnslink) return
if (state.ipfsNodeType === 'embedded') return
if (!state.localGwAvailable) return
if (state.peerCount < 1) return
return preloadQueue.add(async () => {
const { pathname } = new URL(url)
Expand All @@ -128,7 +125,13 @@ module.exports = function createDnslinkResolver (getState) {
let apiProvider
// TODO: fix DNS resolver for ipfsNodeType='embedded:chromesockets', for now use ipfs.io
if (!state.ipfsNodeType.startsWith('embedded') && state.peerCount !== offlinePeerCount) {
apiProvider = state.apiURLString
// Use gw port so it can be a GET:
// Chromium does not execute onBeforeSendHeaders for synchronous calls
// made from the same extension context as onBeforeSendHeaders
// which means we are unable to fixup Origin on the fly for this
// This will no longer be needed when we switch
// to async lookup via ipfs.dns everywhere
apiProvider = state.gwURLString
} else {
// fallback to resolver at public gateway
apiProvider = 'https://ipfs.io/'
Expand Down Expand Up @@ -204,19 +207,21 @@ module.exports = function createDnslinkResolver (getState) {
// in url.hostname OR in url.pathname (/ipns/<fqdn>)
// and return matching FQDN if present
findDNSLinkHostname (url) {
const { hostname, pathname } = new URL(url)
// check //foo.tld/ipns/<fqdn>
if (IsIpfs.ipnsPath(pathname)) {
if (!url) return
// Normalize subdomain and path gateways to to /ipns/<fqdn>
const contentPath = ipfsContentPath(url)
if (IsIpfs.ipnsPath(contentPath)) {
// we may have false-positives here, so we do additional checks below
const ipnsRoot = pathname.match(/^\/ipns\/([^/]+)/)[1]
const ipnsRoot = contentPath.match(/^\/ipns\/([^/]+)/)[1]
// console.log('findDNSLinkHostname ==> inspecting IPNS root', ipnsRoot)
// Ignore PeerIDs, match DNSLink only
if (!IsIpfs.cid(ipnsRoot) && dnslinkResolver.readAndCacheDnslink(ipnsRoot)) {
// console.log('findDNSLinkHostname ==> found DNSLink for FQDN in url.pathname: ', ipnsRoot)
return ipnsRoot
}
}
// check //<fqdn>/foo/bar
// Check main hostname
const { hostname } = new URL(url)
if (dnslinkResolver.readAndCacheDnslink(hostname)) {
// console.log('findDNSLinkHostname ==> found DNSLink for url.hostname', hostname)
return hostname
Expand Down
155 changes: 155 additions & 0 deletions add-on/src/lib/http-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
'use strict'
/* eslint-env browser, webextensions */

const browser = require('webextension-polyfill')
const { safeURL } = require('./options')

const debug = require('debug')
const log = debug('ipfs-companion:http-proxy')
log.error = debug('ipfs-companion:http-proxy:error')

// Preface:
//
// When go-ipfs runs on localhost, it exposes two types of gateway:
// 127.0.0.1:8080 - old school path gateway
// localhost:8080 - subdomain gateway supporting Origins like $cid.ipfs.localhost
// More: https://docs-beta.ipfs.io/how-to/address-ipfs-on-web/#subdomain-gateway
//
// In a web browser contexts we care about Origin per content root (CID)
// because entire web security model uses it as a basis for sandboxing and
// access controls:
// https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy

// registerSubdomainProxy is necessary wourkaround for supporting subdomains
// under 'localhost' (*.ipfs.localhost) because some operating systems do not
// resolve them to local IP and return NX error not found instead
//
// State in Q2 2020:
// - Chromium hardcodes `localhost` name to point at local IP and proxy is not
// really necessary. The code is here (inactivE) in case we need it in the future.
// - Firefox requires proxy to avoid DNS lookup, but there is an open issue
// that will remove that need at some point:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1220810
async function registerSubdomainProxy (getState, runtime, notify) {
// At the moment only firefox requires proxy registration
if (!runtime.isFirefox) return

try {
const { active, useSubdomains, gwURLString } = getState()
const enable = active && useSubdomains

// HTTP Proxy feature is exposed on the gateway port
// Just ensure we use localhost IP to remove any dependency on DNS
const { hostname, port } = safeURL(gwURLString, { useLocalhostName: false })

// Firefox uses own APIs for selective proxying
if (runtime.isFirefox) {
return await registerSubdomainProxyFirefox(enable, hostname, port)
}

// At this point we would asume Chromium, but its not needed atm
// Uncomment below if ever needed (+ add 'proxy' permission to manifest.json)
// return await registerSubdomainProxyChromium(enable, hostname, port)
} catch (err) {
// registerSubdomainProxy is just a failsafe, not necessary in most cases,
// so we should not break init when it fails.
// For now we just log error and exit as NOOP
log.error('registerSubdomainProxy failed', err)
// Show pop-up only the first time, during init() when notify is passed
try {
if (notify) notify('notify_addonIssueTitle', 'notify_addonIssueMsg')
} catch (_) {
}
}
}

// storing listener for later
var onRequestProxyListener

// registerSubdomainProxyFirefox sets proxy using API available in Firefox
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/onRequest
async function registerSubdomainProxyFirefox (enable, hostname, port) {
const { onRequest } = browser.proxy

// always remove the old listener (host and port could change)
const oldListener = onRequestProxyListener
if (oldListener && onRequest.hasListener(oldListener)) {
onRequest.removeListener(oldListener)
}

if (enable) {
// create new listener with the latest host:port note: the listener is
// handling requests made to all localhost ports (limitation of the API,
// port is ignored) that is why we manually check port inside of the listener
onRequestProxyListener = (request) => {
if (new URL(request.url).port === port) {
return { type: 'http', host: hostname, port }
}
return { type: 'direct' }
}

// register the listener
onRequest.addListener(onRequestProxyListener, {
urls: ['http://*.localhost/*'],
incognito: false
})
log(`enabled ${hostname}:${port} as HTTP proxy for *.localhost`)
return
}

// at this point we effectively disabled proxy
log('disabled HTTP proxy for *.localhost')
}

/*
* Chromium 80 does not need proxy, so below is not used.
* Uncomment below if ever needed (+ add 'proxy' permission to manifest.json)

// Helpers for converting callback chrome.* API to promises
const cb = (resolve, reject) => (result) => {
const err = chrome.runtime.lastError
if (err) return reject(err)
return resolve(result)
}
const get = async (opts) => new Promise((resolve, reject) => chrome.proxy.settings.get(opts, cb(resolve, reject)))
const set = async (opts) => new Promise((resolve, reject) => chrome.proxy.settings.set(opts, cb(resolve, reject)))
const clear = async (opts) => new Promise((resolve, reject) => chrome.proxy.settings.clear(opts, cb(resolve, reject)))

// registerSubdomainProxyChromium sets proxy using API available in Chromium
// https://developer.chrome.com/extensions/proxy
async function registerSubdomainProxyChromium (enable, hostname, port) {
const scope = 'regular_only'

// read current proxy settings
const settings = await get({ incognito: false })

// set or update, if enabled
if (enable) {
// PAC script enables selective routing to PROXY at host+port
// here, PROXY is the same as HTTP API endpoint
const pacConfig = {
mode: 'pac_script',
pacScript: {
data: 'function FindProxyForURL(url, host) {\n' +
` if (shExpMatch(host, '*.localhost:${port}'))\n` +
` return 'PROXY ${hostname}:${port}';\n` +
" return 'DIRECT';\n" +
'}'
}
}
await set({ value: pacConfig, scope })
log(`enabled ${hostname}:${port} as HTTP proxy for *.localhost`)
// log('updated chrome.proxy.settings', await get({ incognito: false }))
return
}

// else: remove any existing proxy settings
if (settings && settings.levelOfControl === 'controlled_by_this_extension') {
// remove any proxy settings ipfs-companion set up before
await clear({ scope })
log('disabled HTTP proxy for *.localhost')
}
}
*/

module.exports.registerSubdomainProxy = registerSubdomainProxy
Loading