2022-10-21 23:24:43 +00:00
|
|
|
/* ========================================================================= *\
|
|
|
|
|* === HTTP(S) fetch() from alternative endpoints === *|
|
|
|
|
\* ========================================================================= */
|
|
|
|
|
|
|
|
/**
|
|
|
|
* this plugin does not implement any push method
|
|
|
|
*
|
|
|
|
* NOTICE: this plugin uses Promise.any()
|
|
|
|
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any
|
|
|
|
* the polyfill is implemented in LibResilient's service-worker.js
|
|
|
|
*/
|
|
|
|
|
|
|
|
// no polluting of the global namespace please
|
|
|
|
(function(LRPC){
|
|
|
|
// this never changes
|
|
|
|
const pluginName = "dnslink-fetch"
|
|
|
|
LRPC.set(pluginName, (LR, init={})=>{
|
|
|
|
|
|
|
|
/*
|
|
|
|
* plugin config settings
|
|
|
|
*/
|
|
|
|
|
|
|
|
// sane defaults
|
|
|
|
let defaultConfig = {
|
|
|
|
// how many simultaneous connections to different endpoints do we want
|
|
|
|
//
|
|
|
|
// more concurrency means higher chance of a request succeeding
|
2023-10-07 03:14:42 +00:00
|
|
|
// but uses more bandwidth and other resources.
|
|
|
|
//
|
|
|
|
// this is not about DoH resolution, only one DoH endpoint *can* be
|
|
|
|
// configured and used at any time. this is about concurrency once the
|
|
|
|
// alternative endpoints are already resolved. that is, how many endpoints
|
|
|
|
// are used simultaneously to pull the actual content.
|
2022-10-21 23:24:43 +00:00
|
|
|
//
|
|
|
|
// 3 seems to be a reasonable default
|
|
|
|
concurrency: 3,
|
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// DNS-over-HTTPS media type
|
|
|
|
//
|
|
|
|
// if this is set to "application/dns-message", the binary DNS wire format
|
|
|
|
// will be used and expected.
|
|
|
|
//
|
|
|
|
// if this is set to anything else, JSON format will be used and expected.
|
|
|
|
// when using the JSON format, it is probably safest to set it to "application/dns-json"
|
|
|
|
// as this media type seems to be accepted the widest and is required by CloudFlare.
|
|
|
|
//
|
|
|
|
// other often-used media types for DoH JSON API requests:
|
|
|
|
// - application/dns+json (RFC8427, registered with IANA)
|
|
|
|
// - application/x-javascript (Google, but accepts other media types as well)
|
|
|
|
// - application/json
|
|
|
|
//
|
|
|
|
// by default DoH JSON API is used
|
|
|
|
dohMediaType: "application/dns-json",
|
|
|
|
|
|
|
|
// should the requests to the DoH server be made using GET or POST HTTP method?
|
|
|
|
//
|
|
|
|
// this is *only* valid when dohMediaType above is set to "application/dns-message";
|
|
|
|
// otherwise it is ignored and GET HTTP method is used
|
|
|
|
dohMethod: "GET",
|
|
|
|
|
|
|
|
// DNS-over-HTTPS provider
|
2022-10-22 11:52:38 +00:00
|
|
|
//
|
|
|
|
// by default using Hostux DoH provider, info here:
|
|
|
|
// - https://dns.hostux.net/en/
|
|
|
|
//
|
|
|
|
// other known DoH JSON API providers:
|
|
|
|
// - 'https://cloudflare-dns.com/dns-query'
|
|
|
|
// - 'https://mozilla.cloudflare-dns.com/dns-query'
|
|
|
|
// - 'https://dns.google/resolve'
|
2023-10-07 03:14:42 +00:00
|
|
|
// - 'https://dns.alidns.com/resolve'
|
|
|
|
// - 'https://dns.quad9.net:5053/dns-query'
|
|
|
|
dohProvider: 'https://dns.hostux.net/dns-query'
|
2022-10-21 23:24:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// merge the defaults with settings from the init var
|
|
|
|
let config = {...defaultConfig, ...init}
|
2022-10-22 01:05:05 +00:00
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// reality check: dohMediaType must be a string
|
|
|
|
if (typeof(config.dohMediaType) !== "string" || (config.dohMediaType == '')) {
|
|
|
|
let err = new Error("dohMediaType not confgured")
|
|
|
|
console.error(err)
|
|
|
|
throw err
|
|
|
|
}
|
|
|
|
|
|
|
|
// reality check: dohMethod must be a string, and either "GET" or "POST"
|
|
|
|
// also, "POST" only makes sense when using "application/dns-message" dohMediaType
|
|
|
|
if (typeof(config.dohMethod) !== "string" || (config.dohMethod == '')) {
|
|
|
|
let err = new Error("dohMethod not confgured")
|
|
|
|
console.error(err)
|
|
|
|
throw err
|
|
|
|
} else {
|
|
|
|
// this should be uppercase
|
|
|
|
config.dohMethod = config.dohMethod.toUpperCase()
|
|
|
|
// and only GET or POST
|
|
|
|
if (config.dohMethod != "GET" && config.dohMethod != "POST") {
|
|
|
|
let err = new Error(`dohMethod can only be "GET" or "POST", but is set to: ${config.dohMethod}`)
|
|
|
|
console.error(err)
|
|
|
|
throw err
|
|
|
|
}
|
|
|
|
// "POST" only makes sense when dohMediaType is "application/dns-message"
|
|
|
|
if (config.dohMethod === "POST" && config.dohMediaType != "application/dns-message") {
|
|
|
|
// this is just a warning though, "POST" is just going to be ignored
|
|
|
|
LR.log(pluginName, 'Warning: "POST" dohMethod is going to be ignored as dohMediaType is not "application/dns-message"')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-22 01:05:05 +00:00
|
|
|
// reality check: dohProvider must be a string
|
|
|
|
if (typeof(config.dohProvider) !== "string" || (config.dohProvider == '')) {
|
|
|
|
let err = new Error("dohProvider not confgured")
|
|
|
|
console.error(err)
|
|
|
|
throw err
|
|
|
|
}
|
2023-10-07 03:14:42 +00:00
|
|
|
|
|
|
|
// externally defined functions
|
|
|
|
if (typeof self.importScripts !== 'undefined') {
|
|
|
|
LR.log(pluginName, 'importing doh.js')
|
|
|
|
self.importScripts("./lib/doh.js")
|
|
|
|
}
|
|
|
|
|
|
|
|
// check if doh.js was properly loaded
|
|
|
|
if (typeof resolveEndpointsBinary !== "function") {
|
|
|
|
throw new Error("resolveEndpointsBinary() is not defined, is doh.js loaded?")
|
|
|
|
}
|
|
|
|
if (typeof resolveEndpointsJSON !== "function") {
|
|
|
|
throw new Error("resolveEndpointsJSON() is not defined, is doh.js loaded?")
|
|
|
|
}
|
|
|
|
|
2022-10-21 23:24:43 +00:00
|
|
|
/**
|
|
|
|
* retrieving the alternative endpoints list from dnslink
|
2023-10-07 03:14:42 +00:00
|
|
|
* using DNS-over-HTTPS
|
2022-10-21 23:24:43 +00:00
|
|
|
*
|
2023-10-07 03:14:42 +00:00
|
|
|
* returns an array of strings, each being a valid endpoint,
|
|
|
|
* in the form of
|
2022-10-21 23:24:43 +00:00
|
|
|
* scheme://example.org[/optional/path]
|
|
|
|
*/
|
|
|
|
let resolveEndpoints = async (domain) => {
|
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// first, we need the actual name we're asking about
|
2022-10-21 23:24:43 +00:00
|
|
|
//
|
2023-10-07 03:14:42 +00:00
|
|
|
// this needs to be ASCII-encoded, so if we want an IDN domain
|
|
|
|
// it needs to be punycode-encoded!
|
|
|
|
// TODO: we should *probably* do that ourselves here...
|
|
|
|
let dnslink_name = `_dnslink.${domain}`
|
2022-10-21 23:24:43 +00:00
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// response we will need to process a bit
|
|
|
|
let response = []
|
2022-10-21 23:24:43 +00:00
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// we can either resolve them using the binary DoH DNS wire format request...
|
|
|
|
if (config.dohMediaType === 'application/dns-message') {
|
|
|
|
// defined in doh.js
|
|
|
|
response = await resolveEndpointsBinary(dnslink_name, config, (msg)=>{LR.log(pluginName, msg)})
|
2022-10-21 23:24:43 +00:00
|
|
|
|
2023-10-07 03:14:42 +00:00
|
|
|
// ...or a DoH JSON API request
|
|
|
|
} else {
|
|
|
|
// defined in doh.js
|
|
|
|
response = await resolveEndpointsJSON(dnslink_name, config)
|
2022-10-21 23:24:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// filter by 'dnslink="/https?/', morph into scheme://...
|
2022-10-22 11:52:38 +00:00
|
|
|
// we can't rely on the data not to be wrapped in quotation marks, so we need to correct for that too
|
|
|
|
let re = /^"?dnslink=\/(https?)\/([^"]+)"?$/
|
2022-10-21 23:24:43 +00:00
|
|
|
response = response
|
|
|
|
.filter(r => re.test(r))
|
|
|
|
.map(r => r.replace(re, "$1:\/\/$2"));
|
|
|
|
|
|
|
|
// do we have anything to work with?
|
|
|
|
if (response.length < 1) {
|
|
|
|
throw new Error(`No TXT record contained http or https endpoint definition`)
|
|
|
|
}
|
|
|
|
|
|
|
|
// in case we need some debugging
|
2023-10-07 03:14:42 +00:00
|
|
|
LR.log(pluginName, '+-- alternative endpoints from DNSLink:\n - ' + response.join('"\n - '))
|
2022-10-21 23:24:43 +00:00
|
|
|
|
|
|
|
// this should be what we're looking for - an array of URLs
|
|
|
|
return response
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* getting content using regular HTTP(S) fetch()
|
|
|
|
*/
|
|
|
|
let fetchContentFromAlternativeEndpoints = async (url, init={}) => {
|
|
|
|
|
|
|
|
// remove the https://original.domain/ bit to get the relative path
|
|
|
|
// TODO: this assumes that URLs we handle are always relative to the root
|
|
|
|
// TODO: of the original domain, this needs to be documented
|
2023-09-26 20:32:54 +00:00
|
|
|
var urlData = url.replace(/https?:\/\//, '').split('/')
|
2022-10-22 01:05:05 +00:00
|
|
|
var domain = urlData.shift()
|
|
|
|
var path = urlData.join('/')
|
2022-10-21 23:24:43 +00:00
|
|
|
LR.log(pluginName, '+-- fetching:\n',
|
|
|
|
` - domain: ${domain}\n`,
|
|
|
|
` - path: ${path}\n`
|
|
|
|
)
|
|
|
|
|
|
|
|
// we really want to make fetch happen, Regina!
|
|
|
|
// TODO: this change should *probably* be handled on the Service Worker level
|
|
|
|
init.cache = 'reload'
|
|
|
|
|
|
|
|
// we don't want to modify the original endpoints array
|
|
|
|
var sourceEndpoints = await resolveEndpoints(domain)
|
|
|
|
|
|
|
|
// if we have fewer than the configured concurrency or just as many, use all of them
|
|
|
|
if (sourceEndpoints.length <= config.concurrency) {
|
|
|
|
var useEndpoints = sourceEndpoints
|
|
|
|
|
|
|
|
// otherwise get `config.concurrency` endpoints at random
|
|
|
|
} else {
|
|
|
|
var useEndpoints = new Array()
|
|
|
|
while (useEndpoints.length < config.concurrency) {
|
|
|
|
useEndpoints.push(
|
|
|
|
sourceEndpoints
|
|
|
|
.splice(Math.floor(Math.random() * sourceEndpoints.length), 1)[0]
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// add the rest of the path to each endpoint
|
|
|
|
useEndpoints.forEach((endpoint, index) => {
|
|
|
|
useEndpoints[index] = endpoint + '/' + path;
|
|
|
|
});
|
|
|
|
|
|
|
|
// debug log
|
|
|
|
LR.log(pluginName, `+-- fetching from alternative endpoints:\n - ${useEndpoints.join('\n - ')}`)
|
|
|
|
|
|
|
|
return Promise.any(
|
|
|
|
useEndpoints.map(
|
|
|
|
u=>fetch(u, init)
|
|
|
|
))
|
|
|
|
.then((response) => {
|
2024-03-06 16:33:22 +00:00
|
|
|
|
2022-10-21 23:24:43 +00:00
|
|
|
// all good, it seems
|
|
|
|
LR.log(pluginName, "fetched:", response.url);
|
|
|
|
|
|
|
|
// we need to create a new Response object
|
|
|
|
// with all the headers added explicitly,
|
|
|
|
// since response.headers is immutable
|
|
|
|
var responseInit = {
|
|
|
|
status: response.status,
|
|
|
|
statusText: response.statusText,
|
|
|
|
headers: {},
|
|
|
|
url: url
|
|
|
|
};
|
|
|
|
response.headers.forEach(function(val, header){
|
|
|
|
responseInit.headers[header] = val;
|
|
|
|
});
|
|
|
|
|
|
|
|
// add the X-LibResilient-* headers to the mix
|
|
|
|
responseInit.headers['X-LibResilient-Method'] = pluginName
|
|
|
|
|
|
|
|
// we will not have it most of the time, due to CORS rules:
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header
|
|
|
|
responseInit.headers['X-LibResilient-ETag'] = response.headers.get('ETag')
|
|
|
|
if (responseInit.headers['X-LibResilient-ETag'] === null) {
|
|
|
|
// far from perfect, but what are we going to do, eh?
|
|
|
|
responseInit.headers['X-LibResilient-ETag'] = response.headers.get('last-modified')
|
|
|
|
}
|
|
|
|
|
|
|
|
// return the new response, using the Blob from the original one
|
|
|
|
return response
|
|
|
|
.blob()
|
|
|
|
.then((blob) => {
|
|
|
|
return new Response(
|
|
|
|
blob,
|
|
|
|
responseInit
|
|
|
|
)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// return the plugin data structure
|
|
|
|
return {
|
|
|
|
name: pluginName,
|
|
|
|
description: 'HTTP(S) fetch() using alternative endpoints retrieved via DNSLink',
|
|
|
|
version: 'COMMIT_UNKNOWN',
|
|
|
|
fetch: fetchContentFromAlternativeEndpoints
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
// done with not polluting the global namespace
|
|
|
|
})(LibResilientPluginConstructors)
|