1
0
zjcqoo 6 жил өмнө
commit
9079bba4a1

+ 214 - 0
README.md

@@ -0,0 +1,214 @@
+# 在线预览
+
+https://www.gk.jsproxy.tk
+
+(目前仍在更新中,最好使用隐身模式访问,避免缓存导致的问题)
+
+# 安装部署
+
+## 依赖
+
+* OpenResty
+
+* acme.sh
+
+* node.js / webpack / webpack-cli
+
+CentOS7 可执行 `./server/setup.sh` 一键安装。
+
+
+## 配置
+
+首先需要一个域名,例如 example.com,解析 @ 和 * 到服务器 IP。
+
+在项目的根目录下新建 `dnsconf` 文件:
+
+```bash
+DOMAIN=example.com
+DNS_ID=dns_xx
+export xx_id=xxx
+export xx_key=xxxxxx
+```
+
+第一个为域名,后面三个参考 [acme.sh dns api](https://github.com/Neilpang/acme.sh/tree/master/dnsapi)。
+
+执行 `./build.sh`。该过程会申请 SSL 证书,时间可能较长。
+
+执行 `./server/run.sh` 开启服务。
+
+访问 `https://example.com` 即可进入首页。
+
+> 本项目使用了 `brotli_static` 指令,如果当前的 nginx 不支持,可在 `server/nginx.conf` 配置中将其注释,或参考 `server/setup.sh` 重新编译 nginx。
+
+
+## 扩展
+
+编辑 `sitelist.txt` 文件,可配置站点别名,格式为 `别名 主机名`。配置完成后需要执行 `build.sh` 更新。
+
+执行 `./server/run.sh reload` 重启服务。(该命令的参数和 nginx -s 意义一样,当然也可以自己管理 nginx 服务)
+
+访问 `https://别名.example.com` 即可进入相应站点。
+
+由于 HTTPS 证书不支持多级通配,所以别名数量是有限的(好像 acme.sh 只支持 30 几个)
+
+对于普通的域名,例如 `www.host.com` 则转换成 `www-dot-host-dot-com.example.com` 的格式,即 `.` 变成 `-dot-`。(原本就有 `-dot-` 字符的域名暂未考虑)
+
+
+# 功能特点
+
+## 性能开销
+
+本代理主要功能都运行在客户端,最大程度减少服务端计算量。前端通过 `Service Worker` 拦截和处理资源,同时注入一个 JS 到页面顶部,实现一些辅助功能。
+
+服务端则非常简单,直接利用 nginx 反向代理功能,并且不修改内容(只修改 HTTP 头),避免处理内容的开销,以及原始数据解压再压缩的开销(或者不压缩时流量开销)。
+
+例如现在流行的 br 压缩,压缩比高但压缩成本很大。因此让代理服务器只转发而不操作数据,可节省大量资源。
+
+
+## 域名模型
+
+本代理将不同的目标站点作为独立的子域名,例如:
+
+```text
+so.jsproxy.tk  =>  stackoverflow.com
+gk.jsproxy.tk  =>  www.google.com.hk
+```
+
+这在一定程度上隔离了站点之间的数据,例如 Cookie、Storage 等。
+
+该模型支持目标站点子域和主域 Cookie 共存:
+
+![](docs/sub-root-cookie.png)
+
+另外页面中的辅助脚本,也会对部分 DOM API 进行重写,模拟一个沙盒环境。
+
+例如脚本设置 Cookie 时,会触发钩子程序对赋值进行调整:
+
+![](docs/js-set-cookie.png)
+
+类似的还有:
+
+![](docs/domain-model.png)
+
+使得代理对页面尽可能保持透明。
+
+
+## 路径修正
+
+前端脚本会对资源、超链接、表单、弹窗的 URL 进行修正。
+
+后端代理会对请求头的 `Referer`、`Origin` 字段进行修正,减少被拦截的可能。
+
+![](docs/login1.png)
+
+![](docs/login2.png)
+
+目前测试了 GitHub、Twitter 可以登陆,Google 登陆还有一些问题。
+
+当然请不要在测试服务器里输入隐私数据。
+
+
+# 存在问题
+
+该代理目前仍存在较多问题,主要有:
+
+## 普通域名模式没有子域
+
+由于 `www-dot-host-dot-com.example.com` 并非 `host-dot-com.example.com` 的子域,因此这种模式下 cookie 和 domain 都无法支持域模型。
+
+未来可能会尝试把所有站点都放在同个域名下,例如 `https://example.com/host.com/path/to`,这样就无需考虑域名的问题。当然这种方案需要重写更多的 API 以确保数据隔离,甚至还要自己维护 cookie 的携带,难度比较大。
+
+
+## location hook
+
+由于 `window` 和 `document` 对象的 `location` 属性无法重写,导致很多网站的脚本在读写路径时会出问题。
+
+目前在代码层解决这个问题:通过 Service Worker 以及 API 钩子拦截 JS 代码,然后将其中的 `location` 字符串替换成 `__location`,从而将操作转到我们的对象上。
+
+由于这种方式简单粗暴,有时会把正则、字符串、属性名的 location 也替换了,导致代码出现问题。因此之后会尝试在 AST 层面进行调整,当然缺点是比较耗时,尤其对于很大的 JS。
+
+当然,如果 `location` 不是字面出现的,比如 `obj[key]` 形式,那么这种方案仍不可行,除非调整 `window` 和 `document`。但它们也可以不通过字面获取,例如通过 `this` 也可以获取 `window`,更别提 `eval` 等等。。。所以网站本身若真想访问 `location`,我们还是很难阻止的。
+
+因此这里给 Web 开发者一个建议:如果想检测当前页面 URL 是否为钓鱼网站,最好不要出现字面量的 `window`、`location` 获取 URL,而是通过动态的方式进行获取,以防落入上述这种低级的陷阱。
+
+
+## 多进程问题
+
+由于 Service Worker 无法拦截第三方站点的框架页,因此会出现 iframe 逃脱代理的情况。
+
+目前尝试对框架元素的 `src` 属性进行拦截,同时监控 DOM 创建事件,将新增的框架调整成我们的 URL。当然这里面还涉及到 `about:`、`blob:`、`data:` 等协议,会有些麻烦,暂未实现。
+
+另外新创建的 `Worker`、`SharedWorker` 暂时也没有注入辅助 JS 代码,还在调研中。
+
+至于业务方的 `ServiceWorker`,目前是直接拒绝其使用的,因为这会和代理本身的 `ServiceWorker` 冲突。以后再调研两者是否能较好的共存。
+
+
+## 很多地方需要优化
+
+由于目前还只是个概念验证的状态,很多代码都是临时写的,之后稳定了再重构和完善。
+
+另外测试案例也没有,估计有一大堆 BUG 还没发现。
+
+
+# 优化探索
+
+YY 一些优化方案,以后有时间探索。
+
+## 流量的优先级
+
+因为我们是在前端拦截流量,所以能了解每个请求的具体用途,从而可更好的设置优先级。例如在流量压力较大时,优先满足网页、脚本等流量,推迟视频、动画等流量,确保主要功能不受影响。
+
+## 脚本离线分析
+
+由于前端修改 JS 比较耗性能,因此可事先把各大网站的常用脚本在本地分析,然后上传到 nginx 缓存里。这样浏览器就不需要实时计算了,可以大幅降低开销。并且离线分析可以更加深入,对于动态访问 `location` 的情况也能覆盖到,甚至完全不局限于修改 `location` 的功能,而是更通用的调整,例如去广告,增加其他功能等等。
+
+另外对于常用的内联脚本,也可将离线分析结果进行下发,浏览器运行时只需简单查表,避免大量在线计算。
+
+## 资源本地加速
+
+进一步,还考虑可以把常用网站的静态资源预先下回本地,部署到附近的 CDN 上,或者 [打包成图片上传到各大免费图床、相册里](https://yq.aliyun.com/articles/236582),提供给 Service Worker 更快的访问通道。这样可大幅加快网站访问速度,并且节省代理服务器的流量!
+
+## 缓存重新压缩
+
+虽然大部分网站都开启了 HTTP 传输压缩,并且不少支持 br 格式,但考虑到压缩成本,很多网站并没有将压缩率调到最大。而我们的代理服务器,显然也不会为了节省那么一点流量,牺牲大量 CPU 去做解压和压缩。
+
+但是,这个过程可以离线去做,尤其对于那些 CPU 过剩而流量紧缺的服务器。我们可将空闲时的 CPU 资源用于 nginx cache 最高级 br 压缩。甚至还可以对非 CORS 请求的图片进行更高程度的压缩,并且转换成 WebP 格式,进一步降低流量开销。
+
+## 动态数据压缩
+
+有些网页内容很大却关闭了缓存,例如 google 首页,每次访问都要重新下载一次,浪费不少流量。但是让代理服务器强制缓存也是不行的,因为页面里可能包含了用户信息,缓存的话就会串号导致隐私问题。
+
+然而这些页面的绝大部分都是相同的,每次重复传输实属不必。因此,我们可预先分析出那些不涉及隐私的公共子串,将其部署在本地 CDN 上。代理在返回数据时,重复部分用索引代替,从而可减少传输流量,提高访问速度。更进一步,甚至可以尝试把网页反推回模板,这样只需传输模板变量就可以!
+
+对于那些流量接收免费、发送计费的服务器来说,这是个值得考虑的优化方案。
+
+
+# 初衷
+
+春节期间由于家里电脑上不了 google 很是不爽,平时用惯了公司自带的科学上网,好久没维护自己的都不能用了,于是一气之下写了这个程序。
+
+其实很久以前也尝试过类似的,但都十分简陋。这次决定做个完善的,充分用上浏览器的新技术和黑魔法,顺便再熟悉下 nginx 的技术细节。
+
+当然制作过程并不顺利,遇到各种问题。因此先实现了一个简单的 google 代理,之后的问题就可以通过它解决了。于是用「开发中的代理」搜索「代理开发中」遇到的问题,然后不断改进。或许这就叫自举😂
+
+尽管折腾了整个春节,但毕竟不是寒假才几天时间,所以仍是个半成品。不过用来浏览常见的编程网站是没问题的,甚至还能刷推看视频。
+
+当然要做到完善还需不少时间,暂时先分享个半成品吧~
+
+
+# 后续
+
+之后还会将它用于以下技术的研究:
+
+* 网站镜像 / 沙盒化
+
+* 钓鱼网站攻防检测
+
+* 资源访问端上加速
+
+当然请勿将本项目用于访问非法用途,否则后果自负。
+
+
+# License
+
+MIT

+ 27 - 0
browser/home/index.html

@@ -0,0 +1,27 @@
+<!doctype html>
+<html>
+<head>
+  <title>Page Sandbox Demo</title>
+  <meta charset="utf-8">
+  <base target="_blank">
+  <style>
+    #txtURL {
+      width: 300px;
+    }
+  </style>
+</head>
+<body>
+  <h1>网页沙盒</h1>
+  <div>
+    URL:
+    <input id="txtURL" value="https://www.google.com.hk">
+    <button id="btnGo">Go</button>
+  </div>
+  <script src="x.js"></script>
+  <script>
+    btnGo.onclick = function() {
+      open(txtURL.value)
+    }
+  </script>
+</body>
+</html>

+ 3 - 0
browser/proxy/debug.sh

@@ -0,0 +1,3 @@
+JS=../../server/www/x.js
+rm -f $JS.br
+webpack -w -o $JS --mode development

+ 15 - 0
browser/proxy/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "jsproxy-client",
+  "version": "0.0.1",
+  "description": "",
+  "main": "boot.js",
+  "directories": {
+    "lib": "lib"
+  },
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "EtherDream",
+  "license": "MIT"
+}

+ 2 - 0
browser/proxy/release.sh

@@ -0,0 +1,2 @@
+webpack --mode production
+brotli -f -o ../../server/www/x.js.br dist/main.js

+ 159 - 0
browser/proxy/src/fakeloc.js

