├── .gitignore ├── README.md ├── client.py ├── css.min.js ├── requirements.txt ├── server.py └── settings.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | .idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TBridge 2 | 3 | 基于http协议的tunnel,可在**只支持http代理访问web**的网络环境中使用其他协议。 4 | 5 | By. [twi1ight@t00ls.net](mailto:twi1ight@t00ls.net) 6 | 7 | ## 简介 8 | 9 | 上面一句话介绍有点绕,使用场景如下: 10 | 11 | - 公司内部只支持http代理上网,所有人上网都从统一的http代理出去 12 | - 代理只允许访问http/https协议,其他协议配置该代理无法使用 13 | - 代理上传数据限制大小4K 14 | 15 | 因此,没办法用ssh直接连接自己的VPS,为解决该问题开发了TBridge。 16 | 17 | ## 原理 18 | 19 | TBridge分为两部分:server运行在外部VPS上,会开放一个web服务;client运行在本地,会监听一个端口。 20 | 21 | ssh客户端连接client监听的端口,然后client会将从ssh客户端收到的数据用==AES加密、base64编码==后放在Cookie中通过http代理发送到server端的web接口上,server端收到数据解码后发送给ssh服务器进程(sshd),然后将sshd的响应数据AES加密、base64编码后用==js文件模板混淆后==返回给client,client将数据解码后发送给ssh客户端,至此完成一次数据交互。 22 | 23 | ``` 24 | +--------------+ +--------------+ 25 | | SSH Client | | SSH Server | 26 | +-----+--+-----+ +-----+--+-----+ 27 | | ^ | ^ 28 | | | | | 29 | ssh | | ssh | | 30 | | | | | 31 | | | | | 32 | v | v | 33 | +-----+--+-----+ +------------+ +-----+--+-----+ 34 | |TBridge Client<----------> HTTP Proxy <---------->TBridge Server| 35 | +--------------+ +------------+ +--------------+ 36 | AES encrypted http 37 | ``` 38 | 39 | ## 安装与使用 40 | 41 | **安装:** 42 | 43 | `pip install -r requirements.txt` 44 | 45 | *注意:不要直接用pip install cherrypy安装cherrypy,在写该文档之时,cherrypy最新版与bottle最新版无法一起使用* 46 | 47 | **使用:** 48 | 49 | Server端需要在vps上运行 50 | 51 | ``` 52 | Twi1ight at Mac-Pro in ~/Code/TBridge (master) 53 | $ python server.py 54 | usage: python server.py port-for-client service-host service-port 55 | e.g. for ssh: python server.py 8089 localhost 22 56 | ``` 57 | 58 | `python server.py 8089 localhost 22` 59 | 60 | 这条命令会建立一个web服务器,监听在8089端口,后端连接的服务是本地的ssh服务,理论上也支持连接其他服务器,那样vps就纯粹是一个中转服务器了。 61 | 62 | Client端在本地运行 63 | 64 | ``` 65 | Twi1ight at Mac-Pro in ~/Code/TBridge (master) 66 | usage: python client.py listen-port server-url http-proxy 67 | e.g. python client.py 2222 http://12.34.56.78:8089/ http://proxy.yourcorp.com:8080 68 | then "ssh root@localhost -p 2222" will ssh to 12.34.56.78 69 | ``` 70 | 71 | `python client.py 2222 http://12.34.56.78:8089/ http://proxy.yourcorp.com:8080` 72 | 73 | 这条命令会在本地监听2222端口,指定TBridge的Server为[http://12.34.56.78:8089/](http://12.34.56.78:8089/),指定上网的代理服务器为[http://proxy.yourcorp.com:8080](http://proxy.yourcorp.com:8080) 74 | 75 | 然后用ssh客户端连接localhost:2222即可登录vps。 76 | 77 | 目前只测试过ssh协议,理论上支持其他协议。 78 | 79 | **注意:目前只支持单实例运行,即本地只允许一个客户端进行连接,且服务端只能同时处理一个客户端。** 80 | 81 | ## 设置 82 | 83 | 所有设置都在settings.py中,为躲避proxy分析与拦截,采用的都是常见的web资源名称,如果vps没有域名,可以将headers中的Host字段注释去掉,伪装成baidu(不要改成google,会被GFW切断连接的)。 84 | 85 | ```python 86 | route_to_init = 'underscore-min.js' #初始化服务器路径 87 | route_to_transport = 'jquery-3.2.0.min.js' #服务端接收数据路径 88 | route_to_shutdown = 'bootstrap.min.css' #关闭服务器连接路径 89 | js_template_file = 'css.min.js' #服务器返回数据时使用的混淆模板 90 | cookie_param_name = 'auth' #数据包发送时在Cookie中的变量名 91 | data_fragment_size = 2048 #数据包分片大小,需与代理最大限制保留一定空间,base64编码后可能会变长 92 | 93 | headers = { 94 | # 'Host': 'www.baidu.com', 95 | 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.2987.133 Safari/537.36' 96 | } 97 | 98 | encrypt_key = '1faf0589159c5c87f69a4ab17591249c' #AES加密使用的key 99 | ``` 100 | 101 | ## 计划 102 | 103 | - 服务器可从客户端配置 104 | - 服务器访问控制 105 | - 多客户端、多协议支持 -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # @Time : 2017/4/11 5 | # @Author : Twi1ight 6 | import re 7 | import socket 8 | import requests 9 | import sys 10 | import urlparse 11 | import urllib 12 | 13 | from settings import route_to_init, route_to_transport, route_to_shutdown, md5digest 14 | from settings import headers, cookie_param_name, data_fragment_size, encrypt, decrypt 15 | 16 | 17 | def send_and_recv(buf): 18 | data = encrypt(buf) 19 | print 'send data length', len(data) 20 | cookies = {cookie_param_name: urllib.quote(data)} 21 | response = http_request('GET', urlparse.urljoin(server_url, route_to_transport), cookies=cookies) 22 | ciphers = re.findall('"(.*?)"', response) 23 | ciphertext = ''.join(ciphers) 24 | digest = md5digest(ciphertext) 25 | if response[-len(digest):] != digest: 26 | print 'recv data digest error!' 27 | return '' 28 | print 'recv data length', len(ciphertext) 29 | return decrypt(ciphertext) 30 | 31 | 32 | def http_request(method, url, **kwargs): 33 | try: 34 | ret = requests.request(method, url, headers=headers, timeout=5, 35 | proxies=http_proxy, verify=False, **kwargs).content 36 | except Exception as e: 37 | print 'url request exception', str(e) 38 | ret = '' 39 | return ret 40 | 41 | 42 | def argparse(): 43 | if len(sys.argv) != 4: 44 | print 'usage: python client.py listen-port server-url http-proxy' 45 | print 'e.g. python client.py 2222 http://12.34.56.78:8089/ http://proxy.yourcorp.com:8080' 46 | print 'then "ssh root@localhost -p 2222" will ssh to 12.34.56.78' 47 | sys.exit() 48 | return int(sys.argv[1]), sys.argv[2], sys.argv[3] 49 | 50 | 51 | if __name__ == '__main__': 52 | listen_port, server_url, proxy_url = argparse() 53 | # listen_port, server_url, proxy_url = 2222, 'http://11.22.33.44:8089/', 'http://127.0.0.1:3128' 54 | http_proxy = {'http': proxy_url} 55 | socket = socket.socket() 56 | socket.bind(("127.0.0.1", listen_port)) 57 | socket.listen(1) 58 | shutdown = lambda: 'client exited, %s' % http_request('GET', urlparse.urljoin(server_url, route_to_shutdown)) 59 | while True: 60 | local, _ = socket.accept() 61 | print 'client accepted,', http_request('GET', urlparse.urljoin(server_url, route_to_init)) 62 | while True: 63 | try: 64 | buf = local.recv(data_fragment_size) 65 | except: 66 | print shutdown() 67 | break 68 | if len(buf) == 0: 69 | print shutdown() 70 | break 71 | resp = send_and_recv(buf) 72 | local.sendall(resp) 73 | -------------------------------------------------------------------------------- /css.min.js: -------------------------------------------------------------------------------- 1 | define(function(){if("undefined"==typeof window)return{load:function(e,t,n){n()}};var e=document.getElementsByTagName("head")[0],t=window.navigator.userAgent.match(/Trident\/([^ ;]*)|AppleWebKit\/([^ ;]*)|Opera\/([^ ;]*)|rv\:([^ ;]*)(.*?)Gecko\/([^ ;]*)|MSIE\s([^ ;]*)|AndroidWebKit\/([^ ;]*)/)||0,n=!1,r=!0;t[1]||t[7]?n=parseInt(t[1])<6||parseInt(t[7])<=9:t[2]||t[8]?r=!1:t[4]&&(n=parseInt(t[4])<18);var o={};o.pluginBuilder="./css-builder";var a,i,s,l=function(){a=document.createElement("style"),e.appendChild(a),i=a.styleSheet||a.sheet},u=0,d=[],c=function(e){u++,32==u&&(l(),u=0),i.addImport(e),a.onload=function(){f()}},f=function(){s();var e=d.shift();return e?(s=e[1],void c(e[0])):void(s=null)},h=function(e,t){if(i&&i.addImport||l(),i&&i.addImport)s?d.push([e,t]):(c(e),s=t);else{a.textContent='@import "'+e+'";';var n=setInterval(function(){try{a.sheet.cssRules,clearInterval(n),t()}catch(e){}},10)}},p=function(t,n){var o=document.createElement("link");if(o.type="text/css",o.rel="stylesheet",r)o.onload=function(){o.onload=function(){},setTimeout(n,7)};else var a=setInterval(function(){for(var e=0;e=3.0.8,<9.0.0 2 | bottle==0.12.13 3 | requests -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # @Time : 2017/4/11 5 | # @Author : Twi1ight 6 | import re 7 | import sys 8 | import socket 9 | import select 10 | import urllib 11 | 12 | from bottle import request, run, route, error, abort 13 | from copy import copy 14 | 15 | from settings import route_to_transport, route_to_init, route_to_shutdown, md5digest 16 | from settings import encrypt, decrypt, cookie_param_name, js_template_file, headers 17 | 18 | 19 | @error(404) 20 | @error(405) 21 | def error404(err): 22 | return '404 Not Found.' 23 | 24 | 25 | def invoke_service(data): 26 | sock.sendall(data) 27 | ret = '' 28 | while True: 29 | r, _, _ = select.select([sock], [], [], 0.1) 30 | if r: 31 | ret += sock.recv(4096) 32 | if len(ret) == 0: 33 | print 'service sock closed' 34 | break 35 | else: 36 | print 'service data length', len(ret) 37 | break 38 | return ret 39 | 40 | 41 | @route('/%s' % route_to_transport) 42 | def transport(): 43 | verify_useragent() 44 | raw = urllib.unquote(request.get_cookie(cookie_param_name, '')) 45 | if not raw: return 'no client data found' 46 | data = decrypt(raw) 47 | ret = invoke_service(data) 48 | ciphertext = encrypt(ret) 49 | # obfuscate data to js file 50 | body = copy(js_template) 51 | ciphers = splitn(ciphertext, len(placeholder)) 52 | for i in range(len(placeholder)): 53 | body = body.replace(placeholder[i], '"%s"' % ciphers[i], 1) 54 | return body + '//%s' % md5digest(ciphertext) 55 | 56 | 57 | @route('/%s' % route_to_init) 58 | def init(): 59 | global sock 60 | verify_useragent() 61 | shutdown() 62 | try: 63 | sock = socket.socket() 64 | sock.connect((service_host, service_port)) 65 | except Exception as e: 66 | return 'transport init failed: ', str(e) 67 | return 'transport inited.' 68 | 69 | 70 | @route('/%s' % route_to_shutdown) 71 | def shutdown(): 72 | verify_useragent() 73 | try: 74 | sock.close() 75 | except: 76 | pass 77 | return 'transport stopped.' 78 | 79 | 80 | def verify_useragent(): 81 | if request.get_header('user-agent', '') != headers['User-Agent']: 82 | abort(404) 83 | 84 | 85 | def get_template(filename): 86 | with open(filename) as f: 87 | template = f.read() 88 | return template 89 | 90 | 91 | def splitn(s, n): 92 | l = len(s) / n if len(s) % n == 0 else len(s) / n + 1 93 | array = [s[i:i + l] for i in xrange(0, len(s), l)] 94 | for _ in range(n - len(array)): 95 | array.append('') 96 | return array 97 | 98 | 99 | def argparse(): 100 | if len(sys.argv) != 4: 101 | print 'usage: python server.py port-for-client service-host service-port' 102 | print 'e.g. for ssh: python server.py 8089 localhost 22' 103 | sys.exit() 104 | return int(sys.argv[1]), sys.argv[2], int(sys.argv[3]) 105 | 106 | 107 | if __name__ == '__main__': 108 | webserv_port, service_host, service_port = argparse() 109 | # webserv_port, service_host, service_port = 8089, 'localhost', 22 110 | sock = None 111 | js_template = get_template(js_template_file) 112 | placeholder = re.findall('(".*?")', js_template) 113 | run(server='cherrypy', host='0.0.0.0', port=webserv_port, debug='debug') 114 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # @Time : 2017/4/11 5 | # @Author : Twi1ight 6 | import base64 7 | import hashlib 8 | 9 | from Crypto import Random 10 | from Crypto.Cipher import AES 11 | 12 | route_to_init = 'underscore-min.js' 13 | route_to_transport = 'jquery-3.2.0.min.js' 14 | route_to_shutdown = 'bootstrap.min.css' 15 | js_template_file = 'css.min.js' 16 | cookie_param_name = 'auth' 17 | data_fragment_size = 2048 18 | 19 | headers = { 20 | # 'Host': 'www.baidu.com', 21 | 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.2987.133 Safari/537.36' 22 | } 23 | 24 | # https://gist.github.com/swinton/8409454 25 | # encrypt settings 26 | encrypt_key = '1faf0589159c5c87f69a4ab17591249c' 27 | 28 | BS = AES.block_size 29 | pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS) 30 | unpad = lambda s: s[0:-ord(s[-1])] 31 | 32 | 33 | def encrypt(raw): 34 | raw = pad(raw) 35 | iv = Random.new().read(AES.block_size) 36 | cipher = AES.new(encrypt_key, AES.MODE_CBC, iv) 37 | return base64.b64encode(iv + cipher.encrypt(raw)) 38 | 39 | 40 | def decrypt(enc): 41 | enc = base64.b64decode(enc) 42 | iv = enc[:16] 43 | cipher = AES.new(encrypt_key, AES.MODE_CBC, iv) 44 | return unpad(cipher.decrypt(enc[16:])) 45 | 46 | 47 | def md5digest(raw): 48 | return hashlib.md5(raw).hexdigest() 49 | --------------------------------------------------------------------------------