部署JSPROXY网页在线代理教程

image.png

项目简介

JSPROXY项目地址

JSPROXY是一个基于js在线代理网页的项目,就可通过某个网站访问另一个网站(通常无法直接访问)。不用安装任何插件,不用修改任何配置,仅仅打开一个网页即可。与其他在线代理相比,具有网页js支持好、使用方便、搭建方便等特点。

项目优点

目前存在的问题

  • 无法登录Google
  • Google reCAPTCHA 无法执行
  • twitter 在 Chrome 普通模式下无法登陆,但隐身模式可以
  • 非 UTF8 编码的 JS 会出现乱码(MIME 未指定 charset 的情况下出现)
  • twitch.tv 首页报错
  • Youtube播放全屏卡住

自动部署

curl https://raw.githubusercontent.com/EtherDream/jsproxy/master/i.sh | bash
  • 自动安装目前只支持 Linux x64,并且需要 root 权限
  • 安装过程中 80 端口能被外网访问(申请 HTTPS 证书)

访问: https://服务器IP.xip.io:8443 (具体看脚本输出)

若无法满足上述条件,或希望了解细节,尝试手动搭建

自定义域名

先将域名example.com解析至服务器ip,然后执行👇

curl https://raw.githubusercontent.com/EtherDream/jsproxy/master/i.sh | bash -s example.com

访问: https://example.com:8443

自定义端口

默认为8443(HTTPS)和8080(HTTP),如需改成44380,推荐使用端口转发:

iptables -A PREROUTING -t nat -p tcp --dport 443 -j REDIRECT --to-ports 8443
iptables -A PREROUTING -t nat -p tcp --dport 80 -j REDIRECT --to-ports 8080

同时修改www.conf中的:8443:443

使用 GitHub Pages 前端

本项目支持前后端分离,前端部分(www 目录下的文件)可部署在第三方 Web 服务器上。

例如演示站点的前端部署于 GitHub Pages 服务,从而可使用个性域名(*.github.io),还能减少一定的流量开销。

  1. Fork 本项目,进入 gh-pages 分支(该分支内容和 www 目录相同),编辑 conf.js 文件:

    • 节点列表(node_map 字段,包括节点 id 和节点主机)

    • 默认节点(node_default 字段,指定节点 id)

  2. 在项目根目录下创建 index.html (否则无法创建 Github Pages ),内容为空即可。
    • 网页上点击 Create new file ,文件名 index.html ,滑到最下面 Commit new file 即可。
    • 其他方式自行操作或在评论区提问
  3. 点击项目页的 Settings ,向下滑至 Github Pages ,在 Source 下选择 gh-pages brach ,并勾选 Enforce HTTPS ,然后您可以访问 https://username.github.io/jsproxy/ 进行测试,其中 username 为您的 github 用户名。
  4. 删除 index.html
  5. Github Pages 支持自定义域名,您可以解析您的域名 CNAMEusername.github.io ,并在根目录下创建 CNAME 文件,内容为您的域名(如 example.com ),这样您就可以通过访问 https://example.com/jsproxy/ 进行测试了。

手动搭建

创建用户

新建一个名为 jsproxy 用户(nobody 组),并切换:

groupadd nobody
useradd jsproxy -g nobody --create-home

su - jsproxy

非 Linux 系统,或者无 root 权限的设备,可忽略。

为什么要创建用户?因为使用低权限运行服务可减少风险。另外在防 SSRF 脚本 setup-ipset.sh 中,是通过 iptalbes 的 uid-owner 策略阻止 jsprxoy 这个特定用户访问内网的。

安装 nginx

本项目使用 OpenResty。编译前需确保 make、gcc 等工具存在。

cd $(mktemp -d)

curl -O https://www.openssl.org/source/openssl-1.1.1b.tar.gz
tar zxf openssl-*

curl -O https://ftp.pcre.org/pub/pcre/pcre-8.43.tar.gz
tar zxf pcre-*

curl -O https://zlib.net/zlib-1.2.11.tar.gz
tar zxf zlib-*

curl -O https://openresty.org/download/openresty-1.15.8.1.tar.gz
tar zxf openresty-*
cd openresty-*