@@ -0,0 +1,159 @@
+import * as urlx from "./urlx";
+
+
+/**
+ * @param {string} url 
+ */
+function decOrigin(url) {
+  const u = new URL(url)
+  urlx.decUrlObj(u)
+  return u.origin
+}
+
+function setup(obj, fakeLoc) {
+  Reflect.defineProperty(obj, '__location', {
+    get() {
+      return fakeLoc
+    },
+    set(val) {
+      console.log('[jsproxy] %s set location: %s', obj, val)
+      fakeLoc.href = val
+    }
+  })
+}
+
+/**
+ * @param {Window} win 
+ * @param {Hook} hook 
+ */
+export function init(win, hook) {
+  let loc = win.location
+
+  // TODO: iframe 场合下存在问题
+  // 比如 youtube 首页缺少这个判断会报错
+  if (loc.href === 'about:blank') {
+    loc = win.top.location
+  }
+
+  const fakeLoc = Object.setPrototypeOf({
+    get href() {
+      // console.log('[jsproxy] get location.href')
+      return urlx.decUrlStr(loc.href)
+    },
+
+    get protocol() {
+      // TODO: 未考虑非 https 的页面 URL
+      return loc.protocol
+    },
+
+    get host() {
+      // TODO: 未考虑带端口的页面 URL
+      // console.log('[jsproxy] get location.host')
+      return urlx.decHost(loc.host)
+    },
+
+    get hostname() {
+      // console.log('[jsproxy] get location.hostname')
+      return urlx.decHost(loc.hostname)
+    },
+
+    get port() {
+      // TODO: 未考虑带端口的页面 URL
+      return loc.port
+    },
+
+    get pathname() {
+      return loc.pathname
+    },
+
+    get search() {
+      return loc.search
+    },
+
+    get hash() {
+      return loc.hash
+    },
+
+    get origin() {
+      // console.log('[jsproxy] get location.origin')
+      return decOrigin(loc.origin)
+    },
+
+    get ancestorOrigins() {
+      // TODO: DOMStringList[]
+      // console.log('[jsproxy] get location.ancestorOrigins')
+      return [...loc.ancestorOrigins].map(decOrigin)
+    },
+
+    set href(val) {
+      console.log('[jsproxy] set location.href:', val)
+      loc.href = urlx.encUrlStr(val, loc)
+    },
+
+    set protocol(val) {
+      const u = new URL(loc)
+      // TODO: 
+    },
+
+    set host(val) {
+      console.log('[jsproxy] set location.host:', val)
+      // TODO:
+    },
+
+    set hostname(val) {
+      console.log('[jsproxy] set location.hostname:', val)
+      loc.hostname = urlx.encHost(val)
+    },
+
+    set port(val) {
+      console.log('[jsproxy] set location.port:', val)
+      // TODO:
+    },
+
+    set pathname(val) {
+      loc.pathname = val
+    },
+
+    set search(val) {
+      loc.search = val
+    },
+
+    set hash(val) {
+      loc.hash = val
+    },
+
+    reload(...args) {
+      loc.reload(...args)
+    },
+
+    replace(val) {
+      if (val) {
+        console.log('[jsproxy] location.replace:', val)
+        arguments[0] = urlx.encUrlStr(val, loc)
+      }
+      loc.replace(...arguments)
+    },
+
+    assign(val) {
+      if (val) {
+        console.log('[jsproxy] location.assign:', val)
+        arguments[0] = urlx.encUrlStr(val, loc)
+      }
+      loc.assign(...arguments)
+    },
+
+    toString() {
+      const val = loc.toString(...arguments)
+      return urlx.decUrlStr(val)
+    },
+
+    toLocaleString() {
+      const val = loc.toLocaleString(...arguments)
+      return urlx.decUrlStr(val)
+    },
+  }, loc.constructor.prototype)
+
+
+  setup(win, fakeLoc)
+  setup(win.document, fakeLoc)
+}

+ 263 - 0
browser/proxy/src/hook.js

@@ -0,0 +1,263 @@
+export const DELETE = {}
+export const RETURN = {}
+
+/**
+ * @param {Window} win 
+ */
+export function createHook(win) {
+  // const {
+  //   Reflect,
+  //   WeakMap,
+  // } = win
+
+  const {
+    apply,
+    getOwnPropertyDescriptor,
+    defineProperty,
+  } = Reflect
+
+  const rawMap = new WeakMap()
+
+
+  /**
+   * hook function
+   * 
+   * @param {object} obj 
+   * @param {string} key 
+   * @param {Function} factory 
+   */
+  function func(obj, key, factory) {
+    const oldFn = obj[key]
+    if (!oldFn) {
+      return false
+    }
+    const newFn = factory(oldFn)
+    for (const k in oldFn) {
+      newFn[k] = oldFn[k]
+    }
+    newFn.prototype = oldFn.prototype
+    rawMap.set(newFn, oldFn)
+    obj[key] = newFn
+    return true
+  }
+
+  /**
+   * hook property
+   * 
+   * @param {object} obj 
+   * @param {string} key 
+   * @param {Function} g 
+   * @param {Function} s 
+   */
+  function prop(obj, key, g, s) {
+    const desc = getOwnPropertyDescriptor(obj, key)
+    if (!desc) {
+      return false
+    }
+    if (g) {
+      func(desc, 'get', g)
+    }
+    if (s) {
+      func(desc, 'set', s)
+    }
+    defineProperty(obj, key, desc)
+    return true
+  }
+
+
+  function hookElemProp(proto, name, onget, onset) {
+    prop(proto, name,
+      getter => function() {
+        const val = getter.call(this)
+        return onget.call(this, val)
+      },
+      setter => function(val) {
+        const ret = onset.call(this, val)
+        if (ret !== null) {
+          val = ret
+        }
+        setter.call(this, val)
+      }
+    )
+  }
+
+  const toLCase = ''.toLocaleLowerCase
+  const elemProto = win.Element.prototype
+  const rawGetAttr = elemProto.getAttribute
+  const rawSetAttr = elemProto.setAttribute
+
+  const tagAttrHandlersMap = {}
+  const tagTextHandlerMap = {}
+  const tagKeySetMap = {}
+  const tagKeyGetMap = {}
+
+
+  function attr(tag, proto, ...handlers) {
+    let hasBind, hasAttr
+    let keySetMap, keyGetMap
+
+    handlers.forEach(v => {
+      // 划线转驼峰('http-equiv' -> 'httpEquiv')
+      const name = v.name.replace(/-(\w)/g, (_, $1) => {
+        return $1.toUpperCase()
+      })
+      hookElemProp(proto, name, v.onget, v.onset)
+
+      // #text
+      if (name === 'innerText') {
+        tagTextHandlerMap[tag] = v
+        return
+      }
+
+      // attribute
+      if (tagAttrHandlersMap[tag]) {
+        tagAttrHandlersMap[tag].push(v)
+        hasBind = true
+      } else {
+        tagAttrHandlersMap[tag] = [v]
+        tagKeySetMap[tag] = {}
+        tagKeyGetMap[tag] = {}
+      }
+
+      if (!keySetMap) {
+        keySetMap = tagKeySetMap[tag]
+        keyGetMap = tagKeyGetMap[tag]
+      }
+      const key = toLCase.call(name)
+      keySetMap[key] = v.onset
+      keyGetMap[key] = v.onget
+      hasAttr = true
+    })
+
+    if (hasBind || !hasAttr) {
+      return
+    }
+
+    // 如果之前调用过 setAttribute,那么直接返回上次设置的值。
+    // 如果没有调用过则回调 onget
+    func(proto, 'getAttribute', oldFn => function(name) {
+      const key = toLCase.call(name)
+      const get = keyGetMap[key]
+      if (get) {
+        const lastVal = this['_k' + key]
+        if (lastVal !== undefined) {
+          return lastVal
+        }
+        const val = apply(oldFn, this, arguments)
+        return get(val)
+      }
+      return apply(oldFn, this, arguments)
+    })
+
+    func(proto, 'setAttribute', oldFn => function(name, val) {
+      const key = toLCase.call(name)
+      const set = keySetMap[key]
+      if (set) {
+        this['_k' + key] = val
+
+        const ret = set.call(this, val)
+        if (ret === DELETE) {
+          this.removeAttribute(key)
+          return
+        }
+        if (ret === RETURN) {
+          return
+        }
+        arguments[1] = ret
+      }
+      return apply(oldFn, this, arguments)
+    })
+
+    // TODO: setAttributeNode
+    // ...
+  }
+
+  /**
+   * @param {Text} node
+   * @param {object} handler
+   * @param {Element} elem 
+   */
+  function parseNewTextNode(node, handler, elem) {
+    const val = node.nodeValue
+    const ret = handler.onset.call(elem, val)
+    if (ret === DELETE) {
+      node.remove()
+      return
+    }
+    if (ret === RETURN) {
+      return
+    }
+    node.nodeValue = ret
+  }
+
+  /**
+   * @param {Element} elem 
+   * @param {object} handler
+   */
+  function parseNewElemNode(elem, handler) {
+    const name = handler.name
+    if (!elem.hasAttribute(name)) {
+      return
+    }
+    const val = rawGetAttr.call(elem, name)
+    const ret = handler.onset.call(elem, val)
+    if (ret === DELETE) {
+      elem.removeAttribute(name)
+      return
+    }
+    if (ret === RETURN) {
+      return
+    }
+    rawSetAttr.call(elem, name, ret)
+  }
+
+  /**
+   * @param {MutationRecord[]} mutations 
+   */
+  function parseMutations(mutations) {
+    mutations.forEach(mutation => {
+      mutation.addedNodes.forEach(node => {
+        switch (node.nodeType) {
+        case 1:   // ELEMENT_NODE
+          const handlers = tagAttrHandlersMap[node.tagName]
+          handlers && handlers.forEach(v => {
+            parseNewElemNode(node, v)
+          })
+          break
+        case 3:   // TEXT_NODE
+          const elem = node.parentElement
+          if (elem) {
+            const handler = tagTextHandlerMap[elem.tagName]
+            if (handler) {
+              parseNewTextNode(node, handler, elem)
+            }
+          }
+          break
+        }
+      })
+    })
+  }
+
+
+  const observer = new win.MutationObserver(parseMutations)
+  observer.observe(win.document, {
+    childList: true,
+    subtree: true,
+  })
+
+  // win.addEventListener('DOMContentLoaded', e => {
+  //   parseMutations(observer.takeRecords())
+  //   observer.disconnect()
+  // })
+
+  // hide source code
+  func(Function.prototype, 'toString', oldFn => function() {
+    return apply(oldFn, rawMap.get(this) || this, arguments)
+  })
+  
+  return {
+    func,
+    prop,
+    attr,
+  }
+}

+ 37 - 0
browser/proxy/src/hostlist.js

@@ -0,0 +1,37 @@
+// THIS FILE WAS GENERATED BY build.sh
+// DO NOT MODIFY
+export const MY_ROOT = 'jsproxy.tk'
+export const HOST_LIST = [
+  ['gg', 'google.com'],
+  ['gc', 'google.cn'],
+  ['gk', 'google.com.hk'],
+  ['gu', 'googleusercontent.com'],
+  ['gs', 'googlesource.com'],
+  ['wk', 'wikipedia.org'],
+  ['m.wk', 'm.wikipedia.org'],
+  ['so', 'stackoverflow.com'],
+  ['se', 'stackexchange.com'],
+  ['sf', 'serverfault.com'],
+  ['su', 'superuser.com'],
+  ['au', 'askubuntu.com'],
+  ['gh', 'github.com'],
+  ['qr', 'quora.com'],
+  ['ux', 'unix.com'],
+  ['mz', 'mozilla.org'],
+  ['w3', 'w3schools.com'],
+  ['cr', 'chromium.org'],
+  ['my', 'myspace.com'],
+  ['fb', 'facebook.com'],
+  ['yt', 'youtube.com'],
+  ['tw', 'twitter.com'],
+  ['fl', 'flickr.com'],
+  ['rd', 'reddit.com'],
+  ['bg', 'blogger.com'],
+  ['wp', 'wordpress.com'],
+  ['md', 'medium.com'],
+  ['hn', 'hackernoon.com'],
+  ['yh', 'yahoo.com'],
+  ['bc', 'bbc.com'],
+  ['th', 'twitch.tv'],
+  ['sc', 'steamcommunity.com'],
+]

