IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    Reverland (lhtlyy@gmail.com)发表于 2014-01-29 00:00:00
    love 0

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

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

    服务器返回了这些信息:

    • 服务器端用什么版本的什么话通信(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


沪ICP备19023445号-2号
友情链接