export PATH=$PATH:/sbin

./configure \
  --with-openssl=../openssl-1.1.1b \
  --with-pcre=../pcre-8.43 \
  --with-zlib=../zlib-1.2.11 \
  --with-http_v2_module \
  --with-http_ssl_module \
  --with-pcre-jit \
  --prefix=$HOME/openresty

make
make install

其中 configure 的参数 --prefix 指定 nginx 安装路径,这里为方便设为用户主目录。

注意编译后的 nginx 程序不能改变位置,否则会启动失败

测试能否执行:

~/openresty/nginx/sbin/nginx -h

安装代理程序

下载本项目,其本质就是一堆 nginx 配置。推荐放在 jsproxy 用户的主目录:

cd ~
git clone --depth=1 https://github.com/EtherDream/jsproxy.git server

下载静态资源文件到 www 目录:

cd server
rm -rf www
git clone -b gh-pages --depth=1 https://github.com/EtherDream/jsproxy.git www

开启服务:

./run.sh

更新使用 git 即可。

申请域名

  • 免费申请:https://www.freenom.com

  • 临时测试:服务器IP.xip.io

类似的还有 nip.iosslip.io,自动安装脚本默认使用 xip.io

申请证书

可通过 Let’s Encrypt 申请免费的 HTTPS 证书。

也可以不申请证书,使用免费的 HTTPS 反向代理,例如 CloudFlare

[浏览器] --- https ---> [CloudFlare] --- http ---> [服务器]

这种方案不仅能节省系统资源,还能减少流量开销(静态资源可被 CloudFlare 缓存)。当然延时可能较高,并且安全性略低。

为什么一定要用 HTTPS?因为本项目使用了浏览器 Service Worker 技术,该 API 只能在安全环境使用,除了 localhost、127.0.0.0/8 站点可以使用 HTTP,其他必须 HTTPS。

支持系统

目前测试了 OSX 系统,其他还在测试中。。。


维护

# 切换到 jsproxy 用户
su - jsproxy
# 进入 jsproxy 文件夹
cd jsproxy
# 重启服务
./run.sh reload

# 关闭服务(参数和 nginx -s 相同)
./run.sh quit

# 启动服务
./run.sh

# 查看代理日志
tail server/nginx/logs/proxy.log

目前暂未实现开机自启动。

禁止外链

默认情况下,代理接口允许所有 github.io 子站点调用,这可能导致不必要的流量消耗。

如果希望只给自己网站使用,可编辑 jsproxy 文件夹下 allowed-sites.conf。(重启服务生效)

安全策略

如果不希望代理访问内网(避免 SSRF 风险),可执行 setup-ipset.sh

/home/jsproxy/server/setup-ipset.sh

需要 root 权限,依赖 ipset 命令

该脚本可禁止 jsporxy 用户访问保留 IP 段(针对 TCP)。nginx 之外的程序也生效,但不影响其他用户。


CloudFlareWorkers免费部署

感谢作者开发出了如此好用的项目,为没有服务器的人提供了方便,每天 100,000 次的免费使用

简介

CloudFlare Worker 是 CloudFlare 的边缘计算服务。开发者可通过 JavaScript 对 CDN 进行编程,从而能灵活处理 HTTP 请求。这使得很多任务可在 CDN 上完成,无需自己的服务器参与。

部署

首页:https://workers.cloudflare.com

注册,登陆,Start building,取一个子域名,Create a Worker

复制 index.js 到左侧代码框,Save and deploy。如果正常,右侧应显示首页。

收藏地址框中的 https://xxxx.子域名.workers.dev,以后可直接访问。

使用自定义域名

CloudFlareWokers 使用自定义域名可以尝试用该教程搭建,但可能因为服务器配置原因,我从来没有成功过,所以使用了如下方法👇

如果你的 CloudFlare 账户中已经有了托管的域名,在域名的 DNS 页中添加 CNAME 解析到之前部署的 https://xxxx.子域名.workers.dev (以 js.example.com 为例),然后在 域名页 中找到 Workers ,点击 Add route ,在 Worker 中选择你部署的项目,在 Route 中填 js.example.com/* 注意一定要有 /*