+ 13 - 0
browser/proxy/src/index.js

@@ -0,0 +1,13 @@
+function main() {
+  if ('onclick' in self) {
+    // page env
+    return require('./page.js')
+  }
+  if ('onfetch' in self) {
+    // sw env
+    return require('./sw.js')
+  }
+  return require('./worker.js')
+}
+
+main()

+ 77 - 0
browser/proxy/src/inject.js

@@ -0,0 +1,77 @@
+import * as urlx from "./urlx";
+import * as util from './util.js'
+import * as jsfilter from './jsfilter.js'
+
+
+const RES_HOST = urlx.getMyRootHost()
+const HELPER_URL = `//${RES_HOST}/x.js`
+
+// 为了简化注入位置的分析,这里直接插到 HTML 开头
+// 所以页面里会出现两个 <!DOCTYPE>
+const HTML_BEG = util.strToBytes(
+  `<!DOCTYPE html><script src="${HELPER_URL}"></script>`
+)
+
+// Worker 
+const WORKER_BEG = util.strToBytes(
+  `importScripts('${HELPER_URL}');`
+)
+
+
+/**
+ * @param {Response} res
+ * @param {Object} resOpt
+ */
+export function htmlRemote(res, resOpt) {
+  const reader = res.body.getReader()
+  let injected
+
+  const stream = new ReadableStream({
+    async pull(controller) {
+      if (!injected) {
+        injected = true
+        controller.enqueue(HTML_BEG)
+      }
+      const r = await reader.read()
+      if (r.done) {
+        controller.close()
+        return
+      }
+      controller.enqueue(r.value)
+    }
+  })
+  return new Response(stream, resOpt)
+}
+
+
+// 处理 data、blob 协议的页面
+export function htmlLocal(uri) {
+  // TODO:
+}
+
+
+/**
+ * @param {Response} res
+ * @param {Object} resOpt
+ */
+export async function jsRemote(res, resOpt, charset) {
+  // 之后会分析语法树,所以不使用流模式
+  const buf = await res.arrayBuffer()
+  const ret = await jsfilter.parseBin(buf, charset)
+  if (ret) {
+    resOpt.headers = new Headers(resOpt.headers)
+    resOpt.headers.set('content-type', 'text/javascript')
+  }
+  return new Response(ret || buf, resOpt)
+}
+
+
+export function workerRemote(res, resOpt, charset) {
+  // TODO: 
+}
+
+
+// 处理 data、blob 协议的 Worker
+export function workerLocal(data) {
+  // TODO: 
+}

+ 31 - 0
browser/proxy/src/jsfilter.js

@@ -0,0 +1,31 @@
+import * as util from './util.js'
+
+
+/**
+ * @param {string} code 
+ */
+export function parseSync(code) {
+  // TODO: parse js ast
+  let match
+  code = code.replace(/(\b)location(\b)/g, (s, $1, $2) => {
+    match = true
+    return $1 + '__location' + $2
+  })
+  if (match) {
+    return code
+  }
+}
+
+/**
+ * @param {Uint8Array} buf
+ */
+export async function parseBin(buf, charset) {
+  const str = util.bytesToStr(buf, charset)
+  const ret = parseSync(str)
+  if (ret) {
+    return util.strToBytes(ret)
+  }
+  if (!util.isUtf8(charset)) {
+    return util.strToBytes(str)
+  }
+}

+ 199 - 0
browser/proxy/src/nav.js

@@ -0,0 +1,199 @@
+import * as urlx from './urlx.js';
+
+/**
+ * page navigate intercept
+ * 
+ * @param {Window} win 
+ * @param {Hook} hook 
+ */
+export function init(win, hook) {
+  const {
+    location,
+    Reflect,
+  } = win
+
+  const {
+    apply,
+  } = Reflect
+
+  const linkProto = win.HTMLAnchorElement.prototype
+  const areaProto = win.HTMLAreaElement.prototype
+  const formProto = win.HTMLFormElement.prototype
+
+  function hookNavAttr(tag, proto, name) {
+    hook.attr(tag, proto, {
+      name,
+      onget(val) {
+        const u = new URL(val, location)
+        urlx.unpack(u)
+        return u.href
+      },
+      onset(val) {
+        const u = new URL(val, location)
+        urlx.pack(u, false, false)
+        return u.href
+      }
+    })
+  }
+  hookNavAttr('A', linkProto, 'href')
+  hookNavAttr('AREA', areaProto, 'href')
+  hookNavAttr('FORM', formProto, 'action')
+
+
+  // TODO:
+  function hookLinkProp(proto) {
+    hook.prop(proto, 'hostname',
+      getter => function() {
+        const val = getter.call(this)
+        return val
+      },
+      setter => function(val) {
+        console.log('[jsproxy] set link hostname:', val)
+        setter.call(this, val)
+      }
+    )
+
+    hook.prop(proto, 'host',
+      getter => function() {
+        const val = getter.call(this)
+        return val
+      },
+      setter => function(val) {
+        console.log('[jsproxy] set link host:', val)
+        setter.call(this, val)
+      }
+    )
+
+    hook.prop(proto, 'protocol',
+      getter => function() {
+        const val = getter.call(this)
+        return val
+      },
+      setter => function(val) {
+        console.log('[jsproxy] set link protocol:', val)
+        setter.call(this, val)
+      }
+    )
+
+    hook.prop(proto, 'port',
+      getter => function() {
+        const val = getter.call(this)
+        return val
+      },
+      setter => function(val) {
+        console.log('[jsproxy] set link port:', val)
+        setter.call(this, val)
+      }
+    )
+
+    hook.prop(proto, 'search',
+      getter => function() {
+        const val = getter.call(this)
+        return val
+      },
+      setter => function(val) {
+        console.log('[jsproxy] set link search:', val)
+        setter.call(this, val)
+      }
+    )
+  }
+  hookLinkProp(linkProto)
+  hookLinkProp(areaProto)
+
+  /**
+   * @param {HTMLAnchorElement | HTMLAreaElement | HTMLFormElement} el 
+   * @param {string} prop 
+   */
+  function processElem(el, prop) {
+    const urlStr = el[prop]
+    if (urlStr) {
+      el[prop] = urlStr
+    }
+  }
+
+  function processForm(el) {
+    processElem(el, 'action')
+  }
+
+  function processLink(el) {
+    processElem(el, 'href')
+  }
+
+  win.addEventListener('click', e => {
+    let el = e.target
+    while (el) {
+      let tag = el.tagName
+      if (tag === 'A' || tag === 'AREA') {
+        processLink(el)
+        break
+      }
+      el = el.parentNode
+    }
+  }, true)
+
+  win.addEventListener('submit', e => {
+    let el = e.target
+    if (el.tagName === 'FORM') {
+      processForm(el)
+    }
+  }, true)
+
+
+  function linkClickHook(oldFn) {
+    return function() {
+      processLink(this)
+      return apply(oldFn, this, arguments)
+    }
+  }
+  hook.func(linkProto, 'click', linkClickHook)
+  hook.func(areaProto, 'click', linkClickHook)
+  hook.func(formProto, 'submit', oldFn => function() {
+    processForm(this)
+    return apply(oldFn, this, arguments)
+  })
+
+
+  // hook window.open()
+  hook.func(win, 'open', oldFn => function(url) {
+    if (url) {
+      const u = new URL(url, location)
+      urlx.pack(u, false, false)
+      arguments[0] = u.href
+    }
+    return apply(oldFn, this, arguments)
+  })
+
+
+  //
+  // hook <base>
+  //
+  const baseProto = win.HTMLBaseElement.prototype
+
+  hook.attr('BASE', baseProto, {
+    name: 'href',
+    onget(val) {
+      return urlx.decUrlStr(val)
+    },
+    onset(val) {
+      // console.log('[jsproxy] set base.href:', val)
+      // val = getFinalUrl(val)
+      return urlx.encUrlStr(val, location)
+    }
+  })
+
+  //
+  // hook <meta>
+  //
+  const metaProto = win.HTMLMetaElement.prototype
+
+  hook.attr('META', metaProto, {
+    name: 'http-equiv',
+    onget(val) {
+      // TODO: 
+      return val
+    },
+    onset(val) {
+      return val
+    }
+  })
+}

+ 406 - 0
browser/proxy/src/page.js

