用python写一个http代(和谐)理
简单展示和学习,清晰为主,不考虑效率。
本文简单讨论支持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