计费

后退到 overview 页面可参看使用情况。免费版每天有 10 万次免费请求,对于个人通常足够。

如果不够用,可注册多个 Worker,在 conf.js 中配置多线路负载均衡。或者升级到 $5 的高级版本,每月可用 1000 万次请求(超出部分 $0.5/百万次请求)。

修改配置

默认情况下,静态资源从 https://etherdream.github.io/jsproxy 反向代理,可通过代码中 ASSET_URL 配置,从而可使用自定义的 conf.js 配置。

存在问题

  • WebSocket 代理尚未实现

  • 外链限制尚未实现

  • 未充分测试,以后再完善

代码备份

'use strict'

/**
 * static files (404.html, sw.js, conf.js)
 */
const ASSET_URL = 'https://etherdream.github.io/jsproxy'

const JS_VER = 10
const MAX_RETRY = 1

/** @type {RequestInit} */
const PREFLIGHT_INIT = {
  status: 204,
  headers: new Headers({
    'access-control-allow-origin': '*',
    'access-control-allow-methods': 'GET,POST,PUT,PATCH,TRACE,DELETE,HEAD,OPTIONS',
    'access-control-max-age': '1728000',
  }),
}

/**
 * @param {any} body
 * @param {number} status
 * @param {Object<string, string>} headers
 */
function makeRes(body, status = 200, headers = {}) {
  headers['--ver'] = JS_VER
  headers['access-control-allow-origin'] = '*'
  return new Response(body, {status, headers})
}


/**
 * @param {string} urlStr 
 */
function newUrl(urlStr) {
  try {
    return new URL(urlStr)
  } catch (err) {
    return null
  }
}


addEventListener('fetch', e => {
  const ret = fetchHandler(e)
    .catch(err => makeRes('cfworker error:\n' + err.stack, 502))
  e.respondWith(ret)
})


/**
 * @param {FetchEvent} e 
 */
async function fetchHandler(e) {
  const req = e.request
  const urlStr = req.url
  const urlObj = new URL(urlStr)
  const path = urlObj.href.substr(urlObj.origin.length)

  if (urlObj.protocol === 'http:') {
    urlObj.protocol = 'https:'
    return makeRes('', 301, {
      'strict-transport-security': 'max-age=99999999; includeSubDomains; preload',
      'location': urlObj.href,
    })
  }

  if (path.startsWith('/http/')) {
    return httpHandler(req, path.substr(6))
  }

  switch (path) {
  case '/http':
    return makeRes('请更新 cfworker 到最新版本!')
  case '/ws':
    return makeRes('not support', 400)
  case '/works':
    return makeRes('it works')
  default:
    // static files
    return fetch(ASSET_URL + path)
  }
}


/**
 * @param {Request} req
 * @param {string} pathname
 */
function httpHandler(req, pathname) {
  const reqHdrRaw = req.headers
  if (reqHdrRaw.has('x-jsproxy')) {
    return Response.error()
  }

  // preflight
  if (req.method === 'OPTIONS' &&
      reqHdrRaw.has('access-control-request-headers')
  ) {
    return new Response(null, PREFLIGHT_INIT)
  }

  let acehOld = false
  let rawSvr = ''
  let rawLen = ''
  let rawEtag = ''

  const reqHdrNew = new Headers(reqHdrRaw)
  reqHdrNew.set('x-jsproxy', '1')

  // 此处逻辑和 http-dec-req-hdr.lua 大致相同
  // https://github.com/EtherDream/jsproxy/blob/master/lua/http-dec-req-hdr.lua
  const refer = reqHdrNew.get('referer')
  const query = refer.substr(refer.indexOf('?') + 1)
  if (!query) {
    return makeRes('missing params', 403)
  }
  const param = new URLSearchParams(query)

  for (const [k, v] of Object.entries(param)) {
    if (k.substr(0, 2) === '--') {
      // 系统信息
      switch (k.substr(2)) {
      case 'aceh':
        acehOld = true
        break
      case 'raw-info':
        [rawSvr, rawLen, rawEtag] = v.split('|')
        break
      }
    } else {
      // 还原 HTTP 请求头
      if (v) {
        reqHdrNew.set(k, v)
      } else {
        reqHdrNew.delete(k)
      }
    }
  }
  if (!param.has('referer')) {
    reqHdrNew.delete('referer')
  }

  // cfworker 会把路径中的 `//` 合并成 `/`
  const urlStr = pathname.replace(/^(https?):\/+/, '$1://')
  const urlObj = newUrl(urlStr)
  if (!urlObj) {
    return makeRes('invalid proxy url: ' + urlStr, 403)
  }

  /** @type {RequestInit} */
  const reqInit = {
    method: req.method,
    headers: reqHdrNew,
    redirect: 'manual',
  }
  if (req.method === 'POST') {
    reqInit.body = req.body
  }
  return proxy(urlObj, reqInit, acehOld, rawLen, 0)
}