@@ -0,0 +1,406 @@
+import {createHook, DELETE} from './hook.js'
+import * as urlx from './urlx.js'
+import * as util from './util.js'
+import * as nav from './nav.js'
+import * as jsfilter from './jsfilter.js'
+import * as fakeloc from './fakeloc.js'
+
+
+/**
+ * @param {Window} win 
+ */
+function initWin(win) {
+  if (!win) {
+    return
+  }
+  try {
+    if (win.Math.__flag) {
+      return  // setuped
+    }
+    win.Math.__flag = 1
+  } catch (err) {
+    return    // not same origin
+  }
+
+  const {
+    // WeakSet,
+    // Reflect,
+    // RegExp,
+    // URL,
+    // Proxy,
+    document,
+    location,
+    navigator,
+  } = win
+
+  const {
+    apply,
+    construct,
+  } = Reflect
+
+  const isExtPageMode = urlx.isMyExtHost(location.hostname)
+
+  // hook Function
+  // hook.func(window, 'Function', oldFn => function() {
+  //   return apply(oldFn, this, arguments)
+  // })
+
+  const hook = createHook(win)
+  nav.init(win, hook)
+
+  // hook window/document.location
+  fakeloc.init(win, hook)
+
+
+  // hook document.domain
+  const docProto = win.Document.prototype
+
+  hook.prop(docProto, 'domain',
+    getter => function() {
+      const val = getter.call(this)
+      return urlx.decHost(val)
+    },
+    setter => function(val) {
+      if (isExtPageMode) {
+        console.warn('[jsproxy] unsafe domain')
+        val = urlx.getMyExtHost()
+      } else {
+        val = urlx.encHost(val)
+      }
+      setter.call(this, val)
+    }
+  )
+
+  // hook document.cookie
+  const R_COOKIE_DOMAIN = /(?<=;\s*domain=)[^;]+/i
+
+  hook.prop(docProto, 'cookie', null,
+    setter => function(val) {
+      val = val.replace(R_COOKIE_DOMAIN, rHost => {
+        if (isExtPageMode) {
+          return ''
+        }
+        if (rHost[0] === '.') {
+          rHost = rHost.substr(1)
+        }
+        const vHost = urlx.encHost(rHost)
+        if (urlx.isMyRootHost(vHost)) {
+          console.warn('[jsproxy] invalid cookie domain:', rHost, vHost)
+        }
+        return vHost
+      })
+      setter.call(this, val)
+    }
+  )
+
+  // uri api
+  function getUriHook(getter) {
+    return function() {
+      const val = getter.call(this)
+      return urlx.decUrlStr(val)
+    }
+  }
+  hook.prop(docProto, 'referrer', getUriHook)
+  hook.prop(docProto, 'URL', getUriHook)
+  hook.prop(docProto, 'documentURI', getUriHook)
+  hook.prop(win.Node.prototype, 'baseURI', getUriHook)
+
+
+  // disable ServiceWorker
+  const swProto = win.ServiceWorkerContainer.prototype
+  if (swProto) {
+    hook.func(swProto, 'register', oldFn => function() {
+      console.warn('access serviceWorker.register blocked')
+      return new Promise(function() {})
+    })
+    hook.func(swProto, 'getRegistration', oldFn => function() {
+      console.warn('access serviceWorker.getRegistration blocked')
+      return new Promise(function() {})
+    })
+    hook.func(swProto, 'getRegistrations', oldFn => function() {
+      console.warn('access serviceWorker.getRegistrations blocked')
+      return new Promise(function() {})
+    })
+  }
+
+  //
+  // hook history
+  //
+  function historyStateHook(oldFn) {
+    return function(_0, _1, url) {
+      if (url) {
+        arguments[2] = urlx.encUrlStr(url, location)
+      }
+      // console.log('[jsproxy] history.replaceState', url)
+      return apply(oldFn, this, arguments)
+    }
+  }
+  const historyProto = win.History.prototype
+  hook.func(historyProto, 'pushState', historyStateHook)
+  hook.func(historyProto, 'replaceState', historyStateHook)
+
+
+  //
+  hook.func(navigator, 'registerProtocolHandler', oldFn => function(_0, url, _1) {
+    console.log('registerProtocolHandler:', arguments)
+    return apply(oldFn, this, arguments)
+  })
+  
+
+  // hook Performance API
+  hook.prop(win.PerformanceEntry.prototype, 'name', getUriHook)
+
+  //
+  // hook iframe
+  //
+  const iframeProto = win.HTMLIFrameElement.prototype
+  hook.prop(iframeProto, 'contentWindow',
+    getter => function() {
+      const win = getter.call(this)
+      initWin(win)
+      return win
+    }
+  )
+
+  hook.prop(iframeProto, 'contentDocument',
+    getter => function() {
+      const doc = getter.call(this)
+      if (doc) {
+        initWin(doc.defaultView)
+      }
+      return doc
+    }
+  )
+
+  
+  hook.attr('IFRAME', iframeProto, {
+    name: 'src',
+    onget(val) {
+      return urlx.decUrlStr(val)
+    },
+    onset(val) {
+      val = urlx.encUrlStr(val, location)
+      console.log('[jsproxy] set <iframe> src', val)
+      return val
+    }
+  })
+
+
+  const embedProto = win.HTMLEmbedElement.prototype
+  hook.attr('EMBED', embedProto, {
+    name: 'src',
+    onget(val) {
+      console.log('[jsproxy] get <embed> src:', val)
+      return val
+    },
+    onset(val) {
+      console.log('[jsproxy] set <embed> src:', val)
+      return val
+    }
+  })
+
+
+  const objectProto = win.HTMLObjectElement.prototype
+  hook.attr('OBJECT', objectProto, {
+    name: 'data',
+    onget(val) {
+      console.log('[jsproxy] get <object> src:', val)
+      return val
+    },
+    onset(val) {
+      console.log('[jsproxy] set <object> src:', val)
+      return val
+    }
+  })
+
+
+  const frames = win.frames
+
+  win.frames = new Proxy(frames, {
+    get(_, key) {
+      if (typeof key === 'number') {
+        console.log('get frames index:', key)
+        const win = frames[key]
+        initWin(win)
+        return win
+      } else {
+        return frames[key]
+      }
+    }
+  })
+
+  //
+  // hook message origin
+  //
+  hook.func(win, 'postMessage', oldFn => function(msg, origin) {
+    // origin 必须是完整的 URL(不接受 // 开头的相对协议)
+    if (origin && origin !== '*') {
+      arguments[1] = urlx.encUrlStr(origin)
+    }
+    return apply(oldFn, this, arguments)
+  })
+
+  hook.prop(win.MessageEvent.prototype, 'origin', getUriHook)
+
+  //
+  // hook xhr
+  //
+  const xhrProto = win.XMLHttpRequest.prototype
+  hook.func(xhrProto, 'open', oldFn => function(_0, url, async) {
+    if (url) {
+      arguments[1] = urlx.encUrlStr(url, location)
+    }
+    if (async === false) {
+      console.log('[jsproxy] sync xhr is disabled')
+      arguments[2] = true
+    }
+    return apply(oldFn, this, arguments)
+  })
+
+
+  hook.func(win, 'fetch', oldFn => function(v) {
+    if (v && v.url) {
+      // v is Request
+      url = urlx.encUrlStr(url)
+      arguments[0] = new Request(url, v)
+    } else {
+      // v is string
+      arguments[0] = urlx.encUrlStr(v, location)
+    }
+    return apply(oldFn, this, arguments)
+  })
+
+
+  // hook Worker
+  function workHook(oldFn) {
+    return function(url) {
+      if (url) {
+        console.log('[jsproxy] new worker:', url)
+        arguments[0] = urlx.encUrlStr(url, location)
+      }
+      return construct(oldFn, arguments)
+    }
+  }
+  hook.func(win, 'Worker', workHook)
+  hook.func(win, 'SharedWorker', workHook)
+
+
+  // hook WebSocket
+  hook.func(win, 'WebSocket', oldFn => function(url) {
+    if (url) {
+      const u = new URL(url)
+      urlx.pack(u, true, true)
+      arguments[0] = u.href
+    }
+    return construct(oldFn, arguments)
+  })
+
+
+  const scriptProto = win.HTMLScriptElement.prototype
+
+  hook.attr('SCRIPT', scriptProto,
+  // 强制使用 utf-8 编码,方便 SW 编码
+  {
+    name: 'charset',
+    onget(val) {
+      return this._charset || val
+    },
+    onset(val) {
+      if (!util.isUtf8(val)) {
+        val = 'utf-8'
+      }
+      this._charset = val
+      return val
+    }
+  },
+  // 禁止设置内容校验
+  {
+    name: 'integrity',
+    onget(val) {
+      return this._integrity
+    },
+    onset(val) {
+      this._integrity = val
+      return DELETE
+    }
+  },
+  // // 
+  // {
+  //   name: 'type',
+  //   onget(val) {
+  //     return val
+  //   },
+  //   onset(val) {
+  //     updateScript(this)
+  //     return val
+  //   }
+  // },
+  // 
+  {
+    name: 'innerText',
+    onget(val) {
+      return val
+    },
+    onset(val) {
+      updateScript(this)
+      return val
+    }
+  })
+
+  // text 属性只有 prop 没有 attr
+  let scriptTextSetter
+
+  function scriptGetJs(getter) {
+    return function() {
+      return getter.call(this)
+    }
+  }
+  function scriptSetJs(setter) {
+    scriptTextSetter = setter
+
+    return function(val) {
+      updateScript(this)
+      setter.call(this, val)
+    }
+  }
+  hook.prop(scriptProto, 'innerHTML', scriptGetJs, scriptSetJs)
+  hook.prop(scriptProto, 'text', scriptGetJs, scriptSetJs)
+
+  const JS_MIME = {
+    '': true,
+    'text/javascript': true,
+    'application/javascript': true,
+    'module': true,
+  }
+  
+  /**
+   * @param {HTMLScriptElement} elem 
+   */
+  function updateScript(elem) {
+    const type = elem.type
+    if (!JS_MIME[type]) {
+      return
+    }
+    const code = elem.text
+    if (!code) {
+      return
+    }
+    if (elem.__parsed) {
+      return
+    }
+    const ret = jsfilter.parseSync(code)
+    if (ret) {
+      scriptTextSetter.call(elem, ret)
+    }
+    elem.__parsed = true
+  }
+}
+
+initWin(self)
+
+if (self !== parent) {
+  parent.postMessage('__READY', '*')
+}
+
+document.currentScript.remove()
+console.log('[jsproxy] helper inited', location.href)

+ 153 - 0
browser/proxy/src/sw.js

