计网实验A2:代理http服务器

计网实验A2:代理http服务器

实验A2,因为没有给出框架代码,所以就自己在网络上找了一些资料,参考了一部分开源项目的实现。然后自己重新实现了一个简单的可以进行http代理的proxy服务器,并且进行了性能的验证。

实验介绍

在该实验中,需要实现一个Web代理,该代理同时在多个Web客户端和Web服务器之间传递请求和数据。该实验的目的是熟悉Internet上最流行的应用程序协议之一,超文本传输协议(HTTP),并介绍Berkeley套接字API。完成实验后,学生应当能够配置Web浏览器以将个人代理服务器用作Web代理。

实验环境

一台Linux系统的机器,需要包含gcc、Makefile工具。

在这里我配置了自己的服务器进行,远端的proxy的载体,Host tclab_2080ti

HostName 172.30.2.2

User zaterval

Port 22

ProxyCommand ssh -W %h:%p za_macRev使用火狐浏览器作为http请求终端。

相关背景介绍

HTTP传输协议

超文本传输协议(HTTP)是用于Web上进行通信的协议:它定义Web浏览器如何从Web服务器请求资源以及服务器如何响应。为简单起见,在该实验中将处理HTTP协议的1.0版。HTTP通信以事务形式进行,其中事务由客户端向服务器发送请求,然后读取响应组成。 请求和响应消息共享一个通用的基本格式:

  • 初始行(请求或响应行)
  • 零个或多个头部行
  • 空行(CRLF)
  • 可选消息正文。

对于大多数常见的HTTP事务,协议归结为一系列相对简单的步骤:

首先,客户端创建到服务器的连接;然后客户端通过向服务器发送一行文本来发出请求。这请求行包HTTP方法(比如GET,POST、PUT等),请求URI(类似于URL),以及客户机希望使用的协议版本(比如HTTP/1.0);接着,服务器发送响应消息,其初始行由状态线(指示请求是否成功),响应状态码(指示请求是否成功完成的数值),以及推理短语(一种提供状态代码描述的英文消息组成);最后一旦服务器将响应返回给客户端,它就会关闭连接。

HTTP代理

通常,HTTP是客户端-服务器协议。 客户端(通常是Web浏览器)直接与服务器(Web服务器软件)进行通信。 在某些情况下,引入称为代理的中间实体可能会很有用。 从概念上讲,代理位于客户端和服务器之间。在最简单的情况下,客户端不是将请求直接发送到服务器,而是将其所有请求发送到代理。 然后,代理打开与服务器的连接,并传递客户端的请求。 代理从服务器接收答复,然后将该答复发送回客户端。 从这个角度来看,代理实际上同时扮演HTTP客户端(到远程服务器)和HTTP服务器(到初始客户端)两个角色。

使用代理的几种可能的原因:

  • 性能:通过保存所获取页面的副本,代理可以减少创建与远程服务器的连接的需求。这可以减少检索页面所涉及的总体延迟,尤其是在服务器位于远程或负载较重的情况下。
  • 内容过滤和转换:虽然在最简单的情况下,代理仅获取资源而不检查资源,但没有任何内容说明代理仅限于盲目的获取和提供文件。代理可以检查请求的URL并有选择地阻止对某些域的访问,重新格式化网页(例如,通过剥离图像以使页面更易于在手持式或其他资源有限的客户端上显示),或执行其他转换和过滤。
  • 隐私:通常,Web服务器记录所有传入的资源请求。此信息通常至少包括客户端的IP地址,他们正在使用的浏览器或其他客户端程序(称为用户代理),日期和时间以及所请求的文件。如果客户端不希望记录此个人身份信息,则通过代理路由HTTP请求是一种解决方案。来自使用同一代理的客户端的所有请求似乎都来自代理本身的IP地址和User-Agent,而不是单个客户端。如果许多客户端使用相同的代理(例如,整个企业或大学),则将特定的HTTP事务链接到单台计算机或个人将变得更加困难。

实验功能要求

基本功能

本实验的基本任务是构建一个Web代理,该代理能够接受HTTP请求,将请求转发到远程(原始)服务器,并将响应数据返回给客户端。 代理必须通过使用fork()系统调用为每个新的客户端请求派生一个进程来处理并发请求。 为了简单起见,本实验仅需学生实现GET方法。代理收到的所有其他请求方法应引发“未实现”(501)错误。

本实验须使用C语言完成。它应该使用gcc/g++编译并运行,产生一个称为proxy的二进制文件,该文件将其侦听的端口作为第一个参数。服务器可设置为知名的网址,代理使用DNS服务来获得服务器IP,同时不能预设客户端IP且客户端不能来自预先确定的IP。

