签到

05月06日
尚未签到

共有回帖数 0

    回望空城

    等级:
    [0] sentinel

    首先谴责下咱家万能的vim,竟然不支持中文。嘛,反正写程序又不用中文。

    这篇tutorial仅供快速入手。如果希望达到〔训练有素〕之程度,请一定要学习网络原理和经典的网络编程教材。
    推荐Tanenbaum的《计算机网络》,W.Stevens的《TCP/IP详解》(三卷)和《UNIX网络编程》(两卷)。

    在开始tutorial之前,请确保必要的开发环境已经部署好了。win的库不熟悉,略过。
    *nix下,请先高呼whereis gcc, whereis make, whereis perl
    如果都存在,也请尽量部署一种你熟悉/喜欢的数据库系统。一部分服务器的构建需要数据库(这比解析和维护CSV要实用很多)。
    我使用mysql, 如果你部署了其他软件,请修改代码中的DBI部分。(出于方便而不是性能考虑,大部分DBI是用perl实现的)

    另外,你可能需要一些诸如telnet, tcpdump和wireshark(旧一些的版本称作ethereal)之类的软件辅助分析/调试。
    在我所见过的Linux发布版本上,telnet都包括在了基本安装中。tcpdump, libpcap和wireshark很容易从安装源或者其他站点获得。
    强烈建议在了解协议的时候,使用tcpdump抓取报文进行细致的案例分析。这一强调来自W.Stevens.

    tutorial中的示例程序均使用BSD套接字。
    [1] 前导常识

    在涉及到〔编程〕之前,稍微提几个概念和模型。

    这篇tutorial的基本模型是TCP/IP协议族,但是只涉及应用层和传输层(也即协议栈最顶端两层)。
    你可以认为通讯模型像是下面这个样子:

      +---------+                +---------+
      | 应用层 |                | 应用层 |
      +---------+                +---------+
           |                          |
           |                          |
      +---------+                +---------+
      | 传输层 |-sock------sock-| 传输层 |
      +---------+                +---------+


    这里的应用层协议一般是开发者(好吧,就是你)自行规定,传输层协议可能用到TCP和UDP.

    [1.1] IP地址和端口

    这是两个经常提到,但是也经常不明就里的概念。这里的IP地址(下面简称IP,请不要与同名协议混淆)均指IPv4.

    很多时候我们并不会问:IP是什么?或者,一个简单的理解是〔IP标识主机〕。不过并非尽然。
    实际上,IP意味着〔通路〕,亦即到达一台主机的道路。主机可以同时拥有多个IP(以后也许会在STUN相关的部分里涉及到使用多IP的服务器)。

    〔端口〕则是一个对新人更为模糊的概念。实际上,报文到达主机之后自然地要发送给某个进程,具体给〔哪个〕进程的标识即是端口。
    举一个例子。一般的web服务器运行在80端口上(总有人喜欢用8080……无所谓)。发送给此服务器的报文会标明〔送往80端口〕,传输层负责完成实际的分发工作。

    如果还不是很清晰,请参考一本网络原理教材。

    [1.2] 传输层和套接字

    传输层不表。请自行查考。

    所谓〔套接字〕,写作socket, 读做〔插座〕,在上面的模型中位于传输层之间。数据通过套接字发送,经过链路到达另一方的套接字,递交给传输层...等等。
    有一点*nix背景的读者可以认为socket等同于一个文件描述符(如果不想理解得多么深刻),实际上*nix也是这么使用套接字的。

    套接字之间的通信使用IP:端口对标识。

    [1.3] 传输层协议:TCP和UDP

    具体内容不会涉及太多,请参考一本教材。推荐W.Stevens的TCP/IP详解。
    这里只是提及几个特点。

    TCP是面向连接、可靠的传输。〔面向连接〕意味着基于TCP的通信维持一对套接字之间的〔连接〕,通信主机的另一方总是固定的。
    可靠意味着数据是有序、无损的。但是这在传输大文件时并不能很好地保证。

    UDP相反,是无连接、不可靠的传输。基于UDP的通信中,套接字并不在乎通信另一方是谁,只要指定了信宿就可以发送报文。接受也是〔相似〕的道理。
    UDP只对数据作简单校验,理论上我们需要自行检查数据是否损坏。
    [2] 初步

    [2.0] 简单的调试提示

    假设读者在遇到简单Bug的时候懂得如何调试,在这里就不普及DEBUG常识了。
    有一点需要提醒读者:网络应用程序的初步测试可以在本机完成。IP地址127.0.0.1对应本机的环回接口(loopback, 在你的系统里可能称作接口lo0)。
    使用环回接口,可以在没有任何其他网络接入的情况下使用套接字进行通信。当然,环回接口的MTU比较大也请醒目。(对于快速入手来说,这不太重要)

    简单的程序使用gcc编译,但多文件的工程会写makefile(但不是automake, 仅仅手写一个直供make)。没有特定情况,我不会给出makefile.

    [2.1] 从功能划分的通讯模型

    很遗憾还是要在理论上继续绕。从功能(或者说,角色?)看来,通讯模型可以划分成下面两种形式:

    * 客户端-服务器模型。服务器在熟知的IP:端口上长期运行,客户端相对随意地启动/关闭,并向服务器发起通信。
    流行的浏览器-服务器模型也属于这一范畴。
    * peer-to-peer, 再或者大家耳熟能详的P2P. 用客户端-服务器模型比喻的话,每台主机既是客户端又是服务器,通信的发起方并不固定。

    方便起见,现阶段的讨论以客户端-服务器模型为主。
    [2.2] 使用BSD套接字

    如果下降到代码层次,一个BSD套接字是通过标准的UNIX文件描述符与其他程序通信的方法。表现出来的样子就是一个文件描述符int listener_fd这样。
    一个完整的套接字用下面的5个要素描述:(协议,本地地址,本地端口,远程地址,远程端口)。不过这个描述只是通用的说法,在下面会看到差别。

    用于网络通信的BSD套接字常用三种类型:

    * 流式套接字。提供可靠的、面向连接的数据流。这意味着一般通过TCP.
    流式套接字的客户端-服务器通讯形式可能像是这样:

    服务器: socket() - bind() - listen() - accept() - 阻塞,等待客户端连接 - 处理业务 - close()
    客户端: socker() - connect() - 收发报文 - close()

    收发报文的例程,一般使用send()和recv().

    * 数据报套接字。提供不可靠、无连接的数据报传输。这意味着一般通过UDP.
    数据报套接字的客户端-服务器通讯形式可能像是这样:

    服务器:socket - bind - recvfrom() - 阻塞,等待客户端报文 - 处理业务 - close()
    客户端:socker - 收发报文 - close()

    收发报文的例程,一般使用sendto()和recvfrom().


    * 原始套接字。允许对较低层协议如IP/ICMP进行访问,这里不做涉及(我说了只到传输层,大家keep out)。
    [2.2.1] 一个例子

    在具体介绍相关数据结构和API之前,先拿个例子混脸熟。注意示例去掉了绝大部分错误处理,但这些处理在真实的应用程序当中是高度关键的。
    (服务器程序需要〔长期运行〕——所谓的24 * 7——而不是两下子就宕机了)

    /* main.c */

    #include    "common.h"
    #include    "defines.h"


    int main(int argc, char *argv[]){

       int listen_fd, conn_fd;
       struct sockaddr_in server, client;

       int clilen = sizeof(struct sockaddr_in), addrlen = sizeof(struct sockaddr_in);

       pid_t childpid;

       char msg_recv[MAX_MSGLEN] = "";
       int recvlen = -1;

       listen_fd = socket(AF_INET, SOCK_STREAM, 0);
       
       server.sin_family = AF_INET;
       server.sin_port = htons(9000);
       server.sin_addr.s_addr = inet_addr("127.0.0.1");
       bzero(&(server.sin_zero), 8);

       bind(listen_fd, (struct sockaddr *)&server, addrlen);
       listen(listen_fd, BACKLOG);

       while(1){
           conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clilen);
           switch(childpid = fork()){
               case -1:
                   printf("fork() failed.n");
                   break;
               case 0:
                   close(listen_fd);
                   recvlen = recv(conn_fd, msg_recv, MAX_MSGLEN, 0);
                   send(conn_fd, msg_recv, strlen(msg_recv), 0);
                   exit(0);
               default:
                   close(conn_fd);
           }
       }

       exit(0);
    }  
    /* common.h */

    #ifndef        HAVE_COMMON_H
    #define        HAVE_COMMON_H

    #include    stdio.h
    #include    stdlib.h
    #include    string.h

    #include    unistd.h
    #include    sys/types.h
    #include    sys/stat.h
    #include    sys/socket.h
    #include    netinet/in.h
    #include    netdb.h

    #include    signal.h
    #include    fcntl.h

    #endif        /* HAVE_COMMON_H */

    /* defines.h */

    #ifndef        HAVE_DEFINES_H
    #define        HAVE_DEFINES_H

    #define        MAX_MSGLEN        1024
    #define        BACKLOG            5

    #endif        /* HAVE_DEFINES_H */

    这是一个简单的echo server或者练习缓冲区溢出的某个BUG程序。它在环回接口:9000端口上工作,从客户端接受一个连接请求,派生一个子进程读取客户端发来的字符串之后回送给客户端。请尽量对照代码和前面流式套接字的服务器通信形式。
    下面是一个实际的交互过程(假设服务器正在运行):

    varna@serv:~/workspace/documents/forumAround$ telnet 127.0.0.1 9000
    Trying 127.0.0.1...
    Connected to 127.0.0.1.
    Escape character is '^]'.
    Hello world!
    Hello world!
    Connection closed by foreign host.
    varna@serv:~/workspace/documents/forumAround$


    作为一个练习,请试图批评这个程序的缺陷。在后面的部分中,会要求读者改进这个程序,并添加一个客户端程序。
    同时,读者也可以使用tcpdump观察这一通信过程中的分组交换。
    [2.2.2] 套接字地址的表示

    套接字地址相关的数据结构定义于socket.h中:

    struct sockaddr{
       unsigned short sa_family;
       char sa_data[14];
    };

    其中sa_family代表地址族,网络通信时使用AF_INET(相对地,本地通信使用AF_UNIX,但这里不做介绍);sa_data包含了IP, 端口等等数据。
    实际上我们并没有用到这个结构,而是使用一个尺寸、意义相同,但重新划分了成员从而更易于理解的结构:

    struct sockaddr_in{
       short int sin_family;
       unsigned short int sin_port;
       struct in_addr sin_addr;
       unsigned char sin_zero[8];
    };

    sin_family对应sa_family;sin_port存储端口号(注意变换成网络字节顺序);sin_addr存储IP地址;sin_zero清零(这里用来和struct sockaddr对齐)。

    特别提一下struct in_addr(究竟是联合还是结构,我也没深究过):

    第一种可能的表示:

    typedef struct in_addr {
           union {
                   struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
                   struct { USHORT s_w1,s_w2; } S_un_w;
                   ULONG S_addr;
           } S_un;
    #define s_addr S_un.S_addr              /* can be used for most tcp & ip code */
    #define s_host S_un.S_un_b.s_b2         /* host on imp */
    #define s_net   S_un.S_un_b.s_b1         /* network */
    #define s_imp   S_un.S_un_w.s_w2         /* imp */
    #define s_impno S_un.S_un_b.s_b4         /* imp # */
    #define s_lh    S_un.S_un_b.s_b3         /* logical host */
    } IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

    第二种可能的表示:

    struct in_addr{
       unsigned long s_addr;
    };

    总之,这个联合/结构存储了4字节长的IP地址。
    下面的转换函数实现了主机字节顺序和网络字节顺序的互换。请一定使用这些函数,这样才会保证程序的可移植性。
    函数名字中的h代表主机(host),n代表网络(network),s代表短整型(short),l代表长整型(long)。
    比如,htons()表示〔将主机顺序转换成网络顺序,参数为短整型〕。其余不多解释,如有问题请高呼man.

    可能的组合是这些:htons(), htonl(), ntohs(), ntohl().

    另外的两个函数inet_addr()和inet_ntoa()完成IP地址的ASCII表示和内部表示之间的转换。ASCII表示都假设是点分十进制。
    inet_addr()将一个字符串转化成内部表示,比如server.sin_addr.s_addr = inet_addr("127.0.0.1");
    inet_ntoa()则将一个struct in_addr转化成点分十进制表示,比如printf("%sn", inet_ntoa(server.sin_addr));

    这里请小心鳄鱼。inet_addr()在出错时返回-1,但对于4字节的IP地址这意味着广播地址255.255.255.255, 请妥善处理返回值。
    [2.2.3] API提要

    下面列出了常用的API,和最简单的用法。这些API并不包括DNS解析方面的内容。

    socket() - 取得一个套接字描述符

    #include    sys/types.h
    #include    sys/socket.h

    int socket(int domain, int type, int protocol);
    一般而言,使用TCP/UDP的套接字设domain为AF_INET,type为SOCK_STREAM或SOCK_DGRAM(对应流式套接字和数据报套接字),protocol为0。
    调用成功时返回一个套接字描述符,发生错误时返回-1并设置errno.
    套接字的关闭使用close()(全关闭)或shutdown()(不介绍了,但是可以很恶毒)。

    bind() - 将套接字绑定到机器上一个IP:端口对,以便远程主机发起通信

    #include    sys/types.h
    #include    sys/socket.h

    int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
    sockfd是待绑定的套接字描述符,my_addr指向一个本地套接字地址结构(一般会是struct sockaddr_in,它给出了协议族、IP和端口),addrlen请设为本地struct sockaddr的长度。
    调用错误时返回-1并设置errno.

    connect() - 利用已有的(流式)套接字建立从本地客户端到服务器的连接

    #include    sys/types.h
    #include    sys/socket.h

    int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
    sockfd指明套接字,serv_addr给出远程服务器的套接字地址,addrlen应该是sizeof(struct sockaddr), 和之前的意义相仿。
    调用错误时返回-1并设置errno.

    listen() - 进行系统侦听请求,等待连接

    #include    sys/socket.h

    int listen(int sockfd, int backlog);
    sockfd指定进行侦听的套接字。backlog给出未经处理的连接请求队列可以容纳的最大数量,推荐的范围是5~10。
    调用错误时返回-1并设置errno.
    listen()之前,bind()是必要的。

    accept() - 从等待队列里取出连接,创建并返回用于此连接的套接字描述符

    #include    sys/types.h
    #include    sys/socket.h

    int accept(int sockfd, struct sockaddr *addr, int *addrlen);
    sockfd应当是侦听中的流式套接字;addr指向一个本地结构,用于保存远程计算机的套接字地址;addrlen指向一个本地整型,其中的数据应该是sizeof(struct sockaddr): 如果远程主机的套接字地址结构长度不同,则会改变此值以反映变化。
    调用错误时返回-1并设置errno,否则返回一个用于新连接的套接字。

    这里多说几句。listen() - accept()是TCP区分于UDP的一个表现:即TCP面向连接。这里可以看到,建立的新〔连接〕用accept()返回的套接字标明。
    如果有困惑,请参考TCP连接的建立过程。逻辑上也可以认为如果客户端占用了服务器唯一的侦听用套接字,这显然是很不合理的设置(天生的DoS本质么.DoS = Denial of Service)。
    一般的TCP服务器采用并发结构,和TCP面向连接的特性直接相关。无论如何,每个工作进程应该处理一个连接,而父进程总是用于侦听,不会被长期占用以致DoS。
    顺便请思考如何实现简单的DoS.后面会作为练习提出。
    send() / recv() - 使用流式套接字发送/接收数据流

    #include    sys/types.h
    #include    sys/socket.h

    int send(int sockfd, const void *msg, int len, unsigned int flags);
    sockfd应当是一个accept()返回的连接用套接字;msg为需要发送的消息,len为其长度。flags在简单的情形下可以设为0,具体内容请参考man.
    调用错误时返回-1并设置errno. 成功的调用返回已发送消息的长度:当数据太多时,send()只发送它所能处理的最大数据长度,并且认为调用者会再次调用它发送剩下的数据。保证数据全部完整地发送出去的责任在于编程者,请慎重处理(除非套接字被设置为非阻塞模式)。

    int recv(int sockfd, void *buf, int len, unsigned int flags);
    与send()基本对称,差别在于buf用来接收数据,而len是buf这一缓存的(最大)长度。
    调用错误时返回-1并设置errno,成功的调用返回已接受消息的长度。同样存在数据太多时只接受一部分的情况,请反复调用以获得全部数据。

    sendto() / recvfrom() - 使用套接字发送/接收数据报

    #include    sys/types.h
    #include    sys/socket.h

    int sendto(int sockfd, const void *msg, int len, unsigned flags, const struct sockaddr *to, int tolen);
    int recvfrom(int sockfd, void *buf, int len, unsigned flags, struct sockaddr *from ,int *fromlen);
    前四个参数和send()/recv()含义基本一致,差别在于sockfd可以是(一般是)数据报套接字。
    to/tolen, from/fromlen分别指明了远程主机的套接字地址和地址结构长度。sendto()时需要完整指定远程地址,recvfrom()则使用一个本地结构存储远程地址,fromlen预先设为sizeof(struct sockaddr)。请类比connect()和accept()。
    返回值的含义和send()/recv()相同。



    上面是常用的API. 利用这些结构和API,很容易读懂和写出2.2.1中的服务器程序。请自行尝试编写和调试。
    如果感觉API略为繁多,可以试着记下主要的功能,实际写程序的时候再查阅手册/文档,逐步加深印象。

    [2.3] 练习

    这里列出了一部分练习和需要思考的问题。

    A.请结合对API的了解,尽可能为2.2.1中的服务器程序添加错误处理代码,并编写一个对应的客户端程序。
    B.抛开错误处理和缓冲区溢出的BUG不谈,我们认定2.2.1中的程序存在架构上的问题。请指出这一问题,并重写一个程序作出改进。
    C*.拒绝服务GONGJI(Denial of Service, DoS)希望通过某种手段使得服务器无法对客户端的请求作出响应。针对在B题中所写的服务器程序,尝试设计一个DoS Attack方案。
    D.了解并部署一种web服务器,尽可能分析浏览器访问本地网页过程中的分组交换。
    E.如果要实现一个实时通讯(Instant Message)软件(比如QQ, MSN, Hi),你认为客户端/服务器各需要选择什么样的传输层协议?

    楼主 2015-12-05 13:53 回复

共有回帖数 0
  • 回 帖
  • 表情 图片 视频
  • 发表

登录直线网账号

Copyright © 2010~2015 直线网 版权所有,All Rights Reserved.沪ICP备10039589号 意见反馈 | 关于直线 | 版权声明 | 会员须知