├── .gitignore ├── LICENSE ├── README.md ├── acme ├── .well-known │ └── acme-challenge │ │ └── test.txt └── README.md ├── allowed-sites.conf ├── api.conf ├── cert ├── .gitignore ├── README.md └── cert.conf ├── cf-worker ├── .eslintrc.json ├── README.md └── index.js ├── changelogs ├── README.md ├── v0.0.1.md └── v0.1.0.md ├── docs ├── blogs │ └── js-hook.md ├── cert-auto.md ├── cert-manual.md └── setup.md ├── i.sh ├── log-svc ├── README.md ├── backup.sh ├── backup │ └── README.md └── svc.sh ├── log.conf ├── lua ├── http-body-hash.lua ├── http-dec-req-hdr.lua ├── http-enc-res-hdr.lua └── ws-dec-req-hdr.lua ├── mime.types ├── nginx.conf ├── nginx ├── .gitignore └── logs │ └── .gitignore ├── run.sh ├── setup-ipset.sh ├── test └── works.txt ├── www.conf └── www ├── .gitignore └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | ._* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 EtherDream 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 更新 3 | 4 | * 2019-07-24 [v0.1.0](https://github.com/EtherDream/jsproxy/blob/master/changelogs/v0.1.0.md) 发布,主要修复了缓存失效的问题。网络接口和之前版本不兼容,请及时更新服务端和 cfworker。 5 | 6 | * 2019-06-22 [cfworker 无服务器版](cf-worker) 发布,长期使用演示服务的请使用该版本。 7 | 8 | [查看更多](changelogs) 9 | 10 | 11 | # 安装 12 | 13 | ```bash 14 | curl https://raw.githubusercontent.com/EtherDream/jsproxy/0.1.0/i.sh | bash 15 | ``` 16 | 17 | * 自动安装目前只支持 Linux x64,并且需要 root 权限 18 | 19 | * 安装过程中 80 端口能被外网访问(申请 HTTPS 证书) 20 | 21 | 无法满足上述条件,或想了解安装细节,可尝试[手动安装](docs/setup.md)。 22 | 23 | 测试: `https://服务器IP.xip.io:8443`(具体参考脚本输出) 24 | 25 | 26 | ### 自定义域名 27 | 28 | 将域名 `example.com` 解析到服务器 IP,然后执行: 29 | 30 | ```bash 31 | curl https://raw.githubusercontent.com/EtherDream/jsproxy/master/i.sh | bash -s example.com 32 | ``` 33 | 34 | 访问: `https://example.com:8443` 35 | 36 | 37 | ### 自定义端口 38 | 39 | 默认端口为 8443 (HTTPS) 和 8080 (HTTP) ,如需改成 443 和 80,推荐使用端口转发: 40 | 41 | ```bash 42 | iptables -A PREROUTING -t nat -p tcp --dport 443 -j REDIRECT --to-ports 8443 43 | iptables -A PREROUTING -t nat -p tcp --dport 80 -j REDIRECT --to-ports 8080 44 | ``` 45 | 46 | 同时修改 `www.conf` 中的 `:8443` 为 `:443`。 47 | 48 | 49 | ### 使用 GitHub Pages 前端 50 | 51 | 本项目支持前后端分离,前端部分(`www` 目录下的文件)可部署在第三方 Web 服务器上。 52 | 53 | 例如演示站点的前端部署于 GitHub Pages 服务,从而可使用个性域名(*.github.io),还能减少一定的流量开销。 54 | 55 | Fork 本项目,进入 `gh-pages` 分支(该分支内容和 `www` 目录相同),编辑 `conf.js` 文件: 56 | 57 | * 节点列表(`node_map` 字段,包括节点 id 和节点主机) 58 | 59 | * 默认节点(`node_default` 字段,指定节点 id) 60 | 61 | 访问 `https://用户名.github.io/jsproxy` 预览。 62 | 63 | 64 | # 维护 65 | 66 | ```sh 67 | # 切换到 jsproxy 用户 68 | su - jsproxy 69 | 70 | # 重启服务 71 | ./run.sh reload 72 | 73 | # 关闭服务(参数和 nginx -s 相同) 74 | ./run.sh quit 75 | 76 | # 启动服务 77 | ./run.sh 78 | 79 | # 查看代理日志 80 | tail server/nginx/logs/proxy.log 81 | ``` 82 | 83 | 目前暂未实现开机自启动。 84 | 85 | 86 | # 禁止外链 87 | 88 | 默认情况下,代理接口允许所有 `github.io` 子站点调用,这可能导致不必要的流量消耗。 89 | 90 | 如果希望只给自己网站使用,可编辑 `allowed-sites.conf`。(重启服务生效) 91 | 92 | 93 | # 安全策略 94 | 95 | 如果不希望代理访问内网(避免 SSRF 风险),可执行 `setup-ipset.sh`: 96 | 97 | ```bash 98 | /home/jsproxy/server/setup-ipset.sh 99 | ``` 100 | 101 | > 需要 root 权限,依赖 `ipset` 命令 102 | 103 | 该脚本可禁止 `jsporxy` 用户访问保留 IP 段(针对 TCP)。nginx 之外的程序也生效,但不影响其他用户。 104 | 105 | 106 | # 相关文章 107 | 108 | * [基于 JS Hook 技术,打造最先进的在线代理](https://github.com/EtherDream/jsproxy/blob/master/docs/blogs/js-hook.md) 109 | 110 | 111 | # 项目特点 112 | 113 | 相比传统在线代理,本项目具有以下特点: 114 | 115 | ## 服务端开销低 116 | 117 | 传统在线代理几乎都是在服务端替换 HTML/JS/CSS 等资源中的 URL。这不仅需要对内容做大量的分析和处理,还需对流量进行解压和再压缩,消耗大量 CPU 资源。并且由于逻辑较复杂,通常使用 Python/PHP 等编程语言自己实现。 118 | 119 | 为降低服务端开销,本项目使用浏览器的一个黑科技 —— Service Worker。它能让 JS 拦截网页产生的请求,并能自定义返回内容,相当于在浏览器内部实现一个反向代理。这使得绝大部分的内容处理都可以在浏览器上完成,服务器只需纯粹的转发流量。 120 | 121 | 因此本项目服务端直接使用 nginx,并且转发过程不修改内容(只修改 HTTP 头),避免了内容处理产生的巨大开销。同时得益于 nginx 丰富的功能,很多常用需求无需重新造轮子,通过简单配置即可实现。并且无论性能还是稳定性,都远高于自己实现。 122 | 123 | ## API 虚拟化 124 | 125 | 传统在线代理大多只针对静态 URL 的替换,忽视了动态 URL 以及和 URL 相关的网页 API。例如 a.com 反向代理 google.com,但页面中 JS 读取 `document.domain` 得到的仍是 a.com。这可能导致某些业务逻辑出现问题。 126 | 127 | 为缓解这个问题,本代理在页面头部注入一个 JS,用以重写绝大部分和 URL 相关的 API,使得页面中的 JS 获取到的仍是原始 URL: 128 | 129 | ![](https://raw.githubusercontent.com/EtherDream/jsproxy-localtest/temp/hook.png) 130 | 131 | 对于有些无法重写的 API,例如 `location`,本代理会将代码中字面出现的 `location` 替换成 `__location`,从而将操作转移到自定义对象上。当然对于非字面的情况(例如 `this['lo' + 'cation']`),目前还无法处理。 132 | 133 | 134 | # 类似项目 135 | 136 | 目前找到的都是传统后端替换 URL 的方案。当然后端替换也有不少优点,例如浏览器兼容性高,甚至低版本的 IE 都可以使用。 137 | 138 | ## zmirror 139 | 140 | GitHub: https://github.com/aploium/zmirror 141 | 142 | ## php-proxy 143 | 144 | GitHub: https://github.com/jenssegers/php-proxy 145 | 146 | 147 | # 项目意义 148 | 149 | 本项目主要用于以下技术的研究: 150 | 151 | * 网站镜像 / 沙盒化 152 | 153 | * 钓鱼网站检测技术 154 | 155 | * 前端资源访问加速 156 | 157 | 当然请勿将本项目用于非法用途,否则后果自负。 158 | 159 | Demo 页面文明使用,不要进行登陆等涉及隐私的操作。 160 | 161 | 162 | # License 163 | 164 | MIT 165 | -------------------------------------------------------------------------------- /acme/.well-known/acme-challenge/test.txt: -------------------------------------------------------------------------------- 1 | ok -------------------------------------------------------------------------------- /acme/README.md: -------------------------------------------------------------------------------- 1 | 该目录存放 HTTPS 证书申请时的 challenge(由 acme.sh 写入),用于 Let's Encrypt 服务器的验证 -------------------------------------------------------------------------------- /allowed-sites.conf: -------------------------------------------------------------------------------- 1 | # 2 | # 授权哪些站点可使用本服务,防止外链 3 | # 4 | # 本服务会校验 HTTP 请求头 origin 字段,如果不在该列表,则拒绝代理 5 | # 每个 URL 对应一个短别名,用于日志记录 6 | # 注意 URL 不包含路径部分(结尾没有 /) 7 | # 8 | http://127.0.0.1 '127'; 9 | http://127.0.0.1:8080 '127'; 10 | http://localhost 'lo'; 11 | http://localhost:8080 'lo'; 12 | 13 | # 接口和网站同源,这种情况下 origin 为空 14 | '' 'mysite'; 15 | 16 | # ~ 开头为正则匹配,此处允许 github.io 所有子站点 17 | ~^https://([\w-]+)\.github\.io$ 'gh-$1'; 18 | 19 | # 允许任何站点使用 20 | # ~(.*) '$1'; 21 | -------------------------------------------------------------------------------- /api.conf: -------------------------------------------------------------------------------- 1 | set $_level ''; 2 | set $_switched ''; 3 | set $_url ''; 4 | set $_ver ''; 5 | set $_ref ''; 6 | set $_type ''; 7 | set $_mode ''; 8 | set $_bodyhash ''; 9 | 10 | error_page 500 502 504 /error; 11 | 12 | location = /error { 13 | internal; 14 | access_log off; 15 | more_set_headers 16 | 'access-control-allow-origin: *' 17 | 'access-control-expose-headers: gateway-err--' 18 | 'gateway-err--: {"msg": "$arg_msg", "addr": "$upstream_addr", "url": "$arg_url"}' 19 | ; 20 | return 204; 21 | } 22 | 23 | 24 | location = /preflight { 25 | internal; 26 | access_log off; 27 | more_set_headers 28 | 'access-control-allow-origin: *' 29 | 'access-control-allow-methods: GET,POST,PUT,PATCH,TRACE,DELETE,HEAD,OPTIONS' 30 | 'access-control-max-age: 1728000' 31 | ; 32 | return 204; 33 | } 34 | 35 | # 该接口已作废 36 | location = /http { 37 | access_log off; 38 | more_set_headers 'access-control-allow-origin: *'; 39 | return 200 "该接口已作废,请更新前端脚本"; 40 | } 41 | 42 | # HTTP(S) Proxy 43 | location /http/ { 44 | # see ./allowed-sites.conf 45 | if ($_origin_id = '') { 46 | rewrite ^ /error?msg=ORIGIN_NOT_ALLOWED; 47 | } 48 | if ($http_x_jsproxy) { 49 | rewrite ^ /error?msg=CIRCULAR_DEPENDENCY; 50 | } 51 | proxy_set_header x-jsproxy 1; 52 | proxy_set_header Connection $http_connection; 53 | 54 | 55 | if ($http_access_control_request_methods) { 56 | rewrite ^ /preflight; 57 | } 58 | 59 | access_by_lua_file ../lua/http-dec-req-hdr.lua; 60 | 61 | proxy_cache my_cache; 62 | proxy_pass $_url; 63 | 64 | more_set_headers 65 | 'server: $upstream_http_server' 66 | 'content-security-policy' 67 | 'content-security-policy-report-only' 68 | 'x-frame-options' 69 | 'alt-svc' 70 | 'clear-site-data' 71 | ; 72 | header_filter_by_lua_file ../lua/http-enc-res-hdr.lua; 73 | body_filter_by_lua_file ../lua/http-body-hash.lua; 74 | } 75 | 76 | 77 | # WebSocket Proxy 78 | location = /ws { 79 | access_by_lua_file ../lua/ws-dec-req-hdr.lua; 80 | proxy_set_header Upgrade $http_upgrade; 81 | proxy_set_header Connection $http_connection; 82 | proxy_pass $_url; 83 | } -------------------------------------------------------------------------------- /cert/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | !README.md 3 | !cert.conf 4 | * -------------------------------------------------------------------------------- /cert/README.md: -------------------------------------------------------------------------------- 1 | 该目录存放 HTTPS 证书,每个域名使用独立的目录。 2 | 3 | `cert.conf` 文件被 `nginx.conf` 引用,保存 HTTPS 证书路径。 -------------------------------------------------------------------------------- /cert/cert.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangmyc/jsproxy/8b236a584ee22e66948a63774d81fb009569b768/cert/cert.conf -------------------------------------------------------------------------------- /cf-worker/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "serviceworker": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2017, 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-empty": "warn", 18 | "no-unused-vars": "warn", 19 | "no-constant-condition": "warn" 20 | } 21 | } -------------------------------------------------------------------------------- /cf-worker/README.md: -------------------------------------------------------------------------------- 1 | 使用 CloudFlare Worker 免费部署 2 | 3 | 4 | # 简介 5 | 6 | `CloudFlare Worker` 是 CloudFlare 的边缘计算服务。开发者可通过 JavaScript 对 CDN 进行编程,从而能灵活处理 HTTP 请求。这使得很多任务可在 CDN 上完成,无需自己的服务器参与。 7 | 8 | 9 | # 部署 10 | 11 | 首页:https://workers.cloudflare.com 12 | 13 | 注册,登陆,`Start building`,取一个子域名,`Create a Worker`。 14 | 15 | 复制 [index.js](https://raw.githubusercontent.com/EtherDream/jsproxy/master/cf-worker/index.js) 到左侧代码框,`Save and deploy`。如果正常,右侧应显示首页。 16 | 17 | 收藏地址框中的 `https://xxxx.子域名.workers.dev`,以后可直接访问。 18 | 19 | 20 | # 计费 21 | 22 | 后退到 `overview` 页面可参看使用情况。免费版每天有 10 万次免费请求,对于个人通常足够。 23 | 24 | 如果不够用,可注册多个 Worker,在 `conf.js` 中配置多线路负载均衡。或者升级到 $5 的高级版本,每月可用 1000 万次请求(超出部分 $0.5/百万次请求)。 25 | 26 | 27 | # 修改配置 28 | 29 | 默认情况下,静态资源从 `https://etherdream.github.io/jsproxy` 反向代理,可通过代码中 `ASSET_URL` 配置,从而可使用自定义的 `conf.js` 配置。 30 | 31 | 32 | # 存在问题 33 | 34 | * WebSocket 代理尚未实现 35 | 36 | * 外链限制尚未实现 37 | 38 | * 未充分测试,以后再完善 -------------------------------------------------------------------------------- /cf-worker/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * static files (404.html, sw.js, conf.js) 5 | */ 6 | const ASSET_URL = 'https://etherdream.github.io/jsproxy' 7 | 8 | const JS_VER = 10 9 | const MAX_RETRY = 1 10 | 11 | /** @type {RequestInit} */ 12 | const PREFLIGHT_INIT = { 13 | status: 204, 14 | headers: new Headers({ 15 | 'access-control-allow-origin': '*', 16 | 'access-control-allow-methods': 'GET,POST,PUT,PATCH,TRACE,DELETE,HEAD,OPTIONS', 17 | 'access-control-max-age': '1728000', 18 | }), 19 | } 20 | 21 | /** 22 | * @param {any} body 23 | * @param {number} status 24 | * @param {Object} headers 25 | */ 26 | function makeRes(body, status = 200, headers = {}) { 27 | headers['--ver'] = JS_VER 28 | headers['access-control-allow-origin'] = '*' 29 | return new Response(body, {status, headers}) 30 | } 31 | 32 | 33 | /** 34 | * @param {string} urlStr 35 | */ 36 | function newUrl(urlStr) { 37 | try { 38 | return new URL(urlStr) 39 | } catch (err) { 40 | return null 41 | } 42 | } 43 | 44 | 45 | addEventListener('fetch', e => { 46 | const ret = fetchHandler(e) 47 | .catch(err => makeRes('cfworker error:\n' + err.stack, 502)) 48 | e.respondWith(ret) 49 | }) 50 | 51 | 52 | /** 53 | * @param {FetchEvent} e 54 | */ 55 | async function fetchHandler(e) { 56 | const req = e.request 57 | const urlStr = req.url 58 | const urlObj = new URL(urlStr) 59 | const path = urlObj.href.substr(urlObj.origin.length) 60 | 61 | if (urlObj.protocol === 'http:') { 62 | urlObj.protocol = 'https:' 63 | return makeRes('', 301, { 64 | 'strict-transport-security': 'max-age=99999999; includeSubDomains; preload', 65 | 'location': urlObj.href, 66 | }) 67 | } 68 | 69 | if (path.startsWith('/http/')) { 70 | return httpHandler(req, path.substr(6)) 71 | } 72 | 73 | switch (path) { 74 | case '/http': 75 | return makeRes('请更新 cfworker 到最新版本!') 76 | case '/ws': 77 | return makeRes('not support', 400) 78 | case '/works': 79 | return makeRes('it works') 80 | default: 81 | // static files 82 | return fetch(ASSET_URL + path) 83 | } 84 | } 85 | 86 | 87 | /** 88 | * @param {Request} req 89 | * @param {string} pathname 90 | */ 91 | function httpHandler(req, pathname) { 92 | const reqHdrRaw = req.headers 93 | if (reqHdrRaw.has('x-jsproxy')) { 94 | return Response.error() 95 | } 96 | 97 | // preflight 98 | if (req.method === 'OPTIONS' && 99 | reqHdrRaw.has('access-control-request-headers') 100 | ) { 101 | return new Response(null, PREFLIGHT_INIT) 102 | } 103 | 104 | let acehOld = false 105 | let rawSvr = '' 106 | let rawLen = '' 107 | let rawEtag = '' 108 | 109 | const reqHdrNew = new Headers(reqHdrRaw) 110 | reqHdrNew.set('x-jsproxy', '1') 111 | 112 | // 此处逻辑和 http-dec-req-hdr.lua 大致相同 113 | // https://github.com/EtherDream/jsproxy/blob/master/lua/http-dec-req-hdr.lua 114 | const refer = reqHdrNew.get('referer') 115 | const query = refer.substr(refer.indexOf('?') + 1) 116 | if (!query) { 117 | return makeRes('missing params', 403) 118 | } 119 | const param = new URLSearchParams(query) 120 | 121 | for (const [k, v] of Object.entries(param)) { 122 | if (k.substr(0, 2) === '--') { 123 | // 系统信息 124 | switch (k.substr(2)) { 125 | case 'aceh': 126 | acehOld = true 127 | break 128 | case 'raw-info': 129 | [rawSvr, rawLen, rawEtag] = v.split('|') 130 | break 131 | } 132 | } else { 133 | // 还原 HTTP 请求头 134 | if (v) { 135 | reqHdrNew.set(k, v) 136 | } else { 137 | reqHdrNew.delete(k) 138 | } 139 | } 140 | } 141 | if (!param.has('referer')) { 142 | reqHdrNew.delete('referer') 143 | } 144 | 145 | // cfworker 会把路径中的 `//` 合并成 `/` 146 | const urlStr = pathname.replace(/^(https?):\/+/, '$1://') 147 | const urlObj = newUrl(urlStr) 148 | if (!urlObj) { 149 | return makeRes('invalid proxy url: ' + urlStr, 403) 150 | } 151 | 152 | /** @type {RequestInit} */ 153 | const reqInit = { 154 | method: req.method, 155 | headers: reqHdrNew, 156 | redirect: 'manual', 157 | } 158 | if (req.method === 'POST') { 159 | reqInit.body = req.body 160 | } 161 | return proxy(urlObj, reqInit, acehOld, rawLen, 0) 162 | } 163 | 164 | 165 | /** 166 | * 167 | * @param {URL} urlObj 168 | * @param {RequestInit} reqInit 169 | * @param {number} retryTimes 170 | */ 171 | async function proxy(urlObj, reqInit, acehOld, rawLen, retryTimes) { 172 | const res = await fetch(urlObj.href, reqInit) 173 | const resHdrOld = res.headers 174 | const resHdrNew = new Headers(resHdrOld) 175 | 176 | let expose = '*' 177 | 178 | for (const [k, v] of resHdrOld.entries()) { 179 | if (k === 'access-control-allow-origin' || 180 | k === 'access-control-expose-headers' || 181 | k === 'location' || 182 | k === 'set-cookie' 183 | ) { 184 | const x = '--' + k 185 | resHdrNew.set(x, v) 186 | if (acehOld) { 187 | expose = expose + ',' + x 188 | } 189 | resHdrNew.delete(k) 190 | } 191 | else if (acehOld && 192 | k !== 'cache-control' && 193 | k !== 'content-language' && 194 | k !== 'content-type' && 195 | k !== 'expires' && 196 | k !== 'last-modified' && 197 | k !== 'pragma' 198 | ) { 199 | expose = expose + ',' + k 200 | } 201 | } 202 | 203 | if (acehOld) { 204 | expose = expose + ',--s' 205 | resHdrNew.set('--t', '1') 206 | } 207 | 208 | // verify 209 | if (rawLen) { 210 | const newLen = resHdrOld.get('content-length') || '' 211 | const badLen = (rawLen !== newLen) 212 | 213 | if (badLen) { 214 | if (retryTimes < MAX_RETRY) { 215 | urlObj = await parseYtVideoRedir(urlObj, newLen, res) 216 | if (urlObj) { 217 | return proxy(urlObj, reqInit, acehOld, rawLen, retryTimes + 1) 218 | } 219 | } 220 | return makeRes(res.body, 400, { 221 | '--error': `bad len: ${newLen}, except: ${rawLen}`, 222 | 'access-control-expose-headers': '--error', 223 | }) 224 | } 225 | 226 | if (retryTimes > 1) { 227 | resHdrNew.set('--retry', retryTimes) 228 | } 229 | } 230 | 231 | let status = res.status 232 | 233 | resHdrNew.set('access-control-expose-headers', expose) 234 | resHdrNew.set('access-control-allow-origin', '*') 235 | resHdrNew.set('--s', status) 236 | resHdrNew.set('--ver', JS_VER) 237 | 238 | resHdrNew.delete('content-security-policy') 239 | resHdrNew.delete('content-security-policy-report-only') 240 | resHdrNew.delete('clear-site-data') 241 | 242 | if (status === 301 || 243 | status === 302 || 244 | status === 303 || 245 | status === 307 || 246 | status === 308 247 | ) { 248 | status = status + 10 249 | } 250 | 251 | return new Response(res.body, { 252 | status, 253 | headers: resHdrNew, 254 | }) 255 | } 256 | 257 | 258 | /** 259 | * @param {URL} urlObj 260 | */ 261 | function isYtUrl(urlObj) { 262 | return ( 263 | urlObj.host.endsWith('.googlevideo.com') && 264 | urlObj.pathname.startsWith('/videoplayback') 265 | ) 266 | } 267 | 268 | /** 269 | * @param {URL} urlObj 270 | * @param {number} newLen 271 | * @param {Response} res 272 | */ 273 | async function parseYtVideoRedir(urlObj, newLen, res) { 274 | if (newLen > 2000) { 275 | return null 276 | } 277 | if (!isYtUrl(urlObj)) { 278 | return null 279 | } 280 | try { 281 | const data = await res.text() 282 | urlObj = new URL(data) 283 | } catch (err) { 284 | return null 285 | } 286 | if (!isYtUrl(urlObj)) { 287 | return null 288 | } 289 | return urlObj 290 | } -------------------------------------------------------------------------------- /changelogs/README.md: -------------------------------------------------------------------------------- 1 | # 完整更新日志 2 | 3 | * 2019-06-11 前端脚本调整,首页可离线访问(如果长时间加载中,尝试多刷新几次或者隐身模式访问) 4 | 5 | * 2019-05-30 更新 cfworker,对 ytb 视频进行了优化(推荐选 1080p+,不会增加服务器压力) 6 | 7 | * 2019-05-29 nginx 增加静态资源服务,可同时支持代理接口和首页访问 8 | 9 | * 2019-05-27 增加 nio.io、sslip.io 后备域名,减少申请失败的几率 10 | 11 | * 2019-05-26 安装时自动申请证书(使用 xip.io 域名),安装后即可预览 12 | 13 | * 全新的 URL 模型,取代 [之前版本](https://github.com/EtherDream/jsproxy/tree/first-ver)。[查看详细](v0.0.1.md) 14 | -------------------------------------------------------------------------------- /changelogs/v0.0.1.md: -------------------------------------------------------------------------------- 1 | 虽然该版本仍为概念演示状态,但相比[最初版本](https://github.com/EtherDream/jsproxy/tree/first-ver),有了很大变化: 2 | 3 | # 不再使用子域名 4 | 5 | 使用子域名编码目标域名(例如 gg.jsproxy.tk),存在太多缺陷。例如 HTTPS 证书问题,DNS 性能和安全问题等。因此目前不再使用子域名,只用固定的域名,目标 URL 放在路径里。例如: 6 | 7 | https://zjcqoo.github.io/-----https://www.google.com 8 | 9 | 当然这也会产生很多新问题,例如无法支持 Cookie、页面之间没有同源策略限制等。 10 | 11 | 对于 Cookie,目前通过 JS 来维护,而不用浏览器原生(当然还有不少细节没实现)。这样的好处是前后端可以分离,前端页面可以放在第三方 Web 服务器上(例如 CDN、GitHub Pages),我们的服务器只提供代理接口。 12 | 13 | 这样一个页面可使用多个服务器的代理接口,并能实现线路切换、负载均衡等效果。 14 | 15 | 同源策略方面的限制目前暂未实现,因此不要进行登陆等操作,避免隐私泄露。 16 | 17 | 18 | # 服务端优化 19 | 20 | 安全改进:由于 Web 页面托管在第三方站点上,自己的服务器无需开启 443 端口,因此也无需 root 运行。同时支持 IP 黑名单功能,防止 SSRF 攻击。 21 | 22 | 代码改进:接口代理使用固定的 URL(参见 `api.conf`),不再使用任意路径,代码干净了很多。 23 | 24 | 25 | # 支持更多浏览器 26 | 27 | 相比之前版本只支持 Chrome,现在还支持最新的 Safari 和 FireFox。 28 | 29 | 注意:FireFox 隐身模式下不支持 Service Worker,只能普通模式访问。 30 | 31 | 32 | # 提供一个首页 33 | 34 | 虽然依旧简陋,但比之前好。提供了线路切换、预加载的功能。 -------------------------------------------------------------------------------- /changelogs/v0.1.0.md: -------------------------------------------------------------------------------- 1 | # v0.1.0 2 | 3 | ## 更新内容 4 | 5 | * 后端代理以及 cfworker 接口调整,修复缓存失效的问题 6 | 7 | * 前端增加缓存记录,提高浏览器缓存命中率 8 | 9 | * 前端增加 Cookie 持久化 10 | 11 | * 前端增加 CORS 站点直连功能 12 | 13 | * 配置调整,支持线路权重 14 | 15 | * 更详细的服务器错误信息显示 16 | 17 | * 增加更多的 Storage API Hook 18 | 19 | 20 | ## 代理接口调整 21 | 22 | 之前代理接口使用固定的路径 `/http`,目标 URL 设置在请求头 `--url` 字段,同时返回头配置了 `vary: --url` 字段,希望能根据不同的 `--url` 请求返回不同的缓存内容。但事实上该方案并未生效,和预想的不同,浪费了不少流量。(对 `vary` 了解不够透彻~) 23 | 24 | 为修复这个问题,目前将代理接口改成 `/http/目标 URL`,去掉了 `vary` 字段。同时将绝大部分的请求字段打包到 `Referer` 字段里,使请求头保持简单,不产生 CORS preflight。 25 | 26 | > 如果不打包,则会频繁出现 preflight,即使配置了 `Access-Control-Max-Age` 也没用,因为 max-age 只对特定 URL 记忆,而现在的 URL 几乎每次都不同,所以必须保持请求头足够简单。至于为什么选择 `Referer` 字段,因为只有这个字段可以灵活存储数据,[其他几个字段都有些限制](https://fetch.spec.whatwg.org/#cors-unsafe-request-header-byte),容易出现 preflight。 27 | 28 | 当然这个功能目前仍在研究中,未来也许会有更好的方案。 29 | 30 | 31 | ## 节点缓存 32 | 33 | 由于切换 节点/线路 会使得最终的 URL 发生变化,从而导致无法利用已有的缓存。 34 | 35 | 目前增加了静态资源记忆功能,记住当前使用的域名。下次加载时直接使用上次的域名,从而命中浏览器缓存。 36 | 37 | 存储查看:`indexedDB` -> `.sys` -> `url-cache` 38 | 39 | 40 | ## Cookie 持久化 41 | 42 | 目前 Cookie 信息定期同步到本地存储,浏览器重启后可保持之前的会话。 43 | 44 | 存储查看:`indexedDB` -> `.sys` -> `cookie` 45 | 46 | 47 | ## CORS 站点直连 48 | 49 | 不少网站(通常是 CDN)在返回头中配置了 `access-control-allow-origin: *`,并且不校验 `origin` 和 `referer`(或者允许为空)。 50 | 51 | 对于这样的站点,前端可直接连接而无需通过代理,从而能加快访问速度,并且节省代理服务器流量。 52 | 53 | 目前收集了部分站点,只在纯前端实现。未来将尝试和服务端配合,覆盖所有这样的场合。 54 | 55 | 56 | ## 节点权重支持 57 | 58 | 不同于之前均匀分配负载,目前可配置每个线路的权重,从而能对部分线路增加或降低负载。 59 | 60 | 例如演示案例中的 cfworker 节点,使用 1 个收费版 + 多个免费版的方案。由于免费版有访问频率限制,因此使用更低的权重以减少负载。(命中比例 = 当前值 / 总值) 61 | 62 | 63 | ## 详细错误信息 64 | 65 | 目前可显示代理服务器的 DNS 解析错误、HTTP 连接错误、白名单错误等,取代之前过于简陋的报错信息。 66 | 67 | 68 | ## Storage API Hook 69 | 70 | 增加 `indexedDB` 和 `Cache Storage` 的 key 枚举、删除的 API Hook。 71 | -------------------------------------------------------------------------------- /docs/blogs/js-hook.md: -------------------------------------------------------------------------------- 1 | 《基于 JS Hook 技术,打造最先进的在线代理》 2 | 3 | 4 | # 什么是在线代理 5 | 6 | 所谓在线代理,就类似本项目的演示,就可通过某个网站访问另一个网站(通常无法直接访问)。不用安装任何插件,不用修改任何配置,仅仅打开一个网页即可。 7 | 8 | 类似的网站,或许大家都曾见过,并且印象中应该都不怎么好用。相比 ss/v2 这些网络层代理,在线代理的成熟度显然要低得多,只能临时凑合着用。 9 | 10 | 11 | # 为什么在线代理不好用 12 | 13 | 因为要实现一个完善的在线代理,难度非常大! 14 | 15 | 也许你会说,用 nginx 搭个反向代理不就可以了。其实并没有这么简单。 16 | 17 | 举个例子,假如我们用 `a.com` 反向代理 `b.com`,并且 `b.com` 有如下网页: 18 | 19 | ```html 20 | 21 | 22 | ``` 23 | 24 | 第一个 img 是相对路径。由于当前实际地址是 `a.com`,因此最终访问的 URL 是 `http://a.com/foo.gif`。我们的后端服务器收到请求后,抓取 `http://b.com/foo.gif` 的内容并返回给用户。这没有问题。 25 | 26 | 第二个 img 是绝对路径,这就有问题了!浏览器会直接访问 `b.com`,根本不经过我们的后端。而 `b.com` 是无法直接访问的,于是图片加载失败。 27 | 28 | 因此后端在代理网页时,还需要对其中的内容进行处理,将那些 **绝对路径 URL** 替换成自己的地址。例如: 29 | 30 | ```html 31 | 32 | 33 | ``` 34 | 35 | 这样才能确保图 2 走我们的站点,而不是连接 `b.com` 导致逃脱代理。 36 | 37 | 由此可见,衡量一个在线代理完不完善,很重要的一点就是:能否覆盖网页中尽可能多的 URL,减少逃逸现象。 38 | 39 | ---- 40 | 41 | 虽然替换网页中的 URL 并不困难,但是,这极其麻烦! 42 | 43 | 做过 Web 开发的都清楚,网页里的 URL 有千奇百怪的存在形式,可存在于 HTML、CSS、JS 甚至是动态加载的 JSON、XML 等资源中,因此后端只处理 HTML 是不够的,还必须处理各种文本资源!这对服务器是个不小的开销。 44 | 45 | 除了内容处理,其实还有很多额外开销。互联网上的文本资源大多都是压缩传输,而压缩的数据是无法直接处理的,因此还得先解压;最后处理完的数据,还得再压缩回去。一来一往,消耗不少 CPU。当然也可以不压缩,但这又会增加流量开销。 46 | 47 | 像过去的 `gzip` 压缩开销尚可接受,而如今流行的 `brotli` 压缩开销非常大。假如用户频繁访问大体积的文本资源,代理服务器 CPU 将严重消耗。 48 | 49 | ---- 50 | 51 | 不过,上述问题还不是最严重的。事实上 HTML、CSS 等资源都好说,唯独 JS 是个坑 —— 因为 JS 是程序,它可以动态产生 URL。例如: 52 | 53 | ```js 54 | var site = 'b'; 55 | document.write(''); 56 | ``` 57 | 58 | 遇到这种场合,任何字符串层面的替换都是无解的! 59 | 60 | 除了动态产生 URL,还有动态获取 URL 的情况。因为有很多 API 是和 URL 相关的,例如: 61 | 62 | * `document.domain`,`document.URL`,`document.cookie` 63 | 64 | * 超链接 `href` 属性,表单 `action` 属性,各种元素 `src` 属性 65 | 66 | * 消息事件 `origin` 属性 67 | 68 | * 省略数十个 ... 69 | 70 | 在我们 `a.com` 页面里调用这些 API,返回自然是 `a.com` 的 URL,而不会是 `b.com`。假如网页里的业务逻辑仍以 `b.com` 作为标准处理,很可能就会出现问题。 71 | 72 | 这类情况现实中很普遍,而传统的在线代理,对此则无能为力。 73 | 74 | 75 | # 新概念在线代理 76 | 77 | 现在,我们尝试用更先进的技术,先解决动态 URL 的问题,然后改进服务器的开销问题。 78 | 79 | ## API Hook 80 | 81 | 先来思考:在 `a.com` 的网页里,可以让 `document.domain` 返回 `b.com` 吗? 82 | 83 | 其实可以!因为 JS 非常灵活,绝大部分的原生 API 都可以重写,所以能轻易改变默认行为。例如: 84 | 85 | ```js 86 | var raw_open = window.open; 87 | 88 | window.open = function(url) { 89 | return raw_open('http://a.com/proxy?' + url); 90 | }; 91 | ``` 92 | 93 | 这个经过改造的 `open` 函数,可以在每次调用时给 url 加上 `http://a.com/proxy?` 这个前缀。这样,原始网页弹出的任何 URL,其实都是我们站点的页面! 94 | 95 | 除了函数,属性也可以重写。例如改变 `document.domain` 的 `getter` 和 `setter`: 96 | 97 | ```js 98 | var fakeDomain = 'b.com'; 99 | 100 | Object.defineProperty(document, 'domain', { 101 | get() { 102 | return fakeDomain; 103 | }, 104 | set(value) { 105 | fakeDomain = value; 106 | } 107 | }) 108 | ``` 109 | 110 | 通过对函数和属性的重写,我们可以 hook 绝大多数和 URL 相关的 API,在其中对输入的参数或者输出的返回值进行调整。 111 | 112 | 这样,原本 `b.com` 的网页现在运行于 `a.com` 站点下,页面脚本获得的仍是 `b.com` 的 URL。 113 | 114 | 我们的代理似乎透明般存在,难以被原始页面感知! 115 | 116 | 117 | ## DOM Hook 118 | 119 | 增加前端脚本之后,服务端的开销反而变大了。因为除了替换 URL,还得往页面头部注入脚本代码。 120 | 121 | 既然前端脚本这么强大,可不可以将 URL 的替换也让它来实现? 122 | 123 | 我们设想下,假如服务端不替换 URL,只注入脚本,那么返回的 HTML 类似这样: 124 | 125 | ```html 126 | 127 | 128 | ... 129 | 130 | 131 | 132 | ``` 133 | 134 | 我们的脚本可以最先运行,这是个巨大的优势。但是,这能改变后续标签的 URL 属性吗? 135 | 136 | 事实上,有一个 API 可以拦截 DOM 元素的创建,它就是 `MutationObserver`。通过它,我们可以在 **DOM 元素渲染前** 修改其属性,将绝对路径的 URL 调整成我们的站点。 137 | 138 | **尽管服务器返回的 HTML 里都是原始 URL,但资源实际上是从我们的站点加载,超链接指向的也是我们的站点!** 139 | 140 | 前端有了 `API Hook` 和 `DOM Hook`,后端也就无需处理 JSON、XML 等资源了,因为 URL 无论从何而来,最终都将传给 API,或者赋给 DOM 的属性 —— 这两者现在都能拦截! 141 | 142 | 143 | ## URL Hook 144 | 145 | 由于 `MutationObserver` 只能拦截 DOM 元素,因此仅适用于 HTML,而无法适用于其他资源,例如 CSS 中也有 URL 字符串,这仍需后端处理。 146 | 147 | ```css 148 | @import 'http://b.com/foo.css'; 149 | .xx { 150 | background-image: url(http://b.com/bar.gif); 151 | } 152 | ``` 153 | 154 | 另外,即使 HTML 中只插入一行代码,服务器仍需对流量进行解压和再压缩,消耗不少 CPU 资源。这很不完美! 155 | 156 | 因此,我们的终极目标是:服务器不处理任何内容!最多只处理 HTTP 头。 157 | 158 | ---- 159 | 160 | 为了实现这个目标,需要借助 HTML5 的一个黑科技 —— `Service Worker`。 161 | 162 | `Service Worker` 是一种后台运行的服务进程,它提供的 API 允许 JS 拦截当前站点产生的所有 HTTP 请求(甚至包括浏览器地址栏的访问请求),并能控制返回结果,相当于浏览器内部的反向代理! 163 | 164 | 有了这个 API,我们可统一捕获流量。无论 URL 是绝对路径还是相对路径,无论出现在 HTML 还是 CSS,都能在 `Service Worker` 层进行拦截,然后转发到自己的服务器! 165 | 166 | 不过 `Service Worker` 本身也需通过脚本安装,那么服务端是否仍需往 HTML 中插入脚本? 167 | 168 | 其实不需要。因为 `Service Worker` 是后台进程,一旦安装可长期运行,即使网页关闭它仍在运行。所以只需让用户安装一次就可以。 169 | 170 | 当用户首次访问时,不管访问什么路径,服务端始终返回安装页面。之后,整个站点所有流量都被 `Service Worker` 接管。最终所有的内容处理,都可以由 JS 来实现!服务端只需纯粹转发数据,甚至都不用考虑解压缩,从而大幅降低 CPU 开销。 171 | 172 | ---- 173 | 174 | 到此,我们实现了三种类型的 Hook: 175 | 176 | * API Hook (重写函数和属性) 177 | 178 | * DOM Hook (MutationObserver) 179 | 180 | * URL Hook (Service Worker) 181 | 182 | 现在,无论加载 URL 还是调用 API,都可被我们拦截和代理,仿佛将原始网页嵌套在一个沙盒中运行! 183 | 184 | 185 | # 无法 Hook 的 API 186 | 187 | 由于浏览器限制,有些 API 是无法重写的,其中最典型的就是 `location` —— 无论 `window.location` 还是 `document.location` 以及 `location` 对象中的成员,都是无法重写的。 188 | 189 | ```js 190 | Object.defineProperty(location, 'href', { 191 | get() {}, 192 | set() {} 193 | }) 194 | // Uncaught TypeError: Cannot redefine property: href 195 | ``` 196 | 197 | 然而这个 API 使用频率非常高,不少网站通过它检测当前的域名是否合法,例如: 198 | 199 | ```js 200 | if (location.host != 'b.com') { 201 | location.href = 'http://b.com'; 202 | } 203 | ``` 204 | 205 | 如果不考虑这个接口,网站一旦发生跳转,就逃出我们的沙盒了! 206 | 207 | 然而它又无法重写,这该如何解决?尽管理论上无解,但作为实践,还是可以在一定程度上进行缓解 —— 我们可将 JS 中字面出现的 `location` 进行重命名,例如修改成 `__location`,这样就能将操作**转移到我们定义的对象上**! 208 | 209 | ```js 210 | if (__location.host != 'b.com') { 211 | __location.href = 'http://b.com'; 212 | } 213 | ``` 214 | 215 | 因为 `Service Worker` 掌控所有流量,所以修改 JS 资源并不困难。此外,网页中的内联脚本也可通过 `MutationObserver` 拦截和修改。 216 | 217 | > 目前演示站点为了简单,直接使用正则替换,有时会将同名的函数、属性、字符串也进行修改,导致出现误伤。更好的方案,则是在语法树层面进行修改,当然性能开销也会更大。 218 | 219 | 不过这个方案只能缓解,而无法彻底解决。因为我们只能修改明文出现的 `location`,对于动态的场合就无解了,例如: 220 | 221 | ```js 222 | self['lo' + 'cat' + 'ion'] // location object 223 | this[atob('bG9jYXRpb24=')] // location object 224 | ``` 225 | 226 | 更别提 `eval` 这些了。所以,如果网页有意访问 `location`,我们是无法拦截的! 227 | 228 | 这个特征,可以给 Web 开发者一个警示:如果想检测当前页面 URL 是否合法,尽量不要出现明文的 `window`、`location` 等关键字,而是通过动态的方式访问,以防落入上述这种陷阱。如果想检测你的网站是否安全,可尝试用演示站点访问你的登录页,如果没有跳转到原始网站,说明你的 `location` 接口用得不够安全,用户极有可能在代理页面中输入账号密码,导致隐私泄露! 229 | 230 | 231 | # 无法 Hook 的 DOM 232 | 233 | 事实上,有些特殊请求无法被 `Service Worker` 拦截,例如 **不同源的框架页面**。因此,我们仍需借助 `MutationObserver` 修改元素的 URL 属性,将跨源的页面变成同源,从而可拦截子页面的所有请求。 234 | 235 | 不过 `MutationObserver` 也有一些细节问题。例如,即使给元素设置了新的 URL,但是原始 URL 仍会加载。因为浏览器为了提高速度,有一个预加载的机制,原始 URL 在 HTML 解析阶段就开始加载了;之后修改会导致加载取消,但请求仍已产生,控制台里可看到 `cancel` 状态的请求。 236 | 237 | 有个简单的办法,倒是可以缓解这问题:设置一个 `Content-Security-Policy` 策略,让网页只允许加载自己的域名,从而阻止预加载请求。这个方案目前在演示中开启,可以看到每个页面的头部有一个 `` 标签定义的 CSP 策略。 238 | 239 | 当然 `MutationObserver` 仍存在较多问题,这里不一一叙述。事实上最完善的方案,仍是替换 HTML 里的 URL 字符串,并且最好支持流模式。这个功能以后将会实现。 240 | 241 | 242 | # 无法 Hook 的资源 243 | 244 | 由于 `URL` 只是 `URI` 的子集,因此有些 URI 资源无法被 `Service Worker` 拦截。最典型的就是 `Data URI`。 245 | 246 | 此外,还有 `about:`、`blob:`、`javascript:` 等协议的资源加载也无法拦截。这意味着,通过这些 uri 产生的网页,其中的资源都不会被 `Service Worker` 捕获;通过这些 uri 产生的脚本,其中的 `location` 都不会被替换成 `__location`。这就会出现逃逸现象! 247 | 248 | 因此,我们还得借助 API Hook 和 DOM Hook 来覆盖这类资源的加载。(目前演示中尚未实现) 249 | 250 | 其他例如 `WebSocket` 协议 `ws:` 和 `wss:`,虽然也不会经过 `Service Worker`,但其本质仍是 HTTP,因此通过 API Hook 即可解决。 251 | 252 | 253 | # 更多优化 254 | 255 | 得益于 `Service Worker` 超高的灵活性,我们甚至可对网络架构进行改造,将前后端进行分离: 256 | 257 | * 前端只提供静态资源,负责首页展示、`Service Worker` 的安装以及自身脚本 258 | 259 | * 后端只提供代理接口,负责数据转发 260 | 261 | 这样,前端可部署在第三方 Web 服务器上,例如演示站点部署于 GitHub Pages 服务。并且,一个前端可同时使用多个后端服务,从而可实现多倍的带宽加速! 262 | 263 | 在此基础上,还可以实现负载均衡、故障切换等功能,甚至很多有趣的玩法。。。 264 | 265 | 例如,我们可将各大网站的常用静态资源,预先部署到本地 CDN 上。用户遇到这些资源时可直接从 CDN 加载,大幅加快访问速度,并能减少代理服务器的流量。 266 | 267 | 例如,服务器遇到体积较大的静态资源时,只返回文件信息,让用户从流量更廉价的渠道获取完整内容。如果失败,再从原始服务器获取。这样可大幅降低服务器的流量开销。并且这个过程在 `Service Worker` 里实现,上层业务则是毫无感知的。(目前演示网站使用 CloudFlare Worker 作为大文件的下载渠道,流量费用可节省一倍) 268 | 269 | ---- 270 | 271 | 不过,前后端分离的架构也存在一些缺陷。很多原本浏览器底层实现的功能,现在需要自己来实现,大幅增加了复杂度。例如 Cookie 的增删改查、同源策略的模拟等。目前演示中实现了 Cookie 基本功能,其他的暂未实现。 272 | 273 | 当然,尽管这个演示还不完善,但这种架构模型,目前是最先进的。搜了国外类似的站点,目前只有 [CroxyProxy](https://www.croxyproxy.com) 这个网站具备 `Service Worker` 和 DOM API Hook 的功能。不过这个网站似乎出现没多久,前端部分还是加密的。另外它没有前后端分离,代理接口是通过 PHP 实现的,相比 nginx 效率和体验都会打折扣,并且 `WebSocket` 也没有实现。 274 | 275 | 事实上在线代理本不复杂,就看如何使用各种黑科技和巧妙的思路来改进它~ 276 | -------------------------------------------------------------------------------- /docs/cert-auto.md: -------------------------------------------------------------------------------- 1 | # 自动申请 HTTPS 证书 2 | 3 | 1.转发 80 端口到 8080 端口(需要 root 权限) 4 | 5 | ```bash 6 | iptables -t nat -I PREROUTING 1 -p tcp --dport 80 -j REDIRECT --to-ports 8080 7 | ``` 8 | 9 | > 外部访问 http://服务器IP/.well-known/acme-challenge/test.txt 可验证是否正常。返回 `ok` 说明正常。 10 | 11 | 2.安装 acme.sh(无需 root 权限,在 `jsproxy` 用户下安装) 12 | 13 | ```bash 14 | su - jsproxy 15 | curl https://raw.githubusercontent.com/Neilpang/acme.sh/master/acme.sh | INSTALLONLINE=1 sh 16 | ``` 17 | 18 | > 部分精简系统可能没有 `openssl` 导致运行失败,需提前安装依赖(例如 `yum install -y openssl`) 19 | 20 | 3.申请证书 21 | 22 | ```bash 23 | # 服务器公网 IP 24 | ip=$(curl -s https://api.ipify.org) 25 | domain=$ip.xip.io 26 | 27 | dist=~/server/cert/$domain 28 | mkdir -p $dist 29 | 30 | ~/.acme.sh/acme.sh \ 31 | --issue \ 32 | -d $domain \ 33 | --keylength ec-256 \ 34 | --webroot ~/server/acme 35 | 36 | ~/.acme.sh/acme.sh \ 37 | --install-cert \ 38 | -d $domain \ 39 | --ecc \ 40 | --key-file $dist/ecc.key \ 41 | --fullchain-file $dist/ecc.cer 42 | ``` 43 | 44 | 如果申请失败(例如提示 `rate limit exceeded`),尝试将 `xip.io` 换成 `nip.io`、`sslip.io` 等其他类似的域名。 45 | 46 | 4.生成配置文件: 47 | 48 | ```conf 49 | echo " 50 | listen 8443 ssl http2; 51 | ssl_certificate cert/$domain/ecc.cer; 52 | ssl_certificate_key cert/$domain/ecc.key; 53 | " > ~/server/cert/cert.conf 54 | ``` 55 | 56 | 重启服务:`~/server/run.sh reload` 57 | 58 | 5.验证 59 | 60 | 访问 `https://服务器IP.xip.io:8443/`,没出现证书错误即成功。 61 | 62 | 6.关闭 80 端口转发 63 | 64 | ```bash 65 | iptables -t nat -D PREROUTING 1 66 | ``` 67 | 68 | 如果 80 端口没有运行其他服务,可以不关闭。因为 Let's Encrypt 证书有效期只有 3 个月,所以 acme.sh 会定期执行续签脚本。如果 80 端口关闭则无法自动续签。 -------------------------------------------------------------------------------- /docs/cert-manual.md: -------------------------------------------------------------------------------- 1 | # 手动申请 HTTPS 证书 2 | 3 | 在线申请:https://www.sslforfree.com 4 | 5 | 6 | ## 方案 1 —- 通过 80 端口验证 7 | 8 | 前提条件:公网 IP 能访问 80 端口,设备需要 root 权限 9 | 10 | 1.输入 `服务器IP.xip.io` 11 | 12 | 2.`Manual Verification` -> `Manually Verify Domain` -> `Download File` 13 | 14 | 3.文件保存到服务器 `~/server/acme/.well-known/acme-challenge/` 目录 15 | 16 | 4.转发 80 端口到 8080 端口(需要 root 权限) 17 | 18 | ```bash 19 | iptables -t nat -I PREROUTING 1 -p tcp --dport 80 -j REDIRECT --to-ports 20 | ``` 21 | 22 | 当然也可以使用其他 Web 服务,只要该文件能被外部访问就可以。 23 | 24 | 5.测试链接能否访问(Verify successful upload by visiting the following links in your browser) 25 | 26 | 6.Download SSL Certificate 27 | 28 | 7.保存证书 29 | 30 | `Certificate` 保存到 `~/server/cert/xip.io/cert` 31 | 32 | `Private Key` 保存到 `~/server/cert/xip.io/key` 33 | 34 | 编辑文件 `~/server/cert/cert.conf` 35 | 36 | ```conf 37 | listen 8443 ssl http2; 38 | ssl_certificate cert/xip.io/cert; 39 | ssl_certificate_key cert/xip.io/key; 40 | ``` 41 | 42 | 重启服务:`~/server/run.sh reload` 43 | 44 | 8.验证 45 | 46 | 访问 `https://服务器IP.xip.io:8443/`,没出现证书错误即成功。 47 | 48 | 9.关闭 80 端口转发 49 | 50 | ```bash 51 | iptables -t nat -D PREROUTING 1 52 | ``` 53 | 54 | 55 | ## 方案 2 —- 通过 DNS 验证 56 | 57 | 前提条件:拥有域名控制权(`xip.io` 不支持) 58 | 59 | 1.输入域名 60 | 61 | 2.Manual Verification (DNS) -> Manually Verify Domain 62 | 63 | 3.根据提示,创建一个 TXT 记录 64 | 65 | 4.Download SSL Certificate 66 | 67 | 5.保存证书(和上述相同) -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # 手动安装 2 | 3 | ## 创建用户 4 | 5 | 新建一个名为 `jsproxy` 用户(`nobody` 组),并切换: 6 | 7 | ```bash 8 | groupadd nobody 9 | useradd jsproxy -g nobody --create-home 10 | 11 | su - jsproxy 12 | ``` 13 | 14 | 非 Linux 系统,或者无 root 权限的设备,可忽略。 15 | 16 | > 为什么要创建用户?因为使用低权限运行服务可减少风险。另外在防 SSRF 脚本 `setup-ipset.sh` 中,是通过 iptalbes 的 `uid-owner` 策略阻止 `jsprxoy` 这个特定用户访问内网的。 17 | 18 | 19 | ## 安装 nginx 20 | 21 | 本项目使用 [OpenResty](https://openresty.org/en/)。编译前需确保 make、gcc 等工具存在。 22 | 23 | ```bash 24 | cd $(mktemp -d) 25 | 26 | curl -O https://www.openssl.org/source/openssl-1.1.1b.tar.gz 27 | tar zxf openssl-* 28 | 29 | curl -O https://ftp.pcre.org/pub/pcre/pcre-8.43.tar.gz 30 | tar zxf pcre-* 31 | 32 | curl -O https://zlib.net/zlib-1.2.11.tar.gz 33 | tar zxf zlib-* 34 | 35 | curl -O https://openresty.org/download/openresty-1.15.8.1.tar.gz 36 | tar zxf openresty-* 37 | cd openresty-* 38 | 39 | export PATH=$PATH:/sbin 40 | 41 | ./configure \ 42 | --with-openssl=../openssl-1.1.1b \ 43 | --with-pcre=../pcre-8.43 \ 44 | --with-zlib=../zlib-1.2.11 \ 45 | --with-http_v2_module \ 46 | --with-http_ssl_module \ 47 | --with-pcre-jit \ 48 | --prefix=$HOME/openresty 49 | 50 | make 51 | make install 52 | ``` 53 | 54 | 其中 `configure` 的参数 `--prefix` 指定 nginx 安装路径,这里为方便设为用户主目录。 55 | 56 | > 注意编译后的 nginx 程序不能改变位置,否则会启动失败 57 | 58 | 测试能否执行: 59 | 60 | ```bash 61 | ~/openresty/nginx/sbin/nginx -h 62 | ``` 63 | 64 | 65 | ## 安装代理程序 66 | 67 | 下载本项目,其本质就是一堆 nginx 配置。推荐放在 `jsproxy` 用户的主目录: 68 | 69 | ```bash 70 | cd ~ 71 | git clone --depth=1 https://github.com/EtherDream/jsproxy.git server 72 | ``` 73 | 74 | 下载静态资源文件到 `www` 目录: 75 | 76 | ```bash 77 | cd server 78 | rm -rf www 79 | git clone -b gh-pages --depth=1 https://github.com/EtherDream/jsproxy.git www 80 | ``` 81 | 82 | 开启服务: 83 | 84 | ```bash 85 | ./run.sh 86 | ``` 87 | 88 | 更新使用 git 即可。 89 | 90 | 91 | ## 申请域名 92 | 93 | * 免费申请:https://www.freenom.com 94 | 95 | * 临时测试:`服务器IP.xip.io` 96 | 97 | 类似的还有 `nip.io`、`sslip.io`,自动安装脚本默认使用 `xip.io`。 98 | 99 | 100 | ## 申请证书 101 | 102 | 可通过 Let's Encrypt 申请免费的 HTTPS 证书。 103 | 104 | * [手动申请](cert-manual.md) 105 | 106 | * [自动申请](cert-auto.md) 107 | 108 | 也可以不申请证书,使用免费的 HTTPS 反向代理,例如 [CloudFlare](https://www.cloudflare.com/): 109 | 110 | ```text 111 | [浏览器] --- https ---> [CloudFlare] --- http ---> [服务器] 112 | ``` 113 | 114 | 这种方案不仅能节省系统资源,还能减少流量开销(静态资源可被 CloudFlare 缓存)。当然延时可能较高,并且安全性略低。 115 | 116 | > 为什么一定要用 HTTPS?因为本项目使用了浏览器 Service Worker 技术,该 API 只能在安全环境使用,除了 localhost、127.0.0.0/8 站点可以使用 HTTP,其他必须 HTTPS。 117 | 118 | 119 | ## 支持系统 120 | 121 | 目前测试了 OSX 系统,其他还在测试中。。。 122 | -------------------------------------------------------------------------------- /i.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | { # this ensures the entire script is downloaded # 4 | 5 | JSPROXY_VER=0.1.0 6 | OPENRESTY_VER=1.15.8.1 7 | 8 | SRC_URL=https://raw.githubusercontent.com/EtherDream/jsproxy/$JSPROXY_VER 9 | BIN_URL=https://raw.githubusercontent.com/EtherDream/jsproxy-bin/master 10 | ZIP_URL=https://codeload.github.com/EtherDream/jsproxy/tar.gz 11 | 12 | SUPPORTED_OS="Linux-x86_64" 13 | OS="$(uname)-$(uname -m)" 14 | USER=$(whoami) 15 | 16 | INSTALL_DIR=/home/jsproxy 17 | NGX_DIR=$INSTALL_DIR/openresty 18 | 19 | DOMAIN_SUFFIX=( 20 | xip.io 21 | nip.io 22 | sslip.io 23 | ) 24 | 25 | GET_IP_API=( 26 | https://api.ipify.org 27 | https://bot.whatismyipaddress.com/ 28 | ) 29 | 30 | COLOR_RESET="\033[0m" 31 | COLOR_RED="\033[31m" 32 | COLOR_GREEN="\033[32m" 33 | COLOR_YELLOW="\033[33m" 34 | 35 | output() { 36 | local color=$1 37 | shift 1 38 | local sdata=$@ 39 | local stime=$(date "+%H:%M:%S") 40 | printf "$color[jsproxy $stime]$COLOR_RESET $sdata\n" 41 | } 42 | log() { 43 | output $COLOR_GREEN $1 44 | } 45 | warn() { 46 | output $COLOR_YELLOW $1 47 | } 48 | err() { 49 | output $COLOR_RED $1 50 | } 51 | 52 | gen_cert() { 53 | local ip="" 54 | 55 | for i in ${GET_IP_API[@]}; do 56 | log "服务器公网 IP 获取中,通过接口 $i" 57 | ip=$(curl -s $i) 58 | 59 | if [[ ! $ip ]]; then 60 | warn "获取失败" 61 | continue 62 | fi 63 | 64 | if ! grep -qP "^\d+\.\d+\.\d+\.\d+$" <<< $ip; then 65 | warn "无效 IP:$ip" 66 | continue 67 | fi 68 | 69 | break 70 | done 71 | 72 | if [[ $ip ]]; then 73 | log "服务器公网 IP: $ip" 74 | else 75 | err "服务器公网 IP 获取失败,无法申请证书" 76 | exit 1 77 | fi 78 | 79 | log "安装 acme.sh 脚本 ..." 80 | curl https://raw.githubusercontent.com/Neilpang/acme.sh/master/acme.sh | INSTALLONLINE=1 sh 81 | 82 | local acme=~/.acme.sh/acme.sh 83 | 84 | local domains=() 85 | 86 | if [[ $@ ]]; then 87 | for i in $@; do 88 | domains+=($i) 89 | done 90 | else 91 | warn "未指定域名,使用公共测试域名" 92 | for i in ${DOMAIN_SUFFIX[@]}; do 93 | domains+=($ip.$i) 94 | done 95 | fi 96 | 97 | for domain in ${domains[@]}; do 98 | echo "校验域名 $domain ..." 99 | 100 | local ret=$(getent ahosts $domain | head -n1 | awk '{print $1}') 101 | if [[ $ret != $ip ]]; then 102 | err "域名 $domain 解析结果: $ret,非本机公网 IP: $ip" 103 | continue 104 | fi 105 | 106 | log "尝试为域名 $domain 申请证书 ..." 107 | 108 | local dist=server/cert/$domain 109 | mkdir -p $dist 110 | 111 | $acme \ 112 | --issue \ 113 | -d $domain \ 114 | --keylength ec-256 \ 115 | --webroot server/acme 116 | 117 | $acme \ 118 | --install-cert \ 119 | -d $domain \ 120 | --ecc \ 121 | --key-file $dist/ecc.key \ 122 | --fullchain-file $dist/ecc.cer 123 | 124 | if [ -s $dist/ecc.key ] && [ -s $dist/ecc.cer ]; then 125 | echo "# generated by i.sh 126 | listen 8443 ssl http2; 127 | ssl_certificate cert/$domain/ecc.cer; 128 | ssl_certificate_key cert/$domain/ecc.key; 129 | " > server/cert/cert.conf 130 | 131 | local url=https://$domain:8443 132 | echo " 133 | $url 'mysite';" >> server/allowed-sites.conf 134 | 135 | log "证书申请完成,重启服务 ..." 136 | server/run.sh reload 137 | 138 | log "在线预览: $url" 139 | break 140 | fi 141 | 142 | err "证书申请失败!(80 端口是否添加到防火墙)" 143 | rm -rf $dist 144 | done 145 | } 146 | 147 | 148 | install() { 149 | cd $INSTALL_DIR 150 | 151 | log "下载 nginx 程序 ..." 152 | curl -O $BIN_URL/$OS/openresty-$OPENRESTY_VER.tar.gz 153 | tar zxf openresty-$OPENRESTY_VER.tar.gz 154 | rm -f openresty-$OPENRESTY_VER.tar.gz 155 | 156 | local ngx_exe=$NGX_DIR/nginx/sbin/nginx 157 | local ngx_ver=$($ngx_exe -v 2>&1) 158 | 159 | if [[ "$ngx_ver" != *"nginx version:"* ]]; then 160 | err "$ngx_exe 无法执行!尝试编译安装" 161 | exit 1 162 | fi 163 | log "$ngx_ver" 164 | log "nginx path: $NGX_DIR" 165 | 166 | log "下载代理服务 ..." 167 | curl -o jsproxy.tar.gz $ZIP_URL/$JSPROXY_VER 168 | tar zxf jsproxy.tar.gz 169 | rm -f jsproxy.tar.gz 170 | 171 | log "下载静态资源 ..." 172 | curl -o www.tar.gz $ZIP_URL/gh-pages 173 | tar zxf www.tar.gz -C jsproxy-$JSPROXY_VER/www --strip-components=1 174 | rm -f www.tar.gz 175 | 176 | if [ -x server/run.sh ]; then 177 | warn "尝试停止当前服务 ..." 178 | server/run.sh quit 179 | fi 180 | 181 | if [ -d server ]; then 182 | local backup="$INSTALL_DIR/bak/$(date +%Y_%m_%d_%H_%M_%S)" 183 | warn "当前 server 目录备份到 $backup" 184 | mkdir -p $backup 185 | mv server $backup 186 | fi 187 | 188 | mv jsproxy-$JSPROXY_VER server 189 | 190 | log "启动服务 ..." 191 | server/run.sh 192 | 193 | log "服务已开启" 194 | 195 | shift 1 196 | gen_cert $@ 197 | } 198 | 199 | main() { 200 | log "自动安装脚本开始执行" 201 | 202 | if [[ "$SUPPORTED_OS" != *"$OS"* ]]; then 203 | err "当前系统 $OS 不支持自动安装。尝试编译安装" 204 | exit 1 205 | fi 206 | 207 | if [[ "$USER" != "root" ]]; then 208 | err "自动安装需要 root 权限。如果无法使用 root,尝试编译安装" 209 | exit 1 210 | fi 211 | 212 | local cmd 213 | if [[ $0 == *"i.sh" ]]; then 214 | warn "本地调试模式" 215 | 216 | local dst=/home/jsproxy/i.sh 217 | cp $0 $dst 218 | chown jsproxy:nobody $dst 219 | if [[ $1 == "-s" ]]; then 220 | shift 1 221 | fi 222 | cmd="bash $dst install $@" 223 | else 224 | cmd="curl -s $SRC_URL/i.sh | bash -s install $@" 225 | fi 226 | 227 | iptables \ 228 | -t nat \ 229 | -I PREROUTING 1 \ 230 | -p tcp --dport 80 \ 231 | -j REDIRECT \ 232 | --to-ports 8080 233 | 234 | if ! id -u jsproxy > /dev/null 2>&1 ; then 235 | log "创建用户 jsproxy ..." 236 | groupadd nobody > /dev/null 2>&1 237 | useradd jsproxy -g nobody --create-home 238 | fi 239 | 240 | log "切换到 jsproxy 用户,执行安装脚本 ..." 241 | su - jsproxy -c "$cmd" 242 | 243 | local line=$(iptables -t nat -nL --line-numbers | grep "tcp dpt:80 redir ports 8080") 244 | iptables -t nat -D PREROUTING ${line%% *} 245 | 246 | log "安装完成。后续维护参考 https://github.com/EtherDream/jsproxy" 247 | } 248 | 249 | 250 | if [[ $1 == "install" ]]; then 251 | install $@ 252 | else 253 | main $@ 254 | fi 255 | 256 | } # this ensures the entire script is downloaded # 257 | -------------------------------------------------------------------------------- /log-svc/README.md: -------------------------------------------------------------------------------- 1 | nginx 日志备份服务 2 | 3 | ## 说明 4 | 5 | nginx 长时间运行会导致日志文件过大,该服务定期备份日志到 `backup` 目录,并进行压缩。 6 | 7 | 8 | ## 启动 9 | 10 | ```bash 11 | ./svc.sh & 12 | ``` 13 | 14 | 使用 `jsproxy` 用户运行,无需 `root`。 -------------------------------------------------------------------------------- /log-svc/backup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 功能:备份 nginx 日志到 backup 目录 3 | 4 | SVC_DIR=/home/jsproxy/server 5 | LOG_DIR=$SVC_DIR/nginx/logs 6 | DST_DIR=$SVC_DIR/log-svc/backup 7 | 8 | LOG_FILE=$LOG_DIR/proxy.log 9 | LOG_SIZE=$(( 256 * 1024 * 1024 )) 10 | 11 | ERR_FILE=$LOG_DIR/error.log 12 | ERR_SIZE=$(( 256 * 1024 * 1024 )) 13 | 14 | 15 | # error.log 达到 ERR_SIZE,开始备份(目前只清理) 16 | errsize=$(stat --printf=%s $ERR_FILE) 17 | if (( $errsize >= $ERR_SIZE )); then 18 | echo > $ERR_FILE 19 | fi 20 | 21 | # proxy.log 达到 LOG_SIZE,开始备份 22 | logsize=$(stat --printf=%s $LOG_FILE) 23 | if (( $logsize < $LOG_SIZE )); then 24 | exit 25 | fi 26 | 27 | logtime=$(date "+%Y-%m-%d-%H-%M-%S") 28 | 29 | # 30 | # 先移走日志文件,然后创建新的日志文件,通知 nginx 重新打开 31 | # 32 | mv $LOG_FILE $DST_DIR/$logtime.log 33 | touch $LOG_FILE 34 | $SVC_DIR/run.sh reopen 35 | sleep 1 36 | 37 | # 38 | # 日志压缩 39 | # 根据实际情况调整策略,在不影响系统的前提下,充分利用剩余 CPU 40 | # 41 | echo "compress ..." 42 | 43 | nice -n 19 xz $DST_DIR/*.log 44 | 45 | echo "done" -------------------------------------------------------------------------------- /log-svc/backup/README.md: -------------------------------------------------------------------------------- 1 | 该目录存放临时备份的日志。 -------------------------------------------------------------------------------- /log-svc/svc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 功能:定时调用 backup.sh 3 | 4 | echo "log svc running" 5 | CUR_DIR=$(cd `dirname $0` && pwd) 6 | 7 | # 也可用 crontab 8 | while true 9 | do 10 | $CUR_DIR/backup.sh 11 | sleep 60 12 | done -------------------------------------------------------------------------------- /log.conf: -------------------------------------------------------------------------------- 1 | # 2 | # 日志格式定义 3 | # https://nginx.org/en/docs/http/ngx_http_log_module.html 4 | # 5 | # 分隔: tab (\t) 6 | # 前缀: 格式版本。格式变化时递增,方便解析 7 | # 备注: 8 | # _origin_id 9 | # 请求源的别名,参考 allowed-sites.conf 10 | # _ver 11 | # 前端配置的版本,定义于 www/conf.js 12 | # remote_addr 13 | # 用户 IP,目前未考虑 XFF 14 | # _level 15 | # 实验中。记录切换状态(首次请求为 1。切换失败再次请求为 0,表示不接受切换) 16 | # _switched 17 | # 实验中。记录是否切换到廉价节点(未切换则为空,有切换则记录资源体积大小) 18 | # _bodyhash 19 | # 返回内容的 SHA256,用于统计重复内容 20 | # upstream_http_access_control_allow_origin 21 | # 统计 acao = * 的站点,用于加入可直连列表 22 | # _ref 23 | # 请求 referer,不包括 `https://example.com/-----` 部分 24 | # _mode 25 | # 前端 request.mode 属性 26 | # _type 27 | # 前端 request.destination 属性 28 | # 29 | log_format log_proxy escape=none 30 | '02 ' 31 | '$time_iso8601 ' 32 | '$_origin_id ' 33 | '$_ver ' 34 | '$remote_addr ' 35 | '$_level ' 36 | '$_switched ' 37 | '$upstream_cache_status ' 38 | '$request_time ' 39 | '$request_length ' 40 | '$bytes_sent ' 41 | '$request_method ' 42 | '$_url ' 43 | '$status ' 44 | '$_bodyhash ' 45 | '$upstream_http_access_control_allow_origin ' 46 | '$http_user_agent ' 47 | '$_ref ' 48 | '$_mode ' 49 | '$_type' 50 | ; 51 | 52 | log_format log_www escape=none 53 | '01 ' 54 | '$time_iso8601 ' 55 | '$remote_addr ' 56 | '$request_time ' 57 | '$request_method ' 58 | '$uri ' 59 | '$http_host ' 60 | '$status ' 61 | '$http_user_agent' 62 | ; 63 | -------------------------------------------------------------------------------- /lua/http-body-hash.lua: -------------------------------------------------------------------------------- 1 | -- ngx.arg[1] => chunk 2 | -- ngx.arg[2] => eof 3 | 4 | 5 | -- 大文件只返回首块 hash(用户从廉价带宽获取内容) 6 | if ngx.ctx._switched then 7 | local chunk = ngx.arg[1] 8 | ngx.arg[1] = #chunk .. ',' .. ngx.crc32_long(chunk) 9 | ngx.arg[2] = true 10 | return 11 | end 12 | 13 | 14 | -- 计算 HTTP 返回数据的 hash(用于统计) 15 | if ngx.ctx._sha256 == nil then 16 | local resty_sha256 = require 'resty.sha256' 17 | ngx.ctx._sha256 = resty_sha256:new() 18 | end 19 | 20 | if ngx.arg[2] then 21 | local digest = ngx.ctx._sha256:final() 22 | digest = digest:sub(1, 16) 23 | 24 | local str = require 'resty.string' 25 | ngx.var._bodyhash = str.to_hex(digest) 26 | else 27 | ngx.ctx._sha256:update(ngx.arg[1]) 28 | end -------------------------------------------------------------------------------- /lua/http-dec-req-hdr.lua: -------------------------------------------------------------------------------- 1 | -- 还原 HTTP 请求头 2 | local hasRawRefer = false 3 | 4 | local hdrs = ngx.req.get_headers() 5 | local refer = hdrs['referer'] 6 | local query = refer:sub(refer:find('?', 10, true) + 1) 7 | local param = ngx.decode_args(query) 8 | 9 | 10 | for k, v in pairs(param) do 11 | if k:sub(1, 2) == '--' then 12 | k = k:sub(3) 13 | 14 | if k == 'ver' then 15 | ngx.var._ver = v 16 | elseif k == 'type' then 17 | ngx.var._type = v 18 | elseif k == 'mode' then 19 | ngx.var._mode = v 20 | elseif k == 'aceh' then 21 | ngx.ctx._acehOld = true 22 | elseif k == 'level' then 23 | ngx.var._level = v 24 | ngx.ctx._level = tonumber(v) 25 | end 26 | else 27 | ngx.req.set_header(k, v) 28 | 29 | if k == 'referer' then 30 | hasRawRefer = true 31 | ngx.var._ref = v 32 | end 33 | end 34 | end 35 | 36 | if not hasRawRefer then 37 | ngx.req.clear_header('referer') 38 | end 39 | 40 | -- 删除 URL 的 '/http/' 前缀 41 | ngx.var._url = ngx.var.request_uri:sub(7) -------------------------------------------------------------------------------- /lua/http-enc-res-hdr.lua: -------------------------------------------------------------------------------- 1 | -- 功能:编码 HTTP 返回头 2 | -- 阶段:header_filter_by_lua 3 | -- 备注: 4 | -- aceh = HTTP 返回头的 access-control-expose-headers 字段 5 | 6 | -- 无论浏览器是否支持,aceh 始终包含 * 7 | local expose = '*' 8 | 9 | -- 该值为 true 表示浏览器不支持 aceh: *,需返回详细的头部列表 10 | local detail = ngx.ctx._acehOld 11 | 12 | 13 | local function addHdr(k, v) 14 | ngx.header[k] = v 15 | if detail then 16 | expose = expose .. ',' .. k 17 | end 18 | end 19 | 20 | 21 | local function flushHdr() 22 | if detail then 23 | if status ~= 200 then 24 | expose = expose .. ',--s' 25 | end 26 | -- 该字段不在 aceh 中,如果浏览器能读取到,说明支持 * 通配 27 | ngx.header['--t'] = '1' 28 | end 29 | 30 | ngx.header['access-control-expose-headers'] = expose 31 | ngx.header['access-control-allow-origin'] = '*' 32 | 33 | local status = ngx.status 34 | 35 | -- 前端优先使用该字段作为状态码 36 | if status ~= 200 then 37 | ngx.header['--s'] = status 38 | end 39 | 40 | -- 保留原始状态码,便于控制台调试 41 | -- 例如 404 显示红色,如果统一设置成 200 则没有颜色区分 42 | -- 需要转义 30X 重定向,否则不符合 cors 标准 43 | if 44 | status == 301 or 45 | status == 302 or 46 | status == 303 or 47 | status == 307 or 48 | status == 308 49 | then 50 | status = status + 10 51 | end 52 | ngx.status = status 53 | end 54 | 55 | 56 | local function nodeSwitched() 57 | local status = ngx.status 58 | if status ~= 200 and status ~= 206 then 59 | return false 60 | end 61 | 62 | local level = ngx.ctx._level 63 | if level == nil or level == 0 then 64 | return false 65 | end 66 | 67 | if ngx.req.get_method() ~= 'GET' then 68 | return false 69 | end 70 | 71 | if ngx.header['set-cookie'] ~= nil then 72 | return false 73 | end 74 | 75 | local resLenStr = ngx.header['content-length'] 76 | if resLenStr == nil then 77 | return false 78 | end 79 | 80 | -- 小于 400KB 的资源不走加速 81 | local resLenNum = tonumber(resLenStr) 82 | if resLenNum == nil or resLenNum < 1000 * 400 then 83 | return false 84 | end 85 | 86 | 87 | local addr = ngx.var.upstream_addr or '' 88 | local etag = ngx.header['etag'] or '' 89 | local last = ngx.header['last-modified'] or '' 90 | 91 | local info = addr .. '|' .. resLenStr .. '|' .. etag .. '|' .. last 92 | 93 | -- clear all res headers 94 | local h, err = ngx.resp.get_headers() 95 | for k, v in pairs(h) do 96 | ngx.header[k] = nil 97 | end 98 | 99 | addHdr('--raw-info', info) 100 | addHdr('--switched', '1') 101 | 102 | ngx.header['cache-control'] = 'no-cache' 103 | ngx.var._switched = resLenStr 104 | ngx.ctx._switched = true 105 | 106 | flushHdr() 107 | return true 108 | end 109 | 110 | -- 节点切换功能,目前还在测试中(demo 中已开启) 111 | if nodeSwitched() then 112 | return 113 | end 114 | 115 | 116 | local h, err = ngx.resp.get_headers() 117 | for k, v in pairs(h) do 118 | if 119 | -- 这些头有特殊意义,需要转义 -- 120 | k == 'access-control-allow-origin' or 121 | k == 'access-control-expose-headers' or 122 | k == 'location' or 123 | k == 'set-cookie' 124 | then 125 | if type(v) == 'table' then 126 | -- 重复的字段,例如 Set-Cookie 127 | -- 转换成 1-Set-Cookie, 2-Set-Cookie, ... 128 | for i = 1, #v do 129 | addHdr(i .. '-' .. k, v[i]) 130 | end 131 | else 132 | addHdr('--' .. k, v) 133 | end 134 | ngx.header[k] = nil 135 | 136 | elseif detail and 137 | -- 非简单头无法被 fetch 读取,需添加到 aceh 列表 -- 138 | -- https://developer.mozilla.org/en-US/docs/Glossary/Simple_response_header 139 | k ~= 'cache-control' and 140 | k ~= 'content-language' and 141 | k ~= 'content-type' and 142 | k ~= 'expires' and 143 | k ~= 'last-modified' and 144 | k ~= 'pragma' 145 | then 146 | expose = expose .. ',' .. k 147 | end 148 | end 149 | 150 | -- 不缓存非 GET 请求 151 | if ngx.req.get_method() ~= 'GET' then 152 | ngx.header['cache-control'] = 'no-cache' 153 | end 154 | 155 | flushHdr() 156 | -------------------------------------------------------------------------------- /lua/ws-dec-req-hdr.lua: -------------------------------------------------------------------------------- 1 | -- 功能:还原 WebSocket 的 HTTP 请求头 2 | -- 阶段:access_by_lua 3 | -- 备注:JS 无法设置 ws 的头部,因此信息存储于 query 4 | 5 | local query, err = ngx.req.get_uri_args() 6 | 7 | for k, v in pairs(query) do 8 | if k == 'url__' then 9 | ngx.var._url = v 10 | elseif k == 'ver__' then 11 | ngx.var._ver = v 12 | else 13 | ngx.req.set_header(k, v) 14 | end 15 | end -------------------------------------------------------------------------------- /mime.types: -------------------------------------------------------------------------------- 1 | 2 | types { 3 | text/html html htm shtml; 4 | text/css css; 5 | text/xml xml; 6 | image/gif gif; 7 | image/jpeg jpeg jpg; 8 | application/javascript js; 9 | application/atom+xml atom; 10 | application/rss+xml rss; 11 | 12 | text/mathml mml; 13 | text/plain txt; 14 | text/vnd.sun.j2me.app-descriptor jad; 15 | text/vnd.wap.wml wml; 16 | text/x-component htc; 17 | 18 | image/png png; 19 | image/svg+xml svg svgz; 20 | image/tiff tif tiff; 21 | image/vnd.wap.wbmp wbmp; 22 | image/webp webp; 23 | image/x-icon ico; 24 | image/x-jng jng; 25 | image/x-ms-bmp bmp; 26 | 27 | font/woff woff; 28 | font/woff2 woff2; 29 | 30 | application/java-archive jar war ear; 31 | application/json json; 32 | application/mac-binhex40 hqx; 33 | application/msword doc; 34 | application/pdf pdf; 35 | application/postscript ps eps ai; 36 | application/rtf rtf; 37 | application/vnd.apple.mpegurl m3u8; 38 | application/vnd.google-earth.kml+xml kml; 39 | application/vnd.google-earth.kmz kmz; 40 | application/vnd.ms-excel xls; 41 | application/vnd.ms-fontobject eot; 42 | application/vnd.ms-powerpoint ppt; 43 | application/vnd.oasis.opendocument.graphics odg; 44 | application/vnd.oasis.opendocument.presentation odp; 45 | application/vnd.oasis.opendocument.spreadsheet ods; 46 | application/vnd.oasis.opendocument.text odt; 47 | application/vnd.openxmlformats-officedocument.presentationml.presentation 48 | pptx; 49 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 50 | xlsx; 51 | application/vnd.openxmlformats-officedocument.wordprocessingml.document 52 | docx; 53 | application/vnd.wap.wmlc wmlc; 54 | application/x-7z-compressed 7z; 55 | application/x-cocoa cco; 56 | application/x-java-archive-diff jardiff; 57 | application/x-java-jnlp-file jnlp; 58 | application/x-makeself run; 59 | application/x-perl pl pm; 60 | application/x-pilot prc pdb; 61 | application/x-rar-compressed rar; 62 | application/x-redhat-package-manager rpm; 63 | application/x-sea sea; 64 | application/x-shockwave-flash swf; 65 | application/x-stuffit sit; 66 | application/x-tcl tcl tk; 67 | application/x-x509-ca-cert der pem crt; 68 | application/x-xpinstall xpi; 69 | application/xhtml+xml xhtml; 70 | application/xspf+xml xspf; 71 | application/zip zip; 72 | 73 | application/octet-stream bin exe dll; 74 | application/octet-stream deb; 75 | application/octet-stream dmg; 76 | application/octet-stream iso img; 77 | application/octet-stream msi msp msm; 78 | 79 | audio/midi mid midi kar; 80 | audio/mpeg mp3; 81 | audio/ogg ogg; 82 | audio/x-m4a m4a; 83 | audio/x-realaudio ra; 84 | 85 | video/3gpp 3gpp 3gp; 86 | video/mp2t ts; 87 | video/mp4 mp4; 88 | video/mpeg mpeg mpg; 89 | video/quicktime mov; 90 | video/webm webm; 91 | video/x-flv flv; 92 | video/x-m4v m4v; 93 | video/x-mng mng; 94 | video/x-ms-asf asx asf; 95 | video/x-ms-wmv wmv; 96 | video/x-msvideo avi; 97 | } 98 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | include log.conf; 3 | server { 4 | listen 8080; 5 | include cert/cert.conf; 6 | include api.conf; 7 | include www.conf; 8 | } 9 | 10 | # https://nginx.org/en/docs/http/ngx_http_core_module.html 11 | resolver 1.1.1.1 ipv6=off; 12 | resolver_timeout 10s; 13 | 14 | keepalive_timeout 60; 15 | keepalive_requests 2048; 16 | server_tokens off; 17 | underscores_in_headers on; 18 | 19 | # https://nginx.org/en/docs/http/ngx_http_ssl_module.html 20 | ssl_protocols TLSv1.2 TLSv1.3; 21 | ssl_ciphers TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-256-GCM-SHA384:TLS13-AES-128-GCM-SHA256:EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH; 22 | ssl_session_cache shared:SSL:30m; 23 | ssl_session_timeout 1d; 24 | ssl_prefer_server_ciphers on; 25 | 26 | # https://nginx.org/en/docs/http/ngx_http_limit_req_module.html 27 | limit_req_log_level warn; 28 | limit_req_zone $binary_remote_addr zone=reqip:16m rate=100r/s; 29 | limit_req zone=reqip burst=200 nodelay; 30 | 31 | access_log logs/proxy.log log_proxy buffer=64k flush=1s; 32 | 33 | # https://nginx.org/cn/docs/http/ngx_http_proxy_module.html 34 | # 1MB = 8000key 35 | proxy_cache_path cache 36 | levels=1:2 37 | keys_zone=my_cache:32m 38 | max_size=20g 39 | inactive=6h 40 | use_temp_path=off 41 | ; 42 | proxy_http_version 1.1; 43 | proxy_ssl_server_name on; 44 | 45 | proxy_buffer_size 16k; 46 | proxy_buffers 4 32k; 47 | proxy_busy_buffers_size 64k; 48 | proxy_send_timeout 30s; 49 | proxy_read_timeout 30s; 50 | proxy_connect_timeout 10s; 51 | 52 | lua_load_resty_core off; 53 | 54 | map $http_origin $_origin_id { 55 | include allowed-sites.conf; 56 | } 57 | } 58 | 59 | # https://nginx.org/en/docs/ngx_core_module.html 60 | events { 61 | worker_connections 4096; 62 | } -------------------------------------------------------------------------------- /nginx/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !logs 3 | !.gitignore -------------------------------------------------------------------------------- /nginx/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | # 2 | # 该脚本封装 nginx 调用,可在任意位置执行 3 | # 4 | # 启动:./run.sh 5 | # 重启:./run.sh -s reload 6 | # 关闭:./run.sh -s quit 7 | # 8 | NGX_BIN=~/openresty/nginx/sbin/nginx 9 | CUR_DIR=$(cd `dirname $0` && pwd) 10 | 11 | if [ $1 ]; then 12 | PARAM="-s $1" 13 | fi 14 | 15 | $NGX_BIN -c $CUR_DIR/nginx.conf -p $CUR_DIR/nginx $PARAM -------------------------------------------------------------------------------- /setup-ipset.sh: -------------------------------------------------------------------------------- 1 | # 2 | # 该脚本用于禁止 jsporxy 用户访问内网,防止 SSRF 攻击 3 | # 需要 root 权限运行,依赖 ipset 命令 4 | # 5 | if [[ $(iptables -L | grep "anti ssrf") ]]; then 6 | exit 7 | fi 8 | 9 | ipset create ngx-ban-dstip hash:net 10 | 11 | iptables \ 12 | -m comment --comment "anti ssrf" \ 13 | -A OUTPUT \ 14 | -p tcp --syn \ 15 | -m owner --uid-owner jsproxy \ 16 | -m set --match-set ngx-ban-dstip dst \ 17 | -j REJECT 18 | 19 | # https://en.wikipedia.org/wiki/Reserved_IP_addresses 20 | REV_NET=( 21 | 0.0.0.0/8 22 | 10.0.0.0/8 23 | 100.64.0.0/10 24 | 127.0.0.0/8 25 | 169.254.0.0/16 26 | 172.16.0.0/12 27 | 192.0.0.0/24 28 | 192.0.2.0/24 29 | 192.88.99.0/24 30 | 192.168.0.0/16 31 | 198.18.0.0/15 32 | 198.51.100.0/24 33 | 203.0.113.0/24 34 | 224.0.0.0/4 35 | 240.0.0.0/4 36 | 255.255.255.255/32 37 | ) 38 | 39 | for v in ${REV_NET[@]}; do 40 | ipset add ngx-ban-dstip $v 41 | done 42 | 43 | # 可屏蔽更多的网段: 44 | # ipset add ngx-ban-dstip xxx -------------------------------------------------------------------------------- /test/works.txt: -------------------------------------------------------------------------------- 1 | ok -------------------------------------------------------------------------------- /www.conf: -------------------------------------------------------------------------------- 1 | # 2 | # 提供 www 目录的静态资源服务 3 | # 4 | include mime.types; 5 | sendfile on; 6 | charset utf-8; 7 | 8 | # 安装步骤多,节省的流量不多,暂时不开 9 | # brotli_static on; 10 | # gzip_static on; 11 | gzip on; 12 | 13 | log_not_found off; 14 | error_page 404 = /404.html; 15 | 16 | location = /404.html { 17 | internal; 18 | root ../www; 19 | 20 | # http 重定向到 https(忽略 localhost 或 IP 访问) 21 | access_by_lua_block { 22 | if ngx.var.scheme == 'https' then 23 | return 24 | end 25 | local host = ngx.var.host 26 | if host == 'localhost' then 27 | return 28 | end 29 | if ngx.re.match(host, [[^\d+\.\d+\.\d+\.\d+$]]) then 30 | return 31 | end 32 | local url = host .. ':8443' .. ngx.var.request_uri 33 | ngx.redirect('https://' .. url, 301) 34 | } 35 | 36 | # 永久重定向申请: https://hstspreload.org/ 37 | more_set_headers 38 | 'strict-transport-security: max-age=99999999; includeSubDomains; preload' 39 | ; 40 | } 41 | 42 | location / { 43 | access_log logs/access.log log_www buffer=64k flush=1s; 44 | root ../www; 45 | index 404.html; 46 | } 47 | 48 | # HTTPS 证书申请验证 49 | location /.well-known/acme-challenge/ { 50 | access_log logs/acme.log combined; 51 | root ../acme; 52 | } -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !README.md 3 | !.gitignore -------------------------------------------------------------------------------- /www/README.md: -------------------------------------------------------------------------------- 1 | 该目录存放首页静态资源,内容和 gh-pages 分支相同 --------------------------------------------------------------------------------