监听

当代理启动时,它要做的第一件事就是建立一个套接字连接,用来监听传入的连接。代理应监听从命令行指定的端口,并等待传入的客户端连接。 每个新的客户端请求均被接受,并使用fork()生成一个新进程来处理该请求。代理可以创建的进程数应有合理的限制(例如100个)。 客户端连接后,代理应从客户端读取数据,随后检查格式正确的HTTP请求(本实验已提供解析HTTP请求行和标头的库)。具体来说,您将使用我们的库来确保代理接收包含有效请求行的请求。

void server_loop() {
    
    
    struct sockaddr_in client_addr;
    socklen_t addrlen = sizeof(client_addr);

    while (1) {
    
    
        client_sock = accept(server_sock, (struct sockaddr*)&client_addr, &addrlen);
        
        if (fork() == 0) {
    
     // 创建子进程处理客户端连接请求
            close(server_sock);
            handle_client(client_sock, client_addr);
            exit(0);
        }
        close(client_sock);
    }

}

解析库

本实验提供了一个解析库来对请求的标头进行字符串解析。该库位于框架代码中的proxy_parse.[c | h]中。该库可以将请求解析为一个名为ParsedRequest的结构,该结构具有诸如主机名(域名)和端口之类的字段。 它还将自定义标头解析为一组ParsedHeader结构,每个结构都包含与标头相对应的键和值。学生可通过键查找标题并进行修改。给定结构中的信息,该库还可以将标头重新编译为字符串。

解析URL

代理接收到有效的HTTP请求后,将需要解析请求的URL。代理至少需要三项信息:请求的主机和端口以及请求的路径。在此需要解析在请求行中指定的绝对URL,对此可使用解析库来做解析。若URL中指示的主机名未指定端口,则应使用默认的HTTP端口80。

从远端服务器获取数据

代理解析完URL后,随后与请求的主机建立连接(使用指定的远程端口,如果未指定,则使用默认值80),并发送HTTP请求。不管代理如何从客户端收到请求,其终以相对URL +主机头格式发送请求。

返回数据到客户端

接收到来自远程服务器的响应后,代理应通过相应的套接字将响应消息原样发送给客户端。

这里就是直接将数据进行转发。

测试代理程序

使用以下命令运行客户端:./proxy , port表示代理应该侦听的端口号。作为功能的基本测试,可尝试使用telnet请求页面:

