简单展示和学习,清晰为主,不考虑效率。
本文简单讨论支持GET/CONNECT方法的http代理。这两种可能是最常用的方法。GET请求用于大多数http请求,CONNECT请求负责处理https。
为了更加明晰,也没有使用requests或者httplib等其它模块,没有使用SocketServer和它的子类,因为我个人觉得从socket开始能有个更加清晰的理解。
看着玩吧。
如果真的要用一个Proxy,我会直接使用pytho中的twisted或者BaseHTTPServer,或者基于nodejs的。它们有着更好的设计和更高层次的抽象,当然,更全面的特性和更稳定、更高的性能。文末将给出相关资料与实现。
请准备好一台linux系统,安装好netcat, openssl和python解释器。目前我用的还是2.7。
基本原理
客户服务器模型
首要问题是:客户服务器之间如何通信。简单说来就是客户端发送请求,告诉服务器我要什么东西,服务器则告诉客户端想要的东西或者告诉客户端找不到。
首先,客户端比如你的浏览器要找到服务器,通常的做法是在浏览器地址栏输入你想寻找的服务器。至于怎么寻找,如何最后在你的客户端和服务器间建立连接这点不细说。总之最后的结果是,两者之间建立了一条可以互相通话的专有线路,就像两个打电话的人一样,电话已经接通。
接着,你的浏览器说,我想要什么什么东西,有什么什么要求。电话另一头的服务器听到后就回复它有没有什么东西,如果有返回个什么样的东西,然后把东西传给你的浏览器。
接受到从服务器传来的数据后,浏览器把一堆你看不懂的东西绘制到屏幕上,绘声绘色地显示给你。
就这么简单。详情请参考RFC 2616,这是第一手最好的资料。
下面让我么实际看看他们都怎么通话的
我们先看看不用代理时,浏览器向服务器发送了些什么。监听本地8888端口
~ ⮀ nc -lvp 8888
listening on [any] 8888 ...
connect to [127.0.0.1] from localhost [127.0.0.1] 56499
GET /index.html?haha=1&papa=2 HTTP/1.1 Host: localhost:8888 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Cookie: __utma=XXXXXXXXXXXXXXXXXXXXXXXXXx; __utmz=111x7x2x1.13x86x7x41.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none)
Connection: keep-alive
忽略无关紧要的细节,这就是传说中的HTTP头。注意,最后还得有个空行表示我说完了。它告诉服务器以下一些信息:
- 浏览器想做什么(
GET
) - 想要什么(
index.html?haha=1&papa=2
) - 说的什么版本的什么话(
HTTP/1.1
) - 要的东西在哪里(
Host
) - 浏览器的一些特征(
User-Agent:
) - 浏览器接收什么样的东西(
Accept
) - 浏览器可以接受什么样的人类语言(
Accept-Language
) - 浏览器能处理的压缩或编码方式(
Accept-Encoding
) - 其它信息(标识浏览器身份的
Cookie
和在通话完成后是否把电话挂掉的信息Connection
)
对于特定版本的HTTP协议1.1,除了前两行是必要的其它都是可选的。
我们再看看服务器返回的信息是啥样的。
~ ⮀ nc baidu.com 80
GET / HTTP/1.1
Host: baidu.com
HTTP/1.1 200 OK
Date: Mon, 03 Feb 2014 07:37:46 GMT
Server: Apache
Cache-Control: max-age=86400
Expires: Tue, 04 Feb 2014 07:37:46 GMT
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-4b4c7d90"
Accept-Ranges: bytes
Content-Length: 81
Connection: Keep-Alive
Content-Type: text/html
<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>
服务器返回了这些信息:
- 服务器端用什么版本的什么话通信(
HTTP/1.1
) - 浏览器请求的资源是否可获得(
200 OK
) - 还有其它细节用来表示时间,它的情况,浏览器应该怎么做,传送的消息是什么等等。
这就是传说中的HTTP响应头。一个空行之后是实际传送的数据。嗯,这里就是浏览器喜欢的html文本文件。浏览器接收后会将其解析渲染或执行对应操作。
嗯基本原理就是这样。
连接的建立
当我们谈互联网时,不得不说说什么是socket。
当然,还得知道互联网的分层架构。
然而暂时不要管什么是socket,反正它就存在在那里,整个互联网建立在socket通信之上,包括Unix系统的内部通信。
可以把它设想成一个通信管道或线路的入口。如何使用它呢?拿python示例:
import socket
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
我们先导入socket模块,然后建立了一种指定类型的socket,嗯,这里是支持IPv4上TCP连接的socket。
一个服务器应该这样,要先绑定,然后监听:
soc.bind("", 8888)
new_soc, address = soc.accept()
new_soc.recv(1024)
以上将socket绑定到本地(""
)的8888端口。这样,所有连接到本机8888端口的连接实际上都是通过这个socket连接了。
接着,开始监听,一旦有客户端连接本机8888端口,就返回它的地址(address)和一个新的socket。注意,服务器端socket并不进行通信,只监听连接并生成一个新的用来连接的socket。然后,可以通过这个新的socket和客户端通信。
客户端则比较简单:
soc.connect(localhost, 8888)
soc.send("GET / HTTP/1.1\r\nHost: baidu.com\r\n\r\n")
连接某个机器的某个端口后则可以通过socket进行通信
代理服务器
代理服务器,是服务器和客户端之间一个中间站。将客户端发送的请求转发给服务器,将服务器的响应转发给客户端。
当我们说到代理服务器,首先它是一个服务器。
有了上面的基础可以写出以下代码,更多细节参考Python的socket文档:
import socket
soc.bind("", 8888)
while True:
# 监听接入的连接
new_soc, address = soc.accept()
# 从socket读取数据
data = new_soc.recv(1024)
# 向socket发送数据
new_soc.send(data)
其次它是一个客户端,它要向服务器请求数据。
其次它是个web服务器,尽管它大部分数据只需要转发。但它应该能处理HTTP协议,只是不必什么都处理。下面将展示有哪些地方在转发时必须处理。
火狐在使用代理时的HTTP头
与不使用代理时有什么不同呢?
~ ⮀ nc -lvp 8000
listening on [any] 8000 ...
connect to [127.0.0.1] from localhost [127.0.0.1] 60601
GET http://baidu.com/ HTTP/1.1
Host: baidu.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Cookie: BAIDUID=×××××××××××××××:FG=1; BDUSS=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX; bid_1=XXXXXXXXXXXXXXXXXXX; MCITY=-XXXXXXXXX%3A
Connection: keep-alive
注意没,GET后面不是请求的文件的路径,而是整个URI。那么我们的代理服务器得把浏览器的请求改成路径再转发。
其次,我们不希望再转发给baidu.com的服务器之后服务器不断开连接而一直保持,我们希望它赶紧断开连接好让我们能干点其它事。
Connection: close
综上,一个简单的能处理GET请求的代理服务器应该能做到:
- 将浏览器请求的第一行中完整的URL(http://baidu.com/)替换成路径('/'),通常情况下,没有指定资源文件的情况下默认是`/index.html`。
- 将HTTP头中的Connection设置为close。
基本原理就是这样,嗯,多简单。
实现代(和谐)理
我们可以先写点什么验证我们的想法,
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import socket
import urlparse
HOST = '' # Symbolic name meaning all available interfaces
PORT = 8000 # Arbitrary non-privileged port
def server(host, port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
s.listen(500)
print "Serving at %s" % PORT
while 1:
try:
conn, addr = s.accept()
handle_connection(conn)
except KeyboardInterrupt:
print "Bye..."
break
def getline(conn):
line = ''
while 1:
buf = conn.recv(1)
if buf == '\r':
line += buf
buf = conn.recv(1)
if buf == '\n':
line += buf
return line
# elif buf == '':
# return
else:
line += buf
def get_header(conn):
'''
不包括\r\n
'''
headers = ''
while 1:
line = getline(conn)
if line is None:
break
if line == '\r\n':
break
else:
headers += line
return headers
def parse_header(raw_headers):
request_lines = raw_headers.split('\r\n')
first_line = request_lines[0].split(' ')
method = first_line[0]
full_path = first_line[1]
version = first_line[2]
print "%s %s" % (method, full_path)
(scm, netloc, path, params, query, fragment) \
= urlparse.urlparse(full_path, 'http')
# 如果url中有‘:’就指定端口,没有则为默认80端口
i = netloc.find(':')
if i >= 0:
address = netloc[:i], int(netloc[i + 1:])
else:
address = netloc, 80
return method, version, scm, address, path, params, query, fragment
def handle_connection(conn):
# 从socket读取头
req_headers = get_header(conn)
# 更改HTTP头
## 要没有HTTP头的话。。。
if req_headers is None:
return
method, version, scm, address, path, params, query, fragment = \
parse_header(req_headers)
path = urlparse.urlunparse(("", "", path, params, query, ""))
req_headers = " ".join([method, path, version]) + "\r\n" +\
"\r\n".join(req_headers.split('\r\n')[1:])
# 建立socket用以连接URL指定的机器
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# soc.settimeout(1)
# 尝试连接
try:
soc.connect(address)
except socket.error, arg:
conn.sendall("HTTP/1.1" + str(arg[0]) + " Fail\r\n\r\n")
conn.close()
soc.close()
else: # 若连接成功
# 把HTTP头中连接设置为中断
# 如果不想让火狐卡在那里不继续加载的话
if req_headers.find('Connection') >= 0:
req_headers = req_headers.replace('keep-alive', 'close')
else:
req_headers += req_headers + 'Connection: close\r\n'
# 发送形如`GET path/params/query HTTP/1.1`
# 结束HTTP头
req_headers += '\r\n'
soc.sendall(req_headers)
# 发送完毕, 接下来从soc读取服务器的回复
# 建立个缓冲区
data = ''
while 1:
try:
buf = soc.recv(8129)
data += buf
except:
buf = None
finally:
if not buf:
soc.close()
break
# 转发给客户端
conn.sendall(data)
conn.close()
if __name__ == '__main__':
server(HOST, PORT)
运行它并且将浏览器设置为使用该代理:
python socket-proxy.py
在本地建立一个web服务器实验:
~/Work/project/proxy/base_python ⮀ python -m SimpleHTTPServer 8888
在浏览器中访问http://localhost:8888
,成功列出当前目录。
你可以直接访问任何网站看看。渐渐会发现,我们的代理服务器虽然运行基本良好,一次却只能接受一个请求?非常低效。程序经常会阻塞在socket的读写上。
目前来说,提高效率有三种途径:
- 异步I/O
- 线程
- 进程
然而,本文暂不讨论如何提高效率。也许下回或某天会专门说说。我们接着再谈谈CONNECT代理实现原理。
可进行https连接的http代理
https是建立在SSL/TLS上的安全连接,不要在意它是什么,我们只谈及它做什么。
通过SSL/TLS建立点与点之间的连接不被窃听。我们要为https连接代理的话,代理服务器就只能帮助客户端和服务器建立一条安全的加密通道,然后仅仅将数据中转。由于是加密的数据流,代理服务器并不能理解是什么,只看到一堆加密后的字符。
HTTP协议规定了一种CONNECT方法,用来向服务器申请这种中转。具体过程我们可以自己试着访问https://google.com
看看,首先将浏览器代理设置为本地8000端口:
~ ⮀ nc -lvp 8000
listening on [any] 8000 ...
connect to [127.0.0.1] from localhost [127.0.0.1] 43263
CONNECT google.com:443 HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0
Proxy-Connection: keep-alive
Connection: keep-alive
Host: google.com
200 OK
��R����^��4G��>�<�N�R���D1kVg|X�lH��
���98���5� ���ED32��
��� ���A/��
-
google.com
▒
#3t
我们可以看到
- 客户端向代理服务器申请代理(
CONNECT google.com:443 HTTP/1.1
) - 代理服务器向客户端应答表示可以代理(
200 OK
) - 客户端开始发送数据,准备建立加密信道
剩下的工作应该由代理服务器继续。
- 代理服务器建立一条与服务器的socket连接,
- 代理服务器在服务器和客户端之间转发数据。
我们简单更改之前的简单脚本使之支持CONNECT(毫无设计的脚本风格写法……见笑):
def handle_connection(conn):
# 从socket读取头
req_headers = get_header(conn)
# 更改HTTP头
## 要没有HTTP头的话。。。
if req_headers is None:
return
method, version, scm, address, path, params, query, fragment = \
parse_header(req_headers)
if method == 'GET':
do_GET(conn,
req_headers,
address,
path,
params,
query,
method,
version)
elif method == 'CONNECT':
# 注意
address = (path.split(':')[0], int(path.split(':')[1]))
do_CONNECT(conn,
req_headers,
address)
def do_CONNECT(conn, req_headers, address):
# 建立socket用以连接URL指定的机器
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# soc.settimeout(4)
# 尝试连接
try:
soc.connect(address)
except socket.error, arg:
conn.sendall("/1.1" + str(arg[0]) + " Fail\r\n\r\n")
conn.close()
soc.close()
else: # 若连接成功
conn.sendall('HTTP/1.1 200 Connection established\r\n\r\n')
# 数据缓冲区
# 读取浏览器给出的消息
try:
while True:
# 从客户端读取数据,并转发给conn
data = conn.recv(99999)
soc.sendall(data)
# 从服务器读取回复,转发回客户端
data = soc.recv(999999)
conn.sendall(data)
except:
conn.close()
soc.close()
def do_GET(conn, req_headers, address, path, params, query, method, version):
path = urlparse.urlunparse(("", "", path, params, query, ""))
req_headers = " ".join([method, path, version]) + "\r\n" +\
"\r\n".join(req_headers.split('\r\n')[1:])
# 建立socket用以连接URL指定的机器
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# soc.settimeout(1)
# 尝试连接
try:
soc.connect(address)
except socket.error, arg:
conn.sendall("HTTP/1.1" + str(arg[0]) + " Fail\r\n\r\n")
conn.close()
soc.close()
else: # 若连接成功
# 把HTTP头中连接设置为中断
# 如果不想让火狐卡在那里不继续加载的话
if req_headers.find('Connection') >= 0:
req_headers = req_headers.replace('keep-alive', 'close')
else:
req_headers += req_headers + 'Connection: close\r\n'
# 发送形如`GET path/params/query HTTP/1.1`
# 结束HTTP头
req_headers += '\r\n'
soc.sendall(req_headers)
# 发送完毕, 接下来从soc读取服务器的回复
# 建立个缓冲区
data = ''
while 1:
try:
buf = soc.recv(8129)
data += buf
except:
buf = None
finally:
if not buf:
soc.close()
break
# 转发给客户端
conn.sendall(data)
conn.close()
在终端运行代理:
python socket-proxy.py
紧接着我们用openssl搭建一个简单的测试用https服务器。
首先生成私钥:
~/Work/project/proxy/base_python ⮀ openssl genrsa -out privkey.pem 1024
Generating RSA private key, 1024 bit long modulus
..++++++
...............................................++++++
e is 65537 (0x10001)
生成一个未签名的证书:
~/Work/project/proxy/base_python ⮀ openssl req -new -x509 -key privkey.pem -out cert.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:
把私钥和证书合在一起生成服务器能使用的文件:
~/Work/project/proxy/base_python ⮀ cat privkey.pem cert.pem > server.pem
建立测试https服务器
~/Work/project/proxy/base_python ⮀ openssl s_server -accept 8888 -cert server.pem -www
Using default temp DH parameters
ACCEPT
ACCEPT
ACCEPT
使用浏览器先直接访问,再试着用自己写的代理服务器访问下。bingo!It really works!
Last but not least
从头到尾,好像两句话就能讲清楚的原理竟然花了这么多笔墨去解释。
总之,如果想真的让代理“能用”,使用线程或异步I/O来实现是必然的。在以后的某天,大概会详细对各种从select到asyncio每个层面的异步来做个走马观花的简介。
参考资料
主要参考资料:
- socket — Low-level networking interface
- Simple SSL cert HOWTO
- RFC2616 Hypertext Transfer Protocol – HTTP/1.1
- RFC2817 Upgrading to TLS Within HTTP/1.1
- When should one use CONNECT and GET HTTP methods at HTTP Proxy Server?
- Openssl Documentation:s_server(1)
- HTTP Tunnel
- HTTPS
- Unable to load certificate in openssl
如果你想学习异步:
- The new python asyncio aka tulip
- How To Use Linux epoll with Python
- The C10K problem
呵呵,就这些吧。竟然死机了,还连死两次,已经好久不知道什么叫死机了,白添加半天链接vim自动保存一恢复反而恢复没了。
最近vim倒挺顺,也不卡也不闹,本来第一次司机恢复下恢复写的内容,结果尼玛还没保存又死机死机死机死机了。firefox不知道怎么回事就卡住然后就鼠标能动键盘都卡住。还有我打字时fcitx这么卡你爸妈知道么,没以前感觉智能无所谓,要不要敲个字等一秒再出来!!!
忽然顺了……我擦……
OT
- Twisted简介和异步编程入门
- 哈哈,成功完成python2 koans