用python写一个http代(和谐)理

Published on Jan 29, 2014

简单展示和学习,清晰为主,不考虑效率。

本文简单讨论支持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