如果代理运行正常,则终端屏幕会显示Baidu主页的标题和HTML。请注意,我们请求绝对URL(http://www.baidu.com/),而不仅仅是相对URL(/)。另外,尝试从两个不同的shell并发地请求一个使用telnet的页面。

这里,我做的处理是,如果请求是基于绝对URL的,那么我们就使用根目录进行转发。

配置Firefox浏览器以使用代理

在FireFox 10.X版中先设置好代理配置(主机名和端口);修改Firefox的代理协议修改为HTTP/1.0,具体操作如下:

(1)在标题栏中键入‘About:config’。

(2)在搜索/筛选栏中,键入‘network.http.Proxy’

(3)可看到:network.http.proxy.keepalive, network.http.proxy.pipelining,和network.http.proxy.version. 此时将keepalive设置为false,version修改为1.0,并确保pipelining设置为假。

套接字编程

使用Berkeley套接字库来构建代理(比如解析地址,建立连接,创建服务器套接字,通过连接进行通信等),并用fork,waitpid等函数来创建并管理多进程。

但是这里,没有使用框架,自己这里重新复现一遍内容。

总体设计

实际上我们的想法很简单,就是一个VPN的代理程序。我们只需要有一个程序,把收取到的所有的报文,都直接给转发到目标的地址上就好了。但是这里,我们没有使用实验提供的库进行内容的解析,所以我们就只能自己按照http请求报文的内容,进行解析并且返回内容。

这里,我们对于自己的代理服务器,也要支持一部分直接访问的工作,也就是要实现一部分简单的代理服务器的内容。这里结合后面的简单的web服务器的实验,我们在可以通过服务器直接访问,返回我们的基本信息。

详细设计

数据结构

#define BUF_SIZE 8192

#define READ  0
#define WRITE 1

#define DEFAULT_LOCAL_PORT          8080  
#define DEFAULT_REMOTE_PORT         8081 
#define SERVER_SOCKET_ERROR         -1
#define SERVER_SETSOCKOPT_ERROR     -2
#define SERVER_BIND_ERROR           -3
#define SERVER_LISTEN_ERROR         -4
#define CLIENT_SOCKET_ERROR         -5
#define CLIENT_RESOLVE_ERROR        -6
#define CLIENT_CONNECT_ERROR        -7
#define CREATE_PIPE_ERROR           -8
#define BROKEN_PIPE_ERROR           -9
#define HEADER_BUFFER_FULL          -10
#define BAD_HTTP_PROTOCOL           -11
#define MAX_HEADER_SIZE             8192

#define LOG(fmt...)  do {
      
       fprintf(stderr,"%s %s ",__DATE__,__TIME__); fprintf(stderr, ##fmt); } while(0)


char remote_host[128]; 
int remote_port; 
int local_port;
int server_sock; 
int client_sock;
int remote_sock;

char * header_buffer ;

enum 
{
    
    
    FLG_NONE = 0,       /* 正常数据流不进行编解码 */
    R_C_DEC = 1,        /* 读取客户端数据仅进行解码 */
    W_S_ENC = 2         /* 发送到服务端进行编码 */
};

static int io_flag;     /* 网络io的一些标志位 */
static int m_pid;       /* 保存主进程id */

在本次实验中,我们不定义与使用过多的数据结构,使用原生的方法进行处理。

这里我们要注意,就是我们的全局变量的话,在子进程之后

函数分析

函数定义预览,如下源码所示:

void            server_loop();
void            stop_server();
void            handle_client(int client_sock, struct sockaddr_in client_addr);
int             read_header(int fd, void * buffer);
void            forward_header(int destination_sock);
void            forward_data(int source_sock, int destination_sock);
ssize_t         readLine(int fd, void *buffer, size_t n);
void            extract_server_path(const char * header,char * output);
int             extract_host(const char * header);
void            rewrite_header();
int             send_data(int socket,char * buffer,int len );
int             receive_data(int socket, char * buffer, int len);
int             send_tunnel_ok(int client_sock);
void            hand_ZaProxy_info_req(int sock,char * header_buffer) ;
void            get_info(char * output);
const char *    get_work_mode();
int             create_connection();
int             create_server_socket(int port);
void            sigchld_handler(int signal);

首先我们是在main函数中,对传入的参数进行解析,配置好初级状态,然后进入start_server初始化服务器的基本内容,判断是否进入后台运行,然后进入服务器的监听状态。

每个程序必须要的对读入参数进行解析,获取本地处理的方式,是否对接收到的数据进行解密,是否将数据加密后进行发送,是否再后台以守护进程的方式进行运行,目标的地址和端口等等。首先是守护进程的实现,实际上没有做特别安全的处理,就是直接fork生成一个进程,子进程进行核心逻辑的进行。

void start_server(int daemon){
    
    
    //初始化全局变量
    header_buffer = (char *) malloc(MAX_HEADER_SIZE);

    signal(SIGCHLD, sigchld_handler); // 防止子进程变成僵尸进程

    if ((server_sock = create_server_socket(local_port)) < 0) {
    
     // start server
        LOG("Cannot run server on %d\n",local_port);
        exit(server_sock);
    }
   
    if(daemon) {
    
    
        pid_t pid;
        if((pid = fork()) == 0)
        {
    
    
            server_loop();
        } else if (pid > 0 ) 
        {
    
    
            m_pid = pid;
            LOG("zaproxy pid is: [%d]\n",pid);
            close(server_sock);
        } else {
    
    
            LOG("Cannot daemonize\n");
            exit(pid);
        }
    } else {
    
    
        server_loop();
    }
}

然后如上代码所示,我们的程序监听我们的目标端口,当我们监听到端口被访问的时候,生成一个子进程,对我们的访问进行处理。我们先读入http请求的header,也就是在第一个空行之前做读取。

server_loop是进程核心函数,监听访问的事件内容,当发生访问事件的时候也就是我们的端口被访问,我们的得到了客户端的套接字,我们在该函数中创建子进程并且调用handle_client函数进行报文的处理与转发。

我们首先读入的是header,首部的标志是一个空行进行划分,我们这样子就很容易地就得到了请求报文地首部内容。

int read_header(int fd, void * buffer)
{
    
    
    // bzero(header_buffer,sizeof(MAX_HEADER_SIZE));
    memset(header_buffer,0,MAX_HEADER_SIZE);
    char line_buffer[2048];
    char * base_ptr = header_buffer;

    for(;;)
    {
    
    
        memset(line_buffer,0,2048);

        int total_read = readLine(fd,line_buffer,2048);
        if(total_read <= 0)
        {
    
    
            return CLIENT_SOCKET_ERROR;
        }
        //防止header缓冲区蛮越界
        if(base_ptr + total_read - header_buffer <= MAX_HEADER_SIZE)
        {
    
    
           strncpy(base_ptr,line_buffer,total_read); 
           base_ptr += total_read;
        } else 
        {
    
    
            return HEADER_BUFFER_FULL;
        }

        //读到了空行,http头结束
        if(strcmp(line_buffer,"\r\n") == 0 || strcmp(line_buffer,"\n") == 0)
        {
    
    
            break;
        }

    }
    return 0;
}

之后对于我们的header做基本的解析,从中抽取出该次请求的方式,目标的地址,和对应的端口号。针对不同的请求方式做不同的修改。同时要特别注意,针对隧道操作,需要特别进行区分。原本这里是用库直接做解析地,但是这里我还是使用自己对于header地理解进行了处理,我们从header的各个标志类的分割符入手进行了处理,将对应的变量传到我们的全局变量里面去。

int extract_host(const char * header){
    
    

    char * _p = strstr(header,"CONNECT");  /* 在 CONNECT 方法中解析 隧道主机名称及端口号 */
    if(_p) {
    
    
        char * _p1 = strchr(_p,' ');

        char * _p2 = strchr(_p1 + 1,':');
        char * _p3 = strchr(_p1 + 1,' ');

        if(_p2) {
    
    
            char s_port[10];
            bzero(s_port,10);

            strncpy(remote_host,_p1+1,(int)(_p2  - _p1) - 1);
            strncpy(s_port,_p2+1,(int) (_p3 - _p2) -1);
            remote_port = atoi(s_port);

        } else {
    
    
            strncpy(remote_host,_p1+1,(int)(_p3  - _p1) -1);
            remote_port = 80;
        }   
        return 0;
    }

    char * p = strstr(header,"Host:");
    if(!p) {
    
    
        return BAD_HTTP_PROTOCOL;
    }
    char * p1 = strchr(p,'\n');
    if(!p1) {
    
    
        return BAD_HTTP_PROTOCOL; 
    }

    char * p2 = strchr(p + 5,':'); /* 5是指'Host:'的长度 */

    if(p2 && p2 < p1) {
    
    
        
        int p_len = (int)(p1 - p2 -1);
        char s_port[p_len];
        strncpy(s_port,p2+1,p_len);
        s_port[p_len] = '\0';
        remote_port = atoi(s_port);

        int h_len = (int)(p2 - p -5 -1 );
        strncpy(remote_host,p + 5 + 1  ,h_len); //Host:
        //assert h_len < 128;
        
        remote_host[h_len] = '\0';
    } else {
    
       
        int h_len = (int)(p1 - p - 5 -1 -1); 
        strncpy(remote_host,p + 5 + 1,h_len);
        //assert h_len < 128;
        remote_host[h_len] = '\0';
        remote_port = 80;
    }
    return 0;
}

现在我们获得了目标地址和端口,那么我们现在进行连接,与目标服务器进行连接我们调用了create_connection来实现。

这里我们调用了Linux标准网络库中的socket方法,创建了套接字,然后与远端进行连接。

int create_connection() {
    
    
    struct sockaddr_in server_addr;
    struct hostent *server;
    int sock;

    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
    
    
        return CLIENT_SOCKET_ERROR;
    }

    if ((server = gethostbyname(remote_host)) == NULL) {
    
    
        errno = EFAULT;
        return CLIENT_RESOLVE_ERROR;
    }
    LOG("======= forward request to remote host:%s port:%d ======= \n",remote_host,remote_port);
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    memcpy(&server_addr.sin_addr.s_addr, server->h_addr, server->h_length);
    server_addr.sin_port = htons(remote_port);

    if (connect(sock, (struct sockaddr *) &server_addr, sizeof(server_addr)) < 0) {
    
    
        return CLIENT_CONNECT_ERROR;
    }

    return sock;
}

我们现在在建立好连接之后,就得到了两个socket,一个是客户端的socket一个是远端服务器的socket,然后我们就进行双向的消息的转发,从客户端的消息直接转发给服务端,服务端的消息也是直接给客户端。

void forward_data(int source_sock, int destination_sock) {
    
    
    char buffer[BUF_SIZE];
    int n;

    while ((n = receive_data(source_sock, buffer, BUF_SIZE)) > 0) 
    {
    
     
        send_data(destination_sock, buffer, n); 
    }

    shutdown(destination_sock, SHUT_RDWR); 

    shutdown(source_sock, SHUT_RDWR); 
}

在发数据的时候,我们使用简单的加密算法来对我们的数据进行加密:

int send_data(int socket,char * buffer,int len)
{
    
    

    if(io_flag == W_S_ENC)
    {
    
    
        int i;
        for(i = 0; i < len ; i++)
        {
    
    
            buffer[i] ^= 1;
           
        }
    }

    return send(socket,buffer,len,0);
}

在这里我们分别建立一个线程,来处理从客户端到服务端的消息和从服务端到客户端的消息。如果是CONNECT方法的话,直接将HEADER进行转发,如果是其他方式的就要重写HEADER的内容,将我们的url转化为文件目录以及其他

    if (fork() == 0) {
    
     // 创建子进程用于从客户端转发数据到远端socket接口

        if(strlen(header_buffer) > 0 && ! ) 
        {
    
    
            forward_header(remote_sock); //普通的http请求先转发header
        } 
        
        forward_data(client_sock, remote_sock);
        exit(0);
    }

    if (fork() == 0) {
    
     // 创建子进程用于转发从远端socket接口过来的数据到客户端

        if(io_flag == W_S_ENC){
    
    
            io_flag = R_C_DEC; //发送请求给服务端进行编码,读取服务端的响应则进行解码
        } else if (io_flag == R_C_DEC)
        {
    
    
             io_flag = W_S_ENC; //接收客户端请求进行解码,那么响应客户端请求需要编码
        }

        if(is_http_tunnel){
    
    
            send_tunnel_ok(client_sock);
        } 

        forward_data(remote_sock, client_sock);
        exit(0);
    }

之后,我们就结束内容转发流程,终止子进程。

调试设计

对于多进程的程序来说,代码调试是比较复杂的一个内容,因为针对接受的报文处理来说,会比较容易出问题。所以这里我选择了,先是一次性搭建好,分模块进行测试,最后再进行整合的操作。(当然这里面还是有很多不确定的因素在里面,测试用例还是不够充分)

我们设计一下的方法进行测试:

使用我们的FireFox浏览器作为我们代理请求的发送端,测试我们的代理服务器对各种请求的测试性能,以及是否可以访问到目标网站。

运行结果

编译代码:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fbamhhmi-1678758188329)(…\实验截图\A2\2022-12-12 13_40_45-8203200527_郑梓昂_演示视频.png)]