@@ -0,0 +1,153 @@
+import * as urlx from './urlx.js'
+import * as util from './util.js'
+import * as inject from './inject.js'
+
+const TYPE_HTML = 1
+const TYPE_JS = 2
+const TYPE_WORKER = 2
+
+
+/**
+ * 
+ * @param {Request} req 
+ * @param {URL} urlObj 
+ */
+async function forward(req, urlObj, redirNum = 0) {
+  const hasCors = (req.mode === 'cors')
+  urlx.pack(urlObj, true, hasCors)
+
+  let reqType = 0
+  if (req.mode === 'navigate') {
+    reqType = TYPE_HTML
+  } else {
+    const dest = req.destination
+    if (dest === 'script') {
+      reqType = TYPE_JS
+    } else if (dest === 'worker') {
+      reqType = TYPE_WORKER
+    }
+  }
+
+  const reqOpt = {
+    // mode: reqType ? 'cors' : req.mode,
+    mode: 'cors',
+    method: req.method,
+    headers: req.headers,
+    // credentials: req.credentials,
+    signal: req.signal,
+    // referrerPolicy: 'no-referrer',
+    referrer: req.referrer,
+  }
+
+  if (req.method === 'POST') {
+    // TODO: 解决 stream is lock 的错误
+    const buf = await req.arrayBuffer()
+    if (buf.byteLength > 0) {
+      reqOpt.body = buf
+    }
+  }
+
+  const res = await fetch(urlObj, reqOpt)
+  const resStatus = res.status
+
+
+  // https://fetch.spec.whatwg.org/#statuses
+  const isEmpty =
+    (resStatus === 101) ||
+    (resStatus === 204) ||
+    (resStatus === 205) ||
+    (resStatus === 304)
+
+  if (isEmpty) {
+    return res
+  }
+
+  const resHdr = res.headers
+  const resOpt = {
+    status: resStatus,
+    statusText: res.statusText,
+    headers: resHdr,
+  }
+
+  // fake redirect
+  const isRedir =
+    (resStatus === 311) ||
+    (resStatus === 312) ||
+    (resStatus === 317) ||
+    (resStatus === 318)
+
+  if (isRedir) {
+    const newUrl = resHdr.get('location')
+    if (newUrl) {
+      // 重定向到相对路径,是基于请求的 URL 计算(不是页面的 URL)
+      const u = new URL(newUrl, urlObj)
+      if (req.redirect === 'follow') {
+        if (redirNum > 5) {
+          return new Response('TOO_MUCH_REDIR')
+        }
+        return forward(req, u, redirNum + 1)
+      }
+      urlx.encUrlObj(u)
+      // urlx.delFlag(u)
+      resOpt.headers = new Headers(resHdr)
+      resOpt.headers.set('location', u)
+    }
+    resOpt.status = resStatus - 10
+    return new Response(res.body, resOpt)
+  }
+
+  if (reqType === 0) {
+    return res
+  }
+
+  // content-type: text/html; ...; charset="gbk"
+  const ctVal = resHdr.get('content-type') || ''
+  const [, mime, charset] = ctVal
+    .toLocaleLowerCase()
+    .match(/([^;]*)(?:.*?charset=['"]?([^'"]+))?/)
+
+  // if (charset && !util.isUtf8(charset)) {
+  //   console.warn('[jsproxy] charset:', charset, urlObj.href)
+  // }
+
+  if (reqType === TYPE_HTML) {
+    if (mime === 'text/html') {
+      return inject.htmlRemote(res, resOpt)
+    }
+  } else if (reqType === TYPE_JS) {
+    return inject.jsRemote(res, resOpt, charset)
+  }
+  return res
+}
+
+
+async function proxy(e, urlObj) {
+  // TODO: 读取本地缓存的资源,以及从本地 CDN 加速
+  try {
+    return await forward(e.request, urlObj)
+  } catch (err) {
+    console.warn('[jsproxy] forward err:', err)
+  }
+}
+
+
+self.onfetch = function(e) {
+  const u = new URL(e.request.url)
+
+  // internal resource (helper.js)
+  if (urlx.isMyRootHost(u.host)) {
+    return
+  }
+  if (urlx.isHttpProto(u.protocol)) {
+    e.respondWith(proxy(e, u))
+  } else {
+    console.log('ignore non-http res:', u.href)
+  }
+}
+
+
+self.onactivate = function() {
+	clients.claim()
+}
+
+console.log('[jsproxy] sw inited')

+ 326 - 0
browser/proxy/src/urlx.js

@@ -0,0 +1,326 @@
+import {MY_ROOT, HOST_LIST} from './hostlist.js'
+
+const MY_ROOT_DOT = '.' + MY_ROOT
+const MY_EXT = 'ext' + MY_ROOT_DOT
+const MY_EXT_DOT = '.' + MY_EXT
+
+const HOST_ENC_MAP = {}
+const HOST_DEC_MAP = {}
+
+HOST_LIST.forEach(([alias, rHost]) => {
+  HOST_ENC_MAP[rHost] = alias
+  HOST_DEC_MAP[alias] = rHost
+})
+
+
+export function getMyRootHost() {
+  return MY_ROOT
+}
+
+export function getMyExtHost() {
+  return MY_EXT
+}
+
+function makeReg(tmpl, map, suffix = '') {
+  const list = Object.keys(map)
+    .join('|')
+    .replace(/\./g, '\\.')
+
+  const [a, b, c] = tmpl.raw
+  if (suffix) {
+    suffix = suffix.replace(/\./g, '\\.') + c
+  }
+  return RegExp(a + list + b + suffix)
+}
+
+const R_HOST_ENC = makeReg`^([\w-]+\.)??(${HOST_ENC_MAP})$`
+const R_HOST_DEC = makeReg`^([\w-]+\.)??(${HOST_DEC_MAP})${MY_ROOT_DOT}$`
+
+
+/**
+ * encode host (rHost to vHost)
+ * 
+ * @param {string} rHost
+ * @example
+ *  'twitter.com' -> 'tw.mysite.net'
+ *  'www.google.com' -> 'www.gg.mysite.net'
+ *  'www.google.com.hk' -> 'www.gk.mysite.net'
+ *  'unsupport.com' -> 'unsupport-dot-com.mysite.net'
+ *  'not-support.com' -> 'not-support-dot-com.mysite.net'
+ *  '*.mysite.net' -> '*.mysite.net'
+ *  'mysite.net' -> 'mysite.net'
+ */
+function _encHost(rHost) {
+  if (isMyHost(rHost)) {
+    return rHost
+  }
+  // 内置域名(替换成短别名)
+  const m = rHost.match(R_HOST_ENC)
+  if (m) {
+    const [, sub, root] = m
+    const vHost = HOST_ENC_MAP[root]
+    if (vHost) {
+      return (sub || '') + vHost + MY_ROOT_DOT
+    }
+  }
+  // 外置域名(将 `.` 替换成 `-dot-`)
+  if (rHost.includes('-dot-')) {
+    console.warn('invalid host:', rHost)
+    return rHost
+  }
+  return rHost.replace(/\./g, '-dot-') + MY_EXT_DOT
+}
+
+/**
+ * decode host (vHost to rHost)
+ * 
+ * @param {string} vHost
+ * @returns {string}
+ *  return *null* if vHost not ends with `HOST_SUFFIX`
+ *  or not in `HOST_LIST`
+ * 
+ * @example
+ *  'gg.mysite.net' -> 'google.com'
+ *  'www.gg.mysite.net' -> 'www.google.com'
+ *  'not-support-dot-com.mysite.net' -> 'not-support.com'
+ *  'www-dot-mysite-dot-net.mysite.net' -> 'www.mysite.net'
+ *  'www.google.com' -> null
+ *  'x.mysite.net' -> null
+ */
+function _decHost(vHost) {
+  if (isMyExtHost(vHost)) {
+    return vHost
+      .slice(0, -MY_EXT_DOT.length)
+      .replace(/-dot-/g, '.')
+  }
+  const m = vHost.match(R_HOST_DEC)
+  if (m) {
+    const [, sub, root] = m
+    const rHost = HOST_DEC_MAP[root]
+    if (rHost) {
+      return (sub || '') + rHost
+    }
+  }
+  return null
+}
+
+const encCache = {}
+const decCache = {}
+
+/**
+ * @param {string} rHost 
+ */
+export function encHost(rHost) {
+  let ret = encCache[rHost]
+  if (!ret) {
+    ret = _encHost(rHost)
+    encCache[rHost] = ret
+  }
+  return ret
+}
+
+export function decHost(vHost) {
+  let ret = decCache[vHost]
+  if (!ret) {
+    ret = _decHost(vHost)
+    decCache[vHost] = ret
+  }
+  return ret
+}
+
+
+/**
+ * @param {string} host 
+ */
+export function isMyHost(host) {
+  return isMyRootHost(host) || isMySubHost(host)
+}
+
+/**
+ * @param {string} host 
+ */
+export function isMyRootHost(host) {
+  return host === MY_ROOT
+}
+
+/**
+ * @param {string} host 
+ */
+export function isMySubHost(host) {
+  return host.endsWith(MY_ROOT_DOT)
+}
+
+/**
+ * @param {string} host 
+ */
+export function isMyExtHost(host) {
+  return host.endsWith(MY_EXT_DOT)
+}
+
+
+/**
+ * @param {string} path 
+ */
+export function isHttpProto(path) {
+  return /^https?:/.test(path)
+}
+
+
+/**
+ * encode urlObj.hostname to vHost
+ * 
+ * @param {URL} urlObj
+ */
+export function encUrlObj(urlObj) {
+  urlObj.hostname = encHost(urlObj.hostname)
+}
+
+
+/**
+ * @param {URL} urlObj
+ * @returns {boolean}
+ */
+export function decUrlObj(urlObj) {
+  const host = decHost(urlObj.hostname)
+  if (host) {
+    urlObj.hostname = host
+  }
+  return !!host
+}
+
+
+/**
+ * @param {string} url 
+ *  需编码的 URL 字符串,可以是完整 URL,或相对路径、相对协议。
+ * 
+ * @param {string | URL} baseUrl
+ *  如果 url 不完整,需指定一个基地址。
+ *  如果未指定基地址,并且 url 不完整,则返回 url 本身。
+ */
+export function encUrlStr(url, baseUrl) {
+  if (!url) {
+    return url
+  }
+  try {
+    var urlObj = new URL(url, baseUrl)
+  } catch (err) {
+    return url
+  }
+  encUrlObj(urlObj)
+  return urlObj.href
+}
+
+
+/**
+ * @param {string} url 
+ */
+export function decUrlStr(url) {
+  if (!url) {
+    return url
+  }
+  try {
+    var urlObj = new URL(url)
+  } catch (err) {
+    return url
+  }
+  return decUrlObj(urlObj) ? urlObj.href : url
+}
+
+
+/**
+ * @param {URL} urlObj
+ * @param {boolean} hasSw
+ * @param {boolean} hasCors
+ */
+export function pack(urlObj, hasSw, hasCors) {
+  let unsafe = false
+
+  switch (urlObj.protocol) {
+  case 'https:':
+    break
+  case 'wss:':
+    break
+  case 'http:':
+    unsafe = true
+    urlObj.protocol = 'https:'
+    break
+  case 'ws:':
+    unsafe = true
+    urlObj.protocol = 'wss:'
+    break
+  default:
+    // 例如 chrome-extension:
+    return
+  }
+
+  encUrlObj(urlObj)
+
+  const port = urlObj.port
+
+  // 都未设置,则不加 flag
+  if (!hasSw && !unsafe && !hasCors && !port) {
+    return
+  }
+
+  if (port && port !== '443') {
+    urlObj.port = '443'
+  }
+
+  let flag = '' +
+    (+hasSw) +
+    (+unsafe) +
+    (+hasCors) +
+    port
+
+  //
+  // 使用 urlObj.searchParams 设置参数会对已有参数进行编码,例如:
+  // new URL('https://s.yimg.com/zz/combo?yui:/3.12.0/yui/yui-min.js')
+  // 设置参数后 :/ 等字符会被编码,导致资源无法加载。
+  //
+  let args = urlObj.search
+
+  urlObj.search = args.replace(/&flag__=[^&]*|$/, _ => {
+    // 出现 ?&flag= 也没事,后端用同样的方法删除该标记
+    return (args ? '' : '?') + '&flag__=' + flag
+  })
+}
+
+// /**
+//  * @param {URL} urlObj 
+//  */
+// export function delFlag(urlObj) {
+//   urlObj.search = urlObj.search.replace(/&flag__=[^&]*/, '')
+// }
+
+/**
+ * @param {URL} urlObj 
+ */
+export function unpack(urlObj) {
+  const flag = urlObj.searchParams.get('flag__')
+  if (!flag) {
+    return
+  }
+  const unsafe = (flag[1] === '1')
+  const port = flag.substr(3)
+
+  switch (urlObj.protocol) {
+  case 'https:':
+    if (unsafe) {
+      urlObj.protocol = 'http:'
+    }
+    break
+  case 'wss:':
+    if (unsafe) {
+      urlObj.protocol = 'ws:'
+    }
+    break
+  default:
+    console.warn('unpack:', urlObj)
+    return
+  }
+  if (port) {
+    urlObj.port = port
+  }
+
+  decUrlObj(urlObj)
+}

+ 23 - 0
browser/proxy/src/util.js

@@ -0,0 +1,23 @@
+const ENC = new TextEncoder()
+
+/**
+ * @param {string} str 
+ */
+export function strToBytes(str) {
+  return ENC.encode(str)
+}
+
+/**
+ * @param {BufferSource} bytes 
+ * @param {string} charset 
+ */
+export function bytesToStr(bytes, charset = 'utf-8') {
+  return new TextDecoder(charset).decode(bytes)
+}
+
+/**
+ * @param {string} label 
+ */
+export function isUtf8(label) {
+  return /^utf-?8$/i.test(label)
+}

+ 0 - 0
browser/proxy/src/worker.js


+ 17 - 0
browser/setup/gen.sh

@@ -0,0 +1,17 @@
+DST=../../server/www/__setup.html
+
+html-minifier \
+  --collapse-whitespace \
+  --remove-comments \
+  --remove-optional-tags \
+  --remove-redundant-attributes \
+  --remove-script-type-attributes \
+  --remove-tag-whitespace \
+  --use-short-doctype \
+  --remove-attribute-quotes \
+  --minify-css true \
+  --minify-js '{"toplevel": true, "ie8": true}' \
+  -o $DST \
+  index.html
+
+brotli -f $DST

+ 38 - 0
browser/setup/index.html

@@ -0,0 +1,38 @@
+<p id="t"></p>
+<script>
+function reload() {
+  var curr = Date.now()
+  try {
+    var last = +sessionStorage._ts || 0
+    if (curr - last < 100) {
+      return setTimeout(reload, 2000)
+    }
+    sessionStorage._ts = curr
+  } catch (err) {
+  }
+  location.reload()
+}
+
+function onfail(err) {
+  t.innerHTML = err.message
+}
+
+if (top === self) {
+  t.innerHTML = 'loading...'
+}
+
+var sw = navigator.serviceWorker
+if (!sw || !self.ReadableStream) {
+  t.innerHTML = '请使用最新版 Chrome 浏览器访问'
+} else {
+  sw.getRegistration().then(function(reg) {
+    if (reg) {
+      reload()
+    } else {
+      sw.register('/__sw.js')
+        .then(reload)
+        .catch(onfail)
+    }
+  })
+}
+</script>

+ 96 - 0
build.sh

@@ -0,0 +1,96 @@
+source ./dnsconf
+
+svc_port=443
+
+acme_args="-d $DOMAIN -d *.$DOMAIN -d *.ext.$DOMAIN"
+js_arr_items=""
+
+ngx_vhost_rhost="\
+*.ext.$DOMAIN   \$_vhost_dec_ext;
+
+"
+ngx_rhost_vhost="\
+default         \$_rhost_enc_ext.ext.$DOMAIN;
+
+"
+
+while read alias host
+do
+    [ -z "$host" ] && continue
+
+    acme_args+=" -d *.$alias.$DOMAIN"
+    js_arr_items+="  ['$alias', '$host'],
+"
+    # rhost to vhost map
+    dot_str=${host//[^.]}
+    dot_num=${#dot_str}
+    
+    ngx_rhost_vhost+="\
+$host       $alias.$DOMAIN;
+www.$host   www.$alias.$DOMAIN;
+*.$host     \$_rhost_slice_$dot_num.$alias.$DOMAIN;
+
+"
+    # vhost to rhost map
+    dot_str=${alias//[^.]}
+    dot_num=${#dot_str}
+
+		ngx_vhost_rhost+="\
+$alias.$DOMAIN      $host;
+www.$alias.$DOMAIN  www.$host;
+*.$alias.$DOMAIN    \$_vhost_slice_$dot_num.$host;
+
+"
+done < sitelist.txt
+
+
+# gen nginx conf
+echo "$ngx_vhost_rhost" > ./server/include/vhost-rhost.map
+echo "$ngx_rhost_vhost" > ./server/include/rhost-vhost.map
+
+echo "\
+server_name $DOMAIN;
+listen $svc_port ssl;" > ./server/include/host-root.conf
+
+echo "\
+server_name *.$DOMAIN;
+listen $svc_port ssl;" > ./server/include/host-wild.conf
+
+
+# gen ssl cert
+ACME=~/.acme.sh/acme.sh
+
+$ACME \
+  --issue \
+  --dns $DNS_ID \
+  $acme_args
+
+$ACME \
+  --issue \
+  --dns $DNS_ID \
+  $acme_args \
+  --keylength ec-256
+
+
+$ACME \
+	--install-cert -d $DOMAIN \
+	--key-file ./server/cert/$DOMAIN.rsa.key \
+	--fullchain-file ./server/cert/$DOMAIN.fullchain.rsa.cer
+
+$ACME \
+	--install-cert -d $DOMAIN --ecc \
+	--key-file ./server/cert/$DOMAIN.ecc.key \
+	--fullchain-file ./server/cert/$DOMAIN.fullchain.ecc.cer
+
+
+# gen js file
+cd ./browser/proxy
+
+echo "\
+// THIS FILE WAS GENERATED BY build.sh
+// DO NOT MODIFY
+export const MY_ROOT = '$DOMAIN'
+export const HOST_LIST = [
+$js_arr_items]" > ./src/hostlist.js
+
+./release.sh

BIN
docs/domain-model.png


BIN
docs/js-set-cookie.png


BIN
docs/login1.png


BIN
docs/login2.png


BIN
docs/sub-root-cookie.png


+ 2 - 0
server/include/host-root.conf

@@ -0,0 +1,2 @@
+server_name jsproxy.tk;
+listen 443 ssl;

+ 2 - 0
server/include/host-wild.conf

@@ -0,0 +1,2 @@
+server_name *.jsproxy.tk;
+listen 443 ssl;

+ 131 - 0
server/include/rhost-vhost.map

@@ -0,0 +1,131 @@
+default         $_rhost_enc_ext.ext.jsproxy.tk;
+
+google.com       gg.jsproxy.tk;
+www.google.com   www.gg.jsproxy.tk;
+*.google.com     $_rhost_slice_1.gg.jsproxy.tk;
+
+google.cn       gc.jsproxy.tk;
+www.google.cn   www.gc.jsproxy.tk;
+*.google.cn     $_rhost_slice_1.gc.jsproxy.tk;
+
+google.com.hk       gk.jsproxy.tk;
+www.google.com.hk   www.gk.jsproxy.tk;
+*.google.com.hk     $_rhost_slice_2.gk.jsproxy.tk;
+
+googleusercontent.com       gu.jsproxy.tk;
+www.googleusercontent.com   www.gu.jsproxy.tk;
+*.googleusercontent.com     $_rhost_slice_1.gu.jsproxy.tk;
+
+googlesource.com       gs.jsproxy.tk;
+www.googlesource.com   www.gs.jsproxy.tk;
+*.googlesource.com     $_rhost_slice_1.gs.jsproxy.tk;
+
+wikipedia.org       wk.jsproxy.tk;
+www.wikipedia.org   www.wk.jsproxy.tk;
+*.wikipedia.org     $_rhost_slice_1.wk.jsproxy.tk;
+
+m.wikipedia.org       m.wk.jsproxy.tk;
+www.m.wikipedia.org   www.m.wk.jsproxy.tk;
+*.m.wikipedia.org     $_rhost_slice_2.m.wk.jsproxy.tk;
+
+stackoverflow.com       so.jsproxy.tk;
+www.stackoverflow.com   www.so.jsproxy.tk;
+*.stackoverflow.com     $_rhost_slice_1.so.jsproxy.tk;
+
+stackexchange.com       se.jsproxy.tk;
+www.stackexchange.com   www.se.jsproxy.tk;
+*.stackexchange.com     $_rhost_slice_1.se.jsproxy.tk;
+
+serverfault.com       sf.jsproxy.tk;
+www.serverfault.com   www.sf.jsproxy.tk;
+*.serverfault.com     $_rhost_slice_1.sf.jsproxy.tk;
+
+superuser.com       su.jsproxy.tk;
+www.superuser.com   www.su.jsproxy.tk;
+*.superuser.com     $_rhost_slice_1.su.jsproxy.tk;
+
+askubuntu.com       au.jsproxy.tk;
+www.askubuntu.com   www.au.jsproxy.tk;
+*.askubuntu.com     $_rhost_slice_1.au.jsproxy.tk;
+
+github.com       gh.jsproxy.tk;
+www.github.com   www.gh.jsproxy.tk;
+*.github.com     $_rhost_slice_1.gh.jsproxy.tk;
+
+quora.com       qr.jsproxy.tk;
+www.quora.com   www.qr.jsproxy.tk;
+*.quora.com     $_rhost_slice_1.qr.jsproxy.tk;
+
+unix.com       ux.jsproxy.tk;
+www.unix.com   www.ux.jsproxy.tk;
+*.unix.com     $_rhost_slice_1.ux.jsproxy.tk;
+
+mozilla.org       mz.jsproxy.tk;
+www.mozilla.org   www.mz.jsproxy.tk;
+*.mozilla.org     $_rhost_slice_1.mz.jsproxy.tk;
+
+w3schools.com       w3.jsproxy.tk;
+www.w3schools.com   www.w3.jsproxy.tk;
+*.w3schools.com     $_rhost_slice_1.w3.jsproxy.tk;
+
+chromium.org       cr.jsproxy.tk;
+www.chromium.org   www.cr.jsproxy.tk;
+*.chromium.org     $_rhost_slice_1.cr.jsproxy.tk;
+
+myspace.com       my.jsproxy.tk;
+www.myspace.com   www.my.jsproxy.tk;
+*.myspace.com     $_rhost_slice_1.my.jsproxy.tk;
+
+facebook.com       fb.jsproxy.tk;
+www.facebook.com   www.fb.jsproxy.tk;
+*.facebook.com     $_rhost_slice_1.fb.jsproxy.tk;
+
+youtube.com       yt.jsproxy.tk;
+www.youtube.com   www.yt.jsproxy.tk;
+*.youtube.com     $_rhost_slice_1.yt.jsproxy.tk;
+
+twitter.com       tw.jsproxy.tk;
+www.twitter.com   www.tw.jsproxy.tk;
+*.twitter.com     $_rhost_slice_1.tw.jsproxy.tk;
+
+flickr.com       fl.jsproxy.tk;
+www.flickr.com   www.fl.jsproxy.tk;
+*.flickr.com     $_rhost_slice_1.fl.jsproxy.tk;
+
+reddit.com       rd.jsproxy.tk;
+www.reddit.com   www.rd.jsproxy.tk;
+*.reddit.com     $_rhost_slice_1.rd.jsproxy.tk;
+
+blogger.com       bg.jsproxy.tk;
+www.blogger.com   www.bg.jsproxy.tk;
+*.blogger.com     $_rhost_slice_1.bg.jsproxy.tk;
+
+wordpress.com       wp.jsproxy.tk;
+www.wordpress.com   www.wp.jsproxy.tk;
+*.wordpress.com     $_rhost_slice_1.wp.jsproxy.tk;
+
+medium.com       md.jsproxy.tk;
+www.medium.com   www.md.jsproxy.tk;
+*.medium.com     $_rhost_slice_1.md.jsproxy.tk;
+
+hackernoon.com       hn.jsproxy.tk;
+www.hackernoon.com   www.hn.jsproxy.tk;
+*.hackernoon.com     $_rhost_slice_1.hn.jsproxy.tk;
+
+yahoo.com       yh.jsproxy.tk;
+www.yahoo.com   www.yh.jsproxy.tk;
+*.yahoo.com     $_rhost_slice_1.yh.jsproxy.tk;
+
+bbc.com       bc.jsproxy.tk;
+www.bbc.com   www.bc.jsproxy.tk;
+*.bbc.com     $_rhost_slice_1.bc.jsproxy.tk;
+
+twitch.tv       th.jsproxy.tk;
+www.twitch.tv   www.th.jsproxy.tk;
+*.twitch.tv     $_rhost_slice_1.th.jsproxy.tk;
+
+steamcommunity.com       sc.jsproxy.tk;
+www.steamcommunity.com   www.sc.jsproxy.tk;
+*.steamcommunity.com     $_rhost_slice_1.sc.jsproxy.tk;
+
+

+ 131 - 0
server/include/vhost-rhost.map

@@ -0,0 +1,131 @@
+*.ext.jsproxy.tk   $_vhost_dec_ext;
+
+gg.jsproxy.tk      google.com;
+www.gg.jsproxy.tk  www.google.com;
+*.gg.jsproxy.tk    $_vhost_slice_0.google.com;
+
+gc.jsproxy.tk      google.cn;
+www.gc.jsproxy.tk  www.google.cn;
+*.gc.jsproxy.tk    $_vhost_slice_0.google.cn;
+
+gk.jsproxy.tk      google.com.hk;
+www.gk.jsproxy.tk  www.google.com.hk;
+*.gk.jsproxy.tk    $_vhost_slice_0.google.com.hk;
+
+gu.jsproxy.tk      googleusercontent.com;
+www.gu.jsproxy.tk  www.googleusercontent.com;
+*.gu.jsproxy.tk    $_vhost_slice_0.googleusercontent.com;
+
+gs.jsproxy.tk      googlesource.com;
+www.gs.jsproxy.tk  www.googlesource.com;
+*.gs.jsproxy.tk    $_vhost_slice_0.googlesource.com;
+
+wk.jsproxy.tk      wikipedia.org;
+www.wk.jsproxy.tk  www.wikipedia.org;
+*.wk.jsproxy.tk    $_vhost_slice_0.wikipedia.org;
+
+m.wk.jsproxy.tk      m.wikipedia.org;
+www.m.wk.jsproxy.tk  www.m.wikipedia.org;
+*.m.wk.jsproxy.tk    $_vhost_slice_1.m.wikipedia.org;
+
+so.jsproxy.tk      stackoverflow.com;
+www.so.jsproxy.tk  www.stackoverflow.com;
+*.so.jsproxy.tk    $_vhost_slice_0.stackoverflow.com;
+
+se.jsproxy.tk      stackexchange.com;
+www.se.jsproxy.tk  www.stackexchange.com;
+*.se.jsproxy.tk    $_vhost_slice_0.stackexchange.com;
+
+sf.jsproxy.tk      serverfault.com;
+www.sf.jsproxy.tk  www.serverfault.com;
+*.sf.jsproxy.tk    $_vhost_slice_0.serverfault.com;
+
+su.jsproxy.tk      superuser.com;
+www.su.jsproxy.tk  www.superuser.com;
+*.su.jsproxy.tk    $_vhost_slice_0.superuser.com;
+
+au.jsproxy.tk      askubuntu.com;
+www.au.jsproxy.tk  www.askubuntu.com;
+*.au.jsproxy.tk    $_vhost_slice_0.askubuntu.com;
+
+gh.jsproxy.tk      github.com;
+www.gh.jsproxy.tk  www.github.com;
+*.gh.jsproxy.tk    $_vhost_slice_0.github.com;
+
+qr.jsproxy.tk      quora.com;
+www.qr.jsproxy.tk  www.quora.com;
+*.qr.jsproxy.tk    $_vhost_slice_0.quora.com;
+
+ux.jsproxy.tk      unix.com;
+www.ux.jsproxy.tk  www.unix.com;
+*.ux.jsproxy.tk    $_vhost_slice_0.unix.com;
+
+mz.jsproxy.tk      mozilla.org;
+www.mz.jsproxy.tk  www.mozilla.org;
+*.mz.jsproxy.tk    $_vhost_slice_0.mozilla.org;
+
+w3.jsproxy.tk      w3schools.com;
+www.w3.jsproxy.tk  www.w3schools.com;
+*.w3.jsproxy.tk    $_vhost_slice_0.w3schools.com;
+
+cr.jsproxy.tk      chromium.org;
+www.cr.jsproxy.tk  www.chromium.org;
+*.cr.jsproxy.tk    $_vhost_slice_0.chromium.org;
+
+my.jsproxy.tk      myspace.com;
+www.my.jsproxy.tk  www.myspace.com;
+*.my.jsproxy.tk    $_vhost_slice_0.myspace.com;
+
+fb.jsproxy.tk      facebook.com;
+www.fb.jsproxy.tk  www.facebook.com;
+*.fb.jsproxy.tk    $_vhost_slice_0.facebook.com;
+
+yt.jsproxy.tk      youtube.com;
+www.yt.jsproxy.tk  www.youtube.com;
+*.yt.jsproxy.tk    $_vhost_slice_0.youtube.com;
+
+tw.jsproxy.tk      twitter.com;
+www.tw.jsproxy.tk  www.twitter.com;
+*.tw.jsproxy.tk    $_vhost_slice_0.twitter.com;
+
+fl.jsproxy.tk      flickr.com;
+www.fl.jsproxy.tk  www.flickr.com;
+*.fl.jsproxy.tk    $_vhost_slice_0.flickr.com;
+
+rd.jsproxy.tk      reddit.com;
+www.rd.jsproxy.tk  www.reddit.com;
+*.rd.jsproxy.tk    $_vhost_slice_0.reddit.com;
+
+bg.jsproxy.tk      blogger.com;
+www.bg.jsproxy.tk  www.blogger.com;
+*.bg.jsproxy.tk    $_vhost_slice_0.blogger.com;
+
+wp.jsproxy.tk      wordpress.com;
+www.wp.jsproxy.tk  www.wordpress.com;
+*.wp.jsproxy.tk    $_vhost_slice_0.wordpress.com;
+
+md.jsproxy.tk      medium.com;
+www.md.jsproxy.tk  www.medium.com;
+*.md.jsproxy.tk    $_vhost_slice_0.medium.com;
+
+hn.jsproxy.tk      hackernoon.com;
+www.hn.jsproxy.tk  www.hackernoon.com;
+*.hn.jsproxy.tk    $_vhost_slice_0.hackernoon.com;
+
+yh.jsproxy.tk      yahoo.com;
+www.yh.jsproxy.tk  www.yahoo.com;
+*.yh.jsproxy.tk    $_vhost_slice_0.yahoo.com;
+
+bc.jsproxy.tk      bbc.com;
+www.bc.jsproxy.tk  www.bbc.com;
+*.bc.jsproxy.tk    $_vhost_slice_0.bbc.com;
+
+th.jsproxy.tk      twitch.tv;
+www.th.jsproxy.tk  www.twitch.tv;
+*.th.jsproxy.tk    $_vhost_slice_0.twitch.tv;
+
+sc.jsproxy.tk      steamcommunity.com;
+www.sc.jsproxy.tk  www.steamcommunity.com;
+*.sc.jsproxy.tk    $_vhost_slice_0.steamcommunity.com;
+
+

+ 95 - 0
server/mime.types

@@ -0,0 +1,95 @@
+
+types {
+    text/html                                        html htm shtml;
+    text/css                                         css;
+    text/xml                                         xml;
+    image/gif                                        gif;
+    image/jpeg                                       jpeg jpg;
+    application/javascript                           js;
+    application/atom+xml                             atom;
+    application/rss+xml                              rss;
+
+    text/mathml                                      mml;
+    text/plain                                       txt;
+    text/vnd.sun.j2me.app-descriptor                 jad;
+    text/vnd.wap.wml                                 wml;
+    text/x-component                                 htc;
+
+    image/png                                        png;
+    image/svg+xml                                    svg svgz;
+    image/tiff                                       tif tiff;
+    image/vnd.wap.wbmp                               wbmp;
+    image/webp                                       webp;
+    image/x-icon                                     ico;
+    image/x-jng                                      jng;
+    image/x-ms-bmp                                   bmp;
+
+    application/font-woff                            woff;
+    application/java-archive                         jar war ear;
+    application/json                                 json;
+    application/mac-binhex40                         hqx;
+    application/msword                               doc;
+    application/pdf                                  pdf;
+    application/postscript                           ps eps ai;
+    application/rtf                                  rtf;
+    application/vnd.apple.mpegurl                    m3u8;
+    application/vnd.google-earth.kml+xml             kml;
+    application/vnd.google-earth.kmz                 kmz;
+    application/vnd.ms-excel                         xls;
+    application/vnd.ms-fontobject                    eot;
+    application/vnd.ms-powerpoint                    ppt;
+    application/vnd.oasis.opendocument.graphics      odg;
+    application/vnd.oasis.opendocument.presentation  odp;
+    application/vnd.oasis.opendocument.spreadsheet   ods;
+    application/vnd.oasis.opendocument.text          odt;
+    application/vnd.openxmlformats-officedocument.presentationml.presentation
+                                                     pptx;
+    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+                                                     xlsx;
+    application/vnd.openxmlformats-officedocument.wordprocessingml.document
+                                                     docx;
+    application/vnd.wap.wmlc                         wmlc;
+    application/x-7z-compressed                      7z;
+    application/x-cocoa                              cco;
+    application/x-java-archive-diff                  jardiff;
+    application/x-java-jnlp-file                     jnlp;
+    application/x-makeself                           run;
+    application/x-perl                               pl pm;
+    application/x-pilot                              prc pdb;
+    application/x-rar-compressed                     rar;
+    application/x-redhat-package-manager             rpm;
+    application/x-sea                                sea;
+    application/x-shockwave-flash                    swf;
+    application/x-stuffit                            sit;
+    application/x-tcl                                tcl tk;
+    application/x-x509-ca-cert                       der pem crt;
+    application/x-xpinstall                          xpi;
+    application/xhtml+xml                            xhtml;
+    application/xspf+xml                             xspf;
+    application/zip                                  zip;
+
+    application/octet-stream                         bin exe dll;
+    application/octet-stream                         deb;
+    application/octet-stream                         dmg;
+    application/octet-stream                         iso img;
+    application/octet-stream                         msi msp msm;
+
+    audio/midi                                       mid midi kar;
+    audio/mpeg                                       mp3;
+    audio/ogg                                        ogg;
+    audio/x-m4a                                      m4a;
+    audio/x-realaudio                                ra;
+
+    video/3gpp                                       3gpp 3gp;
+    video/mp2t                                       ts;
+    video/mp4                                        mp4;
+    video/mpeg                                       mpeg mpg;
+    video/quicktime                                  mov;
+    video/webm                                       webm;
+    video/x-flv                                      flv;
+    video/x-m4v                                      m4v;
+    video/x-mng                                      mng;
+    video/x-ms-asf                                   asx asf;
+    video/x-ms-wmv                                   wmv;
+    video/x-msvideo                                  avi;
+}

+ 255 - 0
server/nginx.conf

@@ -0,0 +1,255 @@
+user root;
+worker_processes  1;
+
+#error_log  logs/error.log;
+#error_log  logs/error.log  notice;
+error_log  logs/warn.log   warn;
+
+pid        logs/nginx.pid;
+
+events {
+  worker_connections  1024;
+}
+
+http {
+  # DNS 服务器地址
+  resolver            1.1.1.1 ipv6=off;
+
+  include             mime.types;
+  default_type        text/html;
+  sendfile            on;
+  #tcp_nopush         on;
+
+  #keepalive_timeout  0;
+  keepalive_timeout   60;
+  keepalive_requests  1024;
+
+  server_tokens       off;
+  more_clear_headers  Server;
+
+  # 限流配置
+  limit_req_log_level warn;
+  limit_req_zone      $binary_remote_addr zone=reqip:16m rate=100r/s;
+  limit_req           zone=reqip burst=200 nodelay;
+
+  # 代理日志(分隔符 \t)
+  log_format              log_proxy
+    '$time_iso8601	$remote_addr	$request_time '
+    '$request_length	$bytes_sent	'
+    '$request_method $_proto	$proxy_host	$request_uri	$status	'
+    '$http_user_agent'
+  ;
+  # 普通日志
+  log_format              log_access
+    '$time_iso8601 $remote_addr $request_time '
+    '$request_method $uri $http_host $status '
+    '$http_user_agent'
+  ;
+  access_log              logs/access.log log_access buffer=64k flush=1s;
+
+  # 缓冲区配置
+  #(设置过低某些网站无法访问)
+  proxy_buffer_size       16k;
+  proxy_buffers           4 32k;
+  proxy_busy_buffers_size 64k;
+
+  proxy_send_timeout      10s;
+
+  # 代理缓存配置
+  proxy_cache_path        cache
+    levels=1:2
+    keys_zone=my_cache:8m
+    max_size=10g
+    inactive=6h
+    use_temp_path=off;
+
+  # SSL 双证书
+  ssl_certificate         cert/jsproxy.tk.fullchain.rsa.cer;
+  ssl_certificate_key     cert/jsproxy.tk.rsa.key;
+
+  ssl_certificate         cert/jsproxy.tk.fullchain.ecc.cer;
+  ssl_certificate_key     cert/jsproxy.tk.ecc.key;
+
+  ssl_protocols           TLSv1 TLSv1.1 TLSv1.2;
+  ssl_ciphers             ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE+AES128:RSA+AES128:ECDHE+AES256:RSA+AES256:ECDHE+3DES:RSA+3DES;
+  ssl_prefer_server_ciphers   on;
+  ssl_session_cache       shared:SSL:10m;
+  ssl_session_timeout     5m;
+
+  proxy_cache             my_cache;
+  proxy_http_version      1.1;
+  proxy_ssl_server_name   on;
+
+  underscores_in_headers  on;
+  merge_slashes           off;
+
+  # 非内置域名的编码(将 `.` 替换成 `-dot-`)
+  # TODO: 由于 nginx map 不支持字符串替换,暂时先这么实现。。。
+  map $_rhost $_rhost_enc_ext {
+    volatile;
+    '~^([\w-]+)\.(\w+)$'                      '$1-dot-$2';
+    '~^([\w-]+)\.([\w-]+)\.(\w+)$'            '$1-dot-$2-dot-$3';
+    '~^([\w-]+)\.([\w-]+)\.([\w-]+)\.(\w+)$'  '$1-dot-$2-dot-$3-dot-$4';
+  }
+
+  # 非内置域名的解码(将 `-dot-` 替换成 `.`)
+  # TODO: 效率低而且级数有限,下次改成 lua 实现
+  map $_ext_src $_ext_dst {
+    volatile;
+    '~^([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-(\w+)$'
+      '$1.$2.$3.$4.$5.$6';
+    '~^([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-(\w+)$'
+      '$1.$2.$3.$4.$5';
+    '~^([\w-]+?)-dot-([\w-]+?)-dot-([\w-]+?)-dot-(\w+)$'
+      '$1.$2.$3.$4';
+    '~^([\w-]+?)-dot-([\w-]+?)-dot-(\w+)$'
+      '$1.$2.$3';
+    '~^([\w-]+?)-dot-(\w+)$'
+      '$1.$2';
+  }
+  map $_vhost $_vhost_dec_ext {
+    volatile;                 #.example.com
+    '~^(?<_ext_src>[^.]+)\.ext\.[\w-]+\.\w+$'    $_ext_dst;
+  }
+
+  # 虚拟域名 -> 真实域名
+  map $_vhost $_vhost_to_rhost {
+    volatile;
+    hostnames;
+    include   include/vhost-rhost.map;
+  }
+
+  # 真实域名 -> 虚拟域名
+  map $_rhost $_rhost_to_vhost {
+    volatile;
+    hostnames;
+    include   include/rhost-vhost.map;
+  }
+
+  # TODO: 由于 hostnames 无法获取 * 部分,暂时先这样获取子域
+  # *.com -> *
+  map $_rhost $_rhost_slice_0 {
+    volatile;
+    '~^(.+?)\.\w+$' $1;
+  }
+  # *.google.com -> *
+  map $_rhost $_rhost_slice_1 {
+    volatile;
+    '~^(.+?)\.(?:[\w-]+\.){1}\w+$' $1;
+  }
+  # *.google.com.hk -> *
+  map $_rhost $_rhost_slice_2 {
+    volatile;
+    '~^(.+?)\.(?:[\w-]+\.){2}\w+$' $1;
+  }
+  # *.wk -> *
+  map $_vhost $_vhost_slice_0 {
+    volatile;    #.example.com
+    '~^(.+?)\.\w+\.[\w-]+\.\w+$' $1;
+  }
+  # *.m.wk -> *
+  map $_vhost $_vhost_slice_1 {
+    volatile;                   #.example.com
+    '~^(.+?)\.(?:[\w-]+\.){1}\w+\.[\w-]+\.\w+$' $1;
+  }
+  ##########
+
+
+
+  # 静态资源站点
+  server {
+    include               include/host-root.conf;
+    root                  ../www;
+    charset               utf-8;
+    add_header            cache-control   max-age=300;
+    add_header            strict-transport-security 'max-age=99999999; includeSubDomains; preload';
+
+    # 需要编译 ngx_brotli 模块(参考 setup.sh)
+    brotli_static         on;
+  }
+
+
+  # 内置站点代理
+  # 格式为 https://[sub.]alias.example.com/path/to/?query
+  server {
+    include               include/host-wild.conf;
+
+    location / {
+      set                 $_vhost $host;
+      set                 $_site  $_vhost_to_rhost;
+
+      if ($_site = '') {
+        return            404  "unknown site";
+      }
+      include             proc-hdr.conf;
+
+      # 非 JS 发送的请求,返回安装 ServiceWorker 的页面
+      # 该请求为首次访问时发起,后续请求会被 SW 拦截并带上 JS 标记
+      if ($_hasSw = '0') {
+        rewrite           ^ /__setup.html;
+      }
+
+      set                 $_proto 'https';
+      if ($_isHttp = '1') {
+        set               $_proto 'http';
+      }
+      if ($_port) {
+        set               $_port  ':$_port';
+      }
+
+      # CORS preflight
+      set                 $_acao  $http_origin;
+      if ($_acao = '') {
+        # TODO: 有没有不存在 origin 字段的情况?
+        set               $_acao  '*';
+      }
+      if ($request_method = 'OPTIONS') {
+        more_set_headers
+          'access-control-allow-origin: $_acao'
+          'access-control-allow-Methods: GET, POST, PUT, DELETE, HEAD, OPTIONS'
+          'access-control-allow-Headers: $http_Access_Control_Request_Headers'
+          'access-control-max-Age: 1728000'
+        ;
+        return            204;
+      }
+
+#       return 200 "[debug]
+# request_uri: $request_uri
+# host: $host
+# isHttp: $_isHttp
+# uri: $uri
+# args: $args
+# is_args: $is_args
+# _ref: $_ref
+# _ori: $_ori
+# _hasSw: $_hasSw
+# proxy: $_proto://$_site$_port;
+# ";
+
+      access_log          logs/proxy.log log_proxy buffer=64k flush=1s;
+      proxy_pass          $_proto://$_site$_port;
+
+      # 将返回头 set-cookie 中的 domain 部分转换成我们的虚拟域名
+      proxy_cookie_domain ~^\.?(?<_rhost>.*)$ $_rhost_to_vhost;
+    }
+
+    location = /__setup.html {
+      internal;
+      brotli_static       on;
+      charset             utf-8;
+      root                ../www;
+      etag                off;
+      more_clear_headers  Accept-Ranges Last-Modified;
+    }
+
+    # 由于 ServiceWorker 脚本必须位于同源站点,
+    # 因此为了减少重复加载,此处只返回实际脚本的引用。
+    location = /__sw.js {
+      add_header          cache-control   max-age=9999999;
+      return              200   'importScripts("//jsproxy.tk/x.js")';
+    }
+  }
+
+  # 测试案例(暂未完成)
+  #include test.conf;
+}

+ 68 - 0
server/proc-hdr.conf

@@ -0,0 +1,68 @@
+# 标记客户端是否已安装 Service Worker
+# 0:请求任何路径,都返回 SW 安装页面(www/__setup.html)
+# 1:正常反向代理
+set $_hasSw         '0';
+
+# 标记资源的协议
+# 0:HTTP
+# 1:HTTPS
+set $_isHttp        '0';
+
+# 标记是否为 CORS 请求
+# 0:不转发 Origin 头
+# 1:调整并转发 Origin 头
+set $_hasCors       '0';
+
+# 记录资源的端口号
+set $_port          '';
+
+set $_ref           '';
+set $_ori           $http_origin;
+
+
+# 获取并删除 flag 参数
+# 参数格式: isHttp .. port 
+if ($args ~
+  (?<_pre>.*?)&flag__=(?<_hasSw>.)(?<_isHttp>.)(?<_hasCors>.)(?<_port>\d*)(?<_post>.*)
+) {
+  set               $args       $_pre$_post;
+  set               $_js        1;
+}
+
+# 调整 Referer 头
+# TODO:未考虑协议和端口,下面的 cors 也有这问题
+if ($http_referer ~ ^https://(?<_vhost>[^/]+)(?<_path>.*)) {
+  set               $_ref       https://$_vhost_to_rhost$_path;
+}
+
+# ServiceWorker 的 fetch 强制 cors 模式,
+# 所以需要该标记,标识原始请求是否为 cors
+if ($_hasCors = '0') {
+  set               $_ori       '';
+}
+if ($_ori           ~ ^https://(?<_vhost>.*)) {
+  set               $_ori       https://$_vhost_to_rhost;
+}
+
+proxy_set_header    Origin      $_ori;
+proxy_set_header    Referer     $_ref;
+proxy_set_header    Upgrade     $http_upgrade;
+proxy_set_header    Connection  $http_connection;
+
+# CSP 可能导致注入页面资源无法加载
+more_clear_headers
+  content-security-policy
+  content-security-policy-report-only
+  expect-ct
+  x-frame-options
+;
+
+more_set_headers
+  'access-control-allow-credentials: true'
+  'access-control-allow-origin: *'
+  'strict-transport-security: max-age=99999999; includeSubDomains; preload'
+;
+
+# 重定向调整
+# 直接用 return 指令返回状态码,会丢失其他的头,比如 set-cookie
+header_filter_by_lua_file   ../proc-redir.lua;

+ 49 - 0
server/proc-redir.lua

@@ -0,0 +1,49 @@
+-- https://fetch.spec.whatwg.org/#statuses
+local s = ngx.status
+if not (s == 301 or s == 302 or s == 307 or s == 308) then
+  return
+end
+
+-- 忽略 WebSocket
+if ngx.header['upgrade'] then
+  return
+end
+
+--[=[
+  如果直接返回 30X 状态,fetch API 会继续请求新的 URL,
+  不符合 req.redirect 为 manual 的情况。
+
+  例如请求 google.com 会重定向到 www.google.com,
+  如果最终获得的内容是后者,但地址栏显示的是前者,路径上就会出现问题。
+
+  如果在 SW 里设置 req.redirect = manual,重定向后拿不到 location。
+  所以这里对状态码 + 10 进行转义,SW 收到后再 -10。
+]=]
+ngx.status = s + 10
+ngx.header['access-control-expose-headers'] = 'location'
+
+
+-- local url = ngx.header['location']
+-- if not url then
+--   return
+-- end
+
+-- -- m = [, rhost, path]
+-- local r = [[^https?://([^/]+)(.*)]]
+-- local m = ngx.re.match(url, r, 'jo')
+-- if not m then
+--   return
+-- end
+
+-- -- rhost to vhost
+-- ngx.var._rhost = m[1]
+-- local vhost = ngx.var._rhost_to_vhost
+
+-- url = 'https://' .. vhost .. m[2]
+
+-- -- add flag
+-- local sign = url:find('?', 1, true) and '&' or '?'
+-- url = url .. sign .. 'flag__=' .. ngx.var._flag
+
+-- -- update redir url
+-- ngx.header['location'] = url

+ 8 - 0
server/run.sh

@@ -0,0 +1,8 @@
+NGX_BIN=/usr/local/openresty/nginx/sbin/nginx
+CUR_DIR=$(cd `dirname $0` && pwd)
+
+if [ $1 ]; then
+    PARAM="-s $1"
+fi
+
+$NGX_BIN -c $CUR_DIR/nginx.conf -p $CUR_DIR/nginx $PARAM

+ 62 - 0
server/setup.sh

@@ -0,0 +1,62 @@
+# nodejs
+curl -sL https://rpm.nodesource.com/setup_10.x | sudo bash -
+
+yum install -y \
+	gcc gcc-c++ clang \
+	zlib zlib-devel unzip \
+	git bc sed tree \
+	make autoconf automake libtool \
+	nodejs
+
+npm i -g webpack webpack-cli
+npm i -g html-minifier
+
+
+# install openresty
+mkdir -p install
+cd install
+
+curl -O https://ftp.pcre.org/pub/pcre/pcre-8.42.zip
+unzip pcre-*
+
+curl -O https://www.openssl.org/source/openssl-1.0.2p.tar.gz
+tar zxvf openssl-*
+
+git clone --recurse-submodules --depth 1 https://github.com/google/ngx_brotli.git
+
+curl -O https://openresty.org/download/openresty-1.13.6.2.tar.gz
+tar zxvf openresty-*
+cd openresty-*
+
+export NGX_BROTLI_STATIC_MODULE_ONLY=1
+./configure \
+	--add-module=../ngx_brotli \
+	--with-http_ssl_module \
+	--with-openssl=../openssl-1.0.2p \
+	--with-pcre=../pcre-8.42 \
+	--with-pcre-jit
+
+make
+make install
+
+
+# install brotli
+# https://www.howtoforge.com/how-to-compile-brotli-from-source-on-centos-7/
+git clone --depth 1 https://github.com/google/brotli.git
+cd ./brotli
+cp docs/brotli.1 /usr/share/man/man1 && gzip /usr/share/man/man1/brotli.1
+./bootstrap
+./configure --prefix=/usr \
+            --bindir=/usr/bin \
+            --sbindir=/usr/sbin \
+            --libexecdir=/usr/lib64/brotli \
+            --libdir=/usr/lib64/brotli \
+            --datarootdir=/usr/share \
+            --mandir=/usr/share/man/man1 \
+            --docdir=/usr/share/doc
+make
+make install
+
+
+# install acme.sh
+curl https://get.acme.sh | sh

+ 1 - 0
server/www/__setup.html

@@ -0,0 +1 @@
+<p id=t></p><script>function n(){var e=Date.now();try{if(e-(+sessionStorage._ts||0)<100)return setTimeout(n,2e3);sessionStorage._ts=e}catch(t){}location.reload()}function r(e){t.innerHTML=e.message}top===self&&(t.innerHTML="loading...");var o=navigator.serviceWorker;o&&self.ReadableStream?o.getRegistration().then(function(e){e?n():o.register("/__sw.js").then(n)["catch"](r)}):t.innerHTML="请使用最新版 Chrome 浏览器访问"</script>

BIN
server/www/__setup.html.br


+ 26 - 0
server/www/index.html

@@ -0,0 +1,26 @@
+<!doctype html>
+<html>
+<head>
+  <title>JS Proxy</title>
+  <meta charset="utf-8">
+  <script src="x.js"></script>
+  <style>
+    #txtURL {
+      width: 300px;
+    }
+  </style>
+</head>
+<body>
+  <h1>网页沙盒</h1>
+  <div>
+    URL:
+    <input id="txtURL" value="https://www.google.com.hk">
+    <button id="btnGo">Go</button>
+  </div>
+  <script>
+    btnGo.onclick = function() {
+      open(txtURL.value)
+    }
+  </script>
+</body>
+</html>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 96 - 0
server/www/x.js


BIN
server/www/x.js.br


+ 32 - 0
sitelist.txt

@@ -0,0 +1,32 @@
+gg    google.com
+gc    google.cn
+gk    google.com.hk
+gu    googleusercontent.com
+gs    googlesource.com
+wk    wikipedia.org
+m.wk  m.wikipedia.org
+so    stackoverflow.com
+se    stackexchange.com
+sf    serverfault.com
+su    superuser.com
+au    askubuntu.com
+gh    github.com
+qr    quora.com
+ux    unix.com
+mz    mozilla.org
+w3    w3schools.com
+cr    chromium.org
+my    myspace.com
+fb    facebook.com
+yt    youtube.com
+tw    twitter.com
+fl    flickr.com
+rd    reddit.com
+bg    blogger.com
+wp    wordpress.com
+md    medium.com
+hn    hackernoon.com
+yh    yahoo.com
+bc    bbc.com
+th    twitch.tv
+sc    steamcommunity.com

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно