350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/proxy.json
|
|
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/schemas/proxy.json
|
|
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1804693
|
|
// Setting single proxy for all fails
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=1495756
|
|
// Issue 1495756: Support bypassList for PAC scripts in the chrome.proxy API
|
|
// https://chromium-review.googlesource.com/c/chromium/src/+/5227338
|
|
// Implement bypassList for PAC scripts in chrome.proxy API
|
|
// Chrome bypassList applies to 'fixed_servers', not 'pac_script' or URL
|
|
// Firefox passthrough applies to all set in proxy.settings.set, i.e. PAC URL
|
|
// manual bypass list:
|
|
// Chrome: pac_script data, not possible for URL
|
|
// Firefox proxy.onRequest
|
|
|
|
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/parent/ext-proxy.js#236
|
|
// throw new ExtensionError("proxy.settings is not supported on android.");
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1725981
|
|
// Support proxy.settings API on Android
|
|
|
|
import {App} from './app.js';
|
|
import {Authentication} from './authentication.js';
|
|
import {OnRequest} from './on-request.js';
|
|
import {Location} from './location.js';
|
|
import {Pattern} from './pattern.js';
|
|
import {Action} from './action.js';
|
|
import {Menus} from './menus.js';
|
|
|
|
export class Proxy {
|
|
|
|
static {
|
|
// from popup.js & options.js
|
|
browser.runtime.onMessage.addListener((...e) => this.onMessage(...e));
|
|
}
|
|
|
|
static onMessage(message) {
|
|
// noDataChange comes from popup.js & test.js
|
|
const {id, pref, host, proxy, dark, tab, noDataChange} = message;
|
|
switch (id) {
|
|
case 'setProxy':
|
|
Action.dark = dark;
|
|
this.set(pref, noDataChange);
|
|
break;
|
|
|
|
case 'includeHost':
|
|
case 'excludeHost':
|
|
// proxy object reference to pref is lost in chrome when sent from popup.js
|
|
const pxy = pref.data.find(i => i.active && host === `${i.hostname}:${i.port}`);
|
|
this.includeHost(pref, pxy, tab, id);
|
|
break;
|
|
|
|
case 'setTabProxy':
|
|
OnRequest.setTabProxy(tab, proxy);
|
|
break;
|
|
|
|
case 'getTabProxy':
|
|
// need to return a promise for 'getTabProxy' from popup.js
|
|
return Promise.resolve(OnRequest.tabProxy[tab.id]);
|
|
|
|
case 'getIP':
|
|
this.getIP();
|
|
break;
|
|
}
|
|
}
|
|
|
|
static async set(pref, noDataChange) {
|
|
// check if proxy.settings is controlled_by_this_extension
|
|
const conf = await this.getSettings();
|
|
// not controlled_by_this_extension
|
|
if (!conf) { return; }
|
|
|
|
// --- update authentication data
|
|
noDataChange || Authentication.init(pref.data);
|
|
|
|
// --- update menus
|
|
noDataChange || Menus.init(pref);
|
|
|
|
// --- check mode
|
|
switch (true) {
|
|
// no proxy, set to disable
|
|
case !pref.data[0]:
|
|
pref.mode = 'disable';
|
|
break;
|
|
|
|
// no include pattern, set proxy to the first entry
|
|
case pref.mode === 'pattern' && !pref.data.some(i => i.include[0] || i.exclude[0]):
|
|
const pxy = pref.data[0];
|
|
pref.mode = pxy.type === 'pac' ? pxy.pac : `${pxy.hostname}:${pxy.port}`;
|
|
break;
|
|
}
|
|
|
|
App.firefox ? Firefox.set(pref, conf) : Chrome.set(pref);
|
|
Action.set(pref);
|
|
}
|
|
|
|
static async getSettings() {
|
|
if (App.android) { return {}; }
|
|
|
|
const conf = await browser.proxy.settings.get({});
|
|
|
|
// https://developer.chrome.com/docs/extensions/mv3/manifest/icons/
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=29683
|
|
// Issue 29683: Extension icons should support SVG (Dec 8, 2009)
|
|
// SVG is not supported by Chrome
|
|
// Firefox: If each one of imageData and path is one of undefined, null or empty object,
|
|
// the global icon will be reset to the manifest icon
|
|
// Chrome -> Error: Either the path or imageData property must be specified.
|
|
|
|
// check if proxy.settings is controlled_by_this_extension
|
|
const control = ['controlled_by_this_extension', 'controllable_by_this_extension'].includes(conf.levelOfControl);
|
|
const path = control ? `/image/icon.png` : `/image/icon-off.png`;
|
|
browser.action.setIcon({path});
|
|
!control && browser.action.setTitle({title: browser.i18n.getMessage('controlledByOtherExtensions')});
|
|
|
|
// return null if Chrome and no control, allow Firefox to continue regardless
|
|
return !App.firefox && !control ? null : conf;
|
|
}
|
|
|
|
// ---------- Include/Exclude Host ----------------------
|
|
static includeHost(pref, proxy, tab, inc) {
|
|
// not for storage.managed
|
|
if (pref.managed) { return; }
|
|
|
|
const url = this.getURL(tab.url);
|
|
if (!url) { return; }
|
|
|
|
const pattern = url.origin + '/';
|
|
const pat = {
|
|
active: true,
|
|
pattern,
|
|
title: url.hostname,
|
|
type: 'wildcard',
|
|
};
|
|
|
|
inc === 'includeHost' ? proxy.include.push(pat) : proxy.exclude.push(pat);
|
|
browser.storage.local.set({data: pref.data});
|
|
// update Proxy, noDataChange
|
|
pref.mode === 'pattern' && proxy.active && this.set(pref, true);
|
|
}
|
|
|
|
static getURL(str) {
|
|
const url = new URL(str);
|
|
// unacceptable URLs
|
|
if (!['http:', 'https:'].includes(url.protocol)) { return; }
|
|
|
|
return url;
|
|
}
|
|
|
|
// from popup.js
|
|
static getIP() {
|
|
fetch('https://getfoxyproxy.org/webservices/lookup.php')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (!Object.keys(data)) {
|
|
App.notify(browser.i18n.getMessage('error'));
|
|
return;
|
|
}
|
|
|
|
const [ip, {cc, city}] = Object.entries(data)[0];
|
|
const text = [ip, city, Location.get(cc)].filter(Boolean).join('\n');
|
|
App.notify(text);
|
|
})
|
|
.catch(error => App.notify(browser.i18n.getMessage('error') + '\n\n' + error.message));
|
|
}
|
|
}
|
|
|
|
class Firefox {
|
|
|
|
static async set(pref, conf) {
|
|
// update OnRequest
|
|
OnRequest.init(pref);
|
|
|
|
if (App.android) { return; }
|
|
|
|
// incognito access
|
|
if (!await browser.extension.isAllowedIncognitoAccess()) {
|
|
return;
|
|
}
|
|
|
|
// retain settings as Network setting is partially customisable
|
|
const value = conf.value;
|
|
|
|
switch (true) {
|
|
// https://github.com/foxyproxy/browser-extension/issues/47
|
|
// Unix domain socket SOCKS proxy support
|
|
// regard file:///run/user/1000/proxy.socks:9999 as normal proxy (not PAC)
|
|
|
|
// sanitizeNoProxiesPref() "network.proxy.no_proxies_on"
|
|
// https://searchfox.org/mozilla-central/source/browser/components/preferences/dialogs/connection.js#338
|
|
|
|
// --- Proxy Auto-Configuration (PAC) URL
|
|
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
|
|
value.proxyType = 'autoConfig';
|
|
value.autoConfigUrl = pref.mode;
|
|
// convert to standard comma-separated
|
|
value.passthrough = pref.passthrough.split(/[\s,;]+/).join(', ');
|
|
value.proxyDNS = pref.proxyDNS;
|
|
// no error if levelOfControl: "controlled_by_other_extensions"
|
|
browser.proxy.settings.set({value});
|
|
break;
|
|
|
|
// --- disable, direct, pattern, or single proxy
|
|
default:
|
|
browser.proxy.settings.clear({});
|
|
}
|
|
}
|
|
}
|
|
|
|
class Chrome {
|
|
|
|
static async set(pref) {
|
|
// https://developer.chrome.com/docs/extensions/reference/types/
|
|
// Scope and life cycle: regular | regular_only | incognito_persistent | incognito_session_only
|
|
const config = {value: {}, scope: 'regular'};
|
|
switch (true) {
|
|
case pref.mode === 'disable':
|
|
case pref.mode === 'direct':
|
|
config.value.mode = 'system';
|
|
break;
|
|
|
|
// --- Proxy Auto-Configuration (PAC) URL
|
|
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
|
|
config.value.mode = 'pac_script';
|
|
config.value.pacScript = {mandatory: true};
|
|
config.value.pacScript.url = pref.mode;
|
|
break;
|
|
|
|
// --- single proxy
|
|
case pref.mode.includes(':'):
|
|
const pxy = this.findProxy(pref);
|
|
if (!pxy) { return; }
|
|
|
|
config.value.mode = 'fixed_servers';
|
|
config.value.rules = this.getSingleProxyRule(pref, pxy);
|
|
break;
|
|
|
|
// --- pattern
|
|
default:
|
|
config.value.mode = 'pac_script';
|
|
config.value.pacScript = {mandatory: true};
|
|
config.value.pacScript.data = this.getPacString(pref);
|
|
}
|
|
|
|
browser.proxy.settings.set(config);
|
|
|
|
// --- incognito
|
|
this.setIncognito(pref);
|
|
}
|
|
|
|
static findProxy(pref, host = pref.mode) {
|
|
return pref.data.find(i =>
|
|
i.active && i.type !== 'pac' && i.hostname && host === `${i.hostname}:${i.port}`);
|
|
}
|
|
|
|
static async setIncognito(pref) {
|
|
// incognito access
|
|
if (!await browser.extension.isAllowedIncognitoAccess()) {
|
|
return;
|
|
}
|
|
|
|
const pxy = pref.container?.incognito && this.findProxy(pref, pref.container?.incognito);
|
|
if (!pxy) {
|
|
// unset incognito
|
|
browser.proxy.settings.clear({scope: 'incognito_persistent'});
|
|
return;
|
|
}
|
|
|
|
const config = {value: {}, scope: 'incognito_persistent'};
|
|
config.value.mode = 'fixed_servers';
|
|
config.value.rules = this.getSingleProxyRule(pref, pxy);
|
|
browser.proxy.settings.set(config);
|
|
}
|
|
|
|
static getSingleProxyRule(pref, pxy) {
|
|
return {
|
|
singleProxy: {
|
|
scheme: pxy.type,
|
|
host: pxy.hostname,
|
|
// must be number, prepare for augmented port
|
|
port: parseInt(pxy.port),
|
|
},
|
|
bypassList: pref.passthrough ? pref.passthrough.split(/[\s,;]+/) : []
|
|
};
|
|
}
|
|
|
|
static getProxyString(proxy) {
|
|
let {type, hostname, port} = proxy;
|
|
switch (type) {
|
|
case 'direct':
|
|
return 'DIRECT';
|
|
|
|
// chrome PAC doesn't support HTTP
|
|
case 'http':
|
|
type = 'PROXY';
|
|
break;
|
|
|
|
default:
|
|
type = type.toUpperCase();
|
|
}
|
|
// prepare for augmented port
|
|
return `${type} ${hostname}:${parseInt(port)}`;
|
|
}
|
|
|
|
static getPacString(pref) {
|
|
// --- proxy by pattern
|
|
const [passthrough, net] = Pattern.getPassthrough(pref.passthrough);
|
|
|
|
// filter data
|
|
let data = pref.data.filter(i => i.active && i.type !== 'pac' && i.hostname);
|
|
data = data.filter(i => i.include[0] || i.exclude[0]).map(item => {
|
|
return {
|
|
str: this.getProxyString(item),
|
|
include: item.include.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
|
|
exclude: item.exclude.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type))
|
|
};
|
|
});
|
|
|
|
// add PAC rules from pacString
|
|
let pacData = pref.data.filter(i => i.active && i.type === 'pac' && i.pacString);
|
|
pacData = pacData.map((i, idx) => i.pacString.replace('FindProxyForURL', '$&' + idx) +
|
|
`\nconst find${idx} = FindProxyForURL${idx}(url, host);
|
|
if (find${idx} !== 'DIRECT') { return find${idx}; }`).join('\n\n');
|
|
pacData &&= `\n${pacData}\n`;
|
|
|
|
// https://developer.chrome.com/docs/extensions/reference/proxy/#type-PacScript
|
|
// https://github.com/w3c/webextensions/issues/339
|
|
// Chrome pacScript doesn't support bypassList
|
|
// https://issues.chromium.org/issues/40286640
|
|
|
|
// isInNet(host, "192.0.2.172", "255.255.255.255")
|
|
|
|
const pacString =
|
|
String.raw`function FindProxyForURL(url, host) {
|
|
const data = ${JSON.stringify(data)};
|
|
const passthrough = ${JSON.stringify(passthrough)};
|
|
const net = ${JSON.stringify(net)};
|
|
const match = array => array.some(i => new RegExp(i, 'i').test(url));
|
|
const inNet = () => net[0] && /^[\d.]+$/.test(host) && net.some(([ip, mask]) => isInNet(host, ip, mask));
|
|
|
|
if (match(passthrough) || inNet()) { return 'DIRECT'; }
|
|
for (const proxy of data) {
|
|
if (!match(proxy.exclude) && match(proxy.include)) { return proxy.str; }
|
|
}
|
|
${pacData}
|
|
return 'DIRECT';
|
|
}`;
|
|
|
|
return pacString;
|
|
}
|
|
} |