监听事件:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gmM4dsiY-1678758188330)(…\实验截图\A2\2022-12-12 13_42_05-Movies & TV.png)]

配置服务器代理:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ybYqRX9m-1678758188330)(…\实验截图\A2\2022-12-12 13_41_29-Movies & TV.png)]

测试服务器的请求:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yR39lOac-1678758188331)(…\实验截图\A2\2022-12-12 13_42_45-Movies & TV.png)]

测试获取基本的配置信息:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RW7aiNyW-1678758188331)(…\实验截图\A2\2022-12-12 14_49_39-Movies & TV.png)]

思考与总结

困难与解决

实际上整个内容是比较复杂的,花费了比较多的时间,同时调试和debug对于这种网络类的小项目,尤其使用c语言构建的来说,还是比较复杂的,所以还是花了比较多的精力。但是在做的时候,实际上内容还是比较多的,但是网络上面的资源还是比较多的,所以有比较多的借鉴在里面,也学习到了许多的编程的技巧,和网络编程的方法在里面。

同时多进程的调试和debug是真的很麻烦,很难受,花了比较多的时间在这里面

同时,在本次的实验中,也算是自己完成了一个比较大的,且有一定实用意义的小项目,还是比较有感触和收获的。

心得与思考

本次计算机网络课程的实验令我受益匪浅。首先,面对纲领性的课程的学习,不仅仅要把书本上,课堂上的内容掌握,理解许多抽象的知识,还应该努力去了解其实际如何发挥作用的,在实践中去学习,印象非常深刻,而且对以后的学习会很有意义。其次,了解和尝试计算机网络相关编程的工具是非常有必要的,例如相关的软件,相关的库,相关的类,这样的学习可以帮助我们拓展知识面,虽然无法全面地掌握,但是有了粗略的了解之后,这一块的知识就在实际需要的时候被调动出来。本次实验就加强了我们的编程能力,让我拓展学习了许多课程相关的内容。最后,我通过本次实验发现计算机网络是一个有很多细节的研究方向,既需要全局的了解,又需要局部的精通,其中不乏很多可以继续提升的地方,也许我们能凭借自己的努力,达到更高的层次水平,为将来的计算机网络发展做出一些微薄的贡献,这会对整个人类社会产生很积极的影响。

猜你喜欢

转载自blog.csdn.net/interval_package/article/details/129517449