/**
 * 
 * @param {URL} urlObj 
 * @param {RequestInit} reqInit 
 * @param {number} retryTimes 
 */
async function proxy(urlObj, reqInit, acehOld, rawLen, retryTimes) {
  const res = await fetch(urlObj.href, reqInit)
  const resHdrOld = res.headers
  const resHdrNew = new Headers(resHdrOld)

  let expose = '*'
  
  for (const [k, v] of resHdrOld.entries()) {
    if (k === 'access-control-allow-origin' ||
        k === 'access-control-expose-headers' ||
        k === 'location' ||
        k === 'set-cookie'
    ) {
      const x = '--' + k
      resHdrNew.set(x, v)
      if (acehOld) {
        expose = expose + ',' + x
      }
      resHdrNew.delete(k)
    }
    else if (acehOld &&
      k !== 'cache-control' &&
      k !== 'content-language' &&
      k !== 'content-type' &&
      k !== 'expires' &&
      k !== 'last-modified' &&
      k !== 'pragma'
    ) {
      expose = expose + ',' + k
    }
  }

  if (acehOld) {
    expose = expose + ',--s'
    resHdrNew.set('--t', '1')
  }

  // verify
  if (rawLen) {
    const newLen = resHdrOld.get('content-length') || ''
    const badLen = (rawLen !== newLen)

    if (badLen) {
      if (retryTimes < MAX_RETRY) {
        urlObj = await parseYtVideoRedir(urlObj, newLen, res)
        if (urlObj) {
          return proxy(urlObj, reqInit, acehOld, rawLen, retryTimes + 1)
        }
      }
      return makeRes(res.body, 400, {
        '--error': `bad len: ${newLen}, except: ${rawLen}`,
        'access-control-expose-headers': '--error',
      })
    }

    if (retryTimes > 1) {
      resHdrNew.set('--retry', retryTimes)
    }
  }

  let status = res.status

  resHdrNew.set('access-control-expose-headers', expose)
  resHdrNew.set('access-control-allow-origin', '*')
  resHdrNew.set('--s', status)
  resHdrNew.set('--ver', JS_VER)

  resHdrNew.delete('content-security-policy')
  resHdrNew.delete('content-security-policy-report-only')
  resHdrNew.delete('clear-site-data')

  if (status === 301 ||
      status === 302 ||
      status === 303 ||
      status === 307 ||
      status === 308
  ) {
    status = status + 10
  }

  return new Response(res.body, {
    status,
    headers: resHdrNew,
  })
}


/**
 * @param {URL} urlObj 
 */
function isYtUrl(urlObj) {
  return (
    urlObj.host.endsWith('.googlevideo.com') &&
    urlObj.pathname.startsWith('/videoplayback')
  )
}

/**
 * @param {URL} urlObj 
 * @param {number} newLen 
 * @param {Response} res 
 */
async function parseYtVideoRedir(urlObj, newLen, res) {
  if (newLen > 2000) {
    return null
  }
  if (!isYtUrl(urlObj)) {
    return null
  }
  try {
    const data = await res.text()
    urlObj = new URL(data)
  } catch (err) {
    return null
  }
  if (!isYtUrl(urlObj)) {
    return null
  }
  return urlObj
}

本次教程到这里就结束了,本文将长期更新,如果有什么问题请在下方评论区提出。