SO2 实验 10——网络¶
实验目标¶
- 理解 Linux 内核网络架构
- 掌握使用数据包(packet)过滤器或防火墙进行 IP 数据包管理
- 熟悉在 Linux 内核级别使用套接字的方法
概述¶
互联网的发展导致网络应用程序呈指数增长,因此操作系统的网络子系统对速度和生产力的要求也在不断增加。网络子系统并非操作系统内核的必需组件(Linux 内核可以选择在编译时不包含网络支持)。然而,由于对连接的需要,计算机系统(甚至嵌入式设备)很少会使用不支持网络的操作系统。现代操作系统使用 TCP/IP 协议栈。它们的内核实现了传输层以下的协议,而应用层协议通常在用户空间实现(如 HTTP、FTP 以及 SSH 等)。
用户空间中的网络编程¶
套接字(socket)是在用户空间中,对于网络通信的抽象。套接字抽象了通信通道,也是基于内核的 TCP/IP 栈交互接口。IP 套接字与 IP 地址、所使用的传输层协议(如 TCP、UDP 等)和端口相关联。常用的使用套接字的函数调用有:创建 (socket
)、初始化 (bind
)、连接 (connect
)、等待连接 (listen
, accept
) 以及关闭套接字 (close
)。
我们通过 read
/write
或 recv
/send
调用实现 TCP 套接字的网络通信,通过 recvfrom
/sendto
调用实现 UDP 套接字的网络通信。传输和接收操作对应用程序来说是透明的,封装和网络传输由内核自行决定。然而,也可以使用原始套接字(创建套接字时使用 PF_PACKET
选项)在用户空间中实现 TCP/IP 栈,或者在内核中实现应用层协议(例如 TUX web 服务器)。
有关使用套接字进行用户空间编程的更多详细信息,请参阅 Beej 的网络套接字编程指南。
Linux 网络编程¶
Linux 内核提供了三种基本的用于处理网络数据包的结构:struct socket
、struct sock
和 struct sk_buff
。
前两者是对套接字的抽象:
struct socket
是非常接近用户空间的抽象,即用于编写网络应用程序的 BSD 套接字;struct sock
或 Linux 术语中的 INET 套接字 是套接字的网络表示。
这两个结构有关联: struct socket
包含 INET 套接字字段,而每个 struct sock
都有一个 BSD 套接字持有它。
struct sk_buff
结构是网络数据包及其状态的表示。当从用户空间或网络接口接收到内核数据包时,该结构被创建。
struct socket
结构¶
struct socket
结构是 BSD 套接字的内核表示,可以执行在它上面执行的操作类似于内核提供的操作(通过系统调用)。使用套接字的常见操作(创建、初始化/绑定、关闭等)会导致特定的系统调用;它们与 struct socket
结构一起工作。
struct socket
的操作在 net/socket.c
中进行描述,且与协议类型无关。因此, struct socket
结构是特定网络操作实现的通用接口。通常,这些操作的名称以 sock_
前缀开头。
对 socket 结构的操作¶
socket 相关操作包括:
创建¶
创建类似于在用户空间调用 socket()
函数,但创建的 struct socket
将存储在 res
参数中:
int sock_create(int family, int type, int protocol, struct socket **res)
:在socket()
系统调用之后创建 socket;int sock_create_kern(struct net *net, int family, int type, int protocol, struct socket **res)
:创建内核 socket;int sock_create_lite(int family, int type, int protocol, struct socket **res)
:创建内核 socket, 不经过参数完整性检查。
这些调用的参数如下:
net
(如果存在) 用作对所使用的网络命名空间的引用;通常我们会使用init_net
进行初始化;family
表示在信息传输中使用的协议族;它们通常以PF_
(协议族) 字符串开头;表示所使用的协议族的常量可以在linux/socket.h
中找到,其中最常用的是PF_INET
, 用于 TCP/IP 协议;type
是 socket 的类型;用于此参数的常量可以在linux/net.h
中找到,其中最常用的是SOCK_STREAM
(用于基于连接的源到目的地通信) 以及SOCK_DGRAM
(用于无连接通信);protocol
表示使用的协议,与type
参数密切相关;用于此参数的常量可以在linux/in.h
中找到,其中最常用的是IPPROTO_TCP
(用于 TCP),IPPROTO_UDP
(用于 UDP)。
要在内核空间中创建 TCP socket,你需要调用:
struct socket *sock;
int err;
err = sock_create_kern(&init_net, PF_INET, SOCK_STREAM, IPPROTO_TCP, &sock);
if (err < 0) {
/* 处理错误 */
}
要在内核空间中创建 UDP socket,你需要调用:
struct socket *sock;
int err;
err = sock_create_kern(&init_net, PF_INET, SOCK_DGRAM, IPPROTO_UDP, &sock);
if (err < 0) {
/* 处理错误 */
}
一个使用示例是 sys_socket()
系统调用处理程序的一部分:
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
int retval;
struct socket *sock;
int flags;
/* 检查 SOCK_* 常量是否一致。 */
BUILD_BUG_ON(SOCK_CLOEXEC != O_CLOEXEC);
BUILD_BUG_ON((SOCK_MAX | SOCK_TYPE_MASK) != SOCK_TYPE_MASK);
BUILD_BUG_ON(SOCK_CLOEXEC & SOCK_TYPE_MASK);
BUILD_BUG_ON(SOCK_NONBLOCK & SOCK_TYPE_MASK);
flags = type & ~SOCK_TYPE_MASK;
if (flags & ~(SOCK_CLOEXEC | SOCK_NONBLOCK))
return -EINVAL;
type &= SOCK_TYPE_MASK;
if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;
retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
goto out;
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
关闭连接¶
关闭连接(对于使用连接的 socket)并释放相关资源:
void sock_release(struct socket *sock)
调用 socket 结构ops
字段中的release
函数:
void sock_release(struct socket *sock)
{
if (sock->ops) {
struct module *owner = sock->ops->owner;
sock->ops->release(sock);
sock->ops = NULL;
module_put(owner);
}
//...
}
发送/接收消息¶
使用以下函数来发送/接收消息:
int sock_recvmsg(struct socket *sock, struct msghdr *msg, int flags);
int kernel_recvmsg(struct socket *sock, struct msghdr *msg, struct kvec *vec, size_t num, size_t size, int flags);
int sock_sendmsg(struct socket *sock, struct msghdr *msg);
int kernel_sendmsg(struct socket *sock, struct msghdr *msg, struct kvec *vec, size_t num, size_t size);
消息的发送/接收函数将调用 socket ops
字段中的 sendmsg
/recvmsg
函数。当 socket 在内核中使用时,应使用以 kernel_
为前缀的函数。
参数包括:
msg
,struct msghdr
结构,包含要发送/接收的消息。该结构的重要组成部分包括msg_name
和msg_namelen
,对于 UDP 套接字,必须使用目标地址填充 (struct sockaddr_in
);vec
,struct kvec
结构,其中有一个指针指向缓冲区,缓冲区内包含该struct kvec
结构的数据和大小;正如所见,它的结构类似于struct iovec
结构 (struct iovec
结构对应用户空间数据,而struct kvec
结构对应内核空间数据)。
可以在 sys_sendto()
系统调用处理程序中看到用法示例:
SYSCALL_DEFINE6(sendto, int, fd, void __user *, buff, size_t, len,
unsigned int, flags, struct sockaddr __user *, addr,
int, addr_len)
{
struct socket *sock;
struct sockaddr_storage address;
int err;
struct msghdr msg;
struct iovec iov;
int fput_needed;
err = import_single_range(WRITE, buff, len, &iov, &msg.msg_iter);
if (unlikely(err))
return err;
sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (!sock)
goto out;
msg.msg_name = NULL;
msg.msg_control = NULL;
msg.msg_controllen = 0;
msg.msg_namelen = 0;
if (addr) {
err = move_addr_to_kernel(addr, addr_len, &address);
if (err < 0)
goto out_put;
msg.msg_name = (struct sockaddr *)&address;
msg.msg_namelen = addr_len;
}
if (sock->file->f_flags & O_NONBLOCK)
flags |= MSG_DONTWAIT;
msg.msg_flags = flags;
err = sock_sendmsg(sock, &msg);
out_put:
fput_light(sock->file, fput_needed);
out:
return err;
}
struct socket
字段¶
/**
* struct socket——通用的 BSD socket
* @state: socket 状态(%SS_CONNECTED 等)
* @type: socket 类型(%SOCK_STREAM 等)
* @flags: socket 标志(%SOCK_NOSPACE 等)
* @ops: 协议特定的 socket 操作
* @file: 反向指向 file 的指针,用于垃圾回收
* @sk: 内部的网络协议无关 socket 表示
* @wq: 有多种用途的等待队列
*/
struct socket {
socket_state state;
short type;
unsigned long flags;
struct socket_wq __rcu *wq;
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
值得注意的字段包括:
ops
——该结构内部有指针,指针指向协议特定函数;sk
——与之关联的INET socket
。
struct proto_ops
结构¶
struct proto_ops
结构体包含了特定操作(TCP、UDP 等)的实现;这些函数将通过通用函数(如 sock_release()
, sock_sendmsg()
等)使用 struct socket
为参数来调用。
因此, struct proto_ops
结构体包含了一些特定协议实现的函数指针:
struct proto_ops {
int family;
struct module *owner;
int (*release) (struct socket *sock);
int (*bind) (struct socket *sock,
struct sockaddr *myaddr,
int sockaddr_len);
int (*connect) (struct socket *sock,
struct sockaddr *vaddr,
int sockaddr_len, int flags);
int (*socketpair)(struct socket *sock1,
struct socket *sock2);
int (*accept) (struct socket *sock,
struct socket *newsock, int flags, bool kern);
int (*getname) (struct socket *sock,
struct sockaddr *addr,
int peer);
//...
}
struct socket
结构体的 ops
字段的初始化是在 __sock_create()
函数中完成的,该函数通过调用针对每个协议的 create()
函数来实现;等效调用是 __sock_create()
函数的实现:
//...
err = pf->create(net, sock, protocol, kern);
if (err < 0)
goto out_module_put;
//...
这将使用与 socket 关联的协议类型特定的调用来实例化函数指针。 sock_register()
和 sock_unregister()
调用用于填充 net_families
向量。
对于 socket 结构的其余操作(除了在 对 socket 结构的操作 部分中描述的创建、关闭和发送/接收消息之外),将调用通过这个结构中的指针传递的函数。例如,对于 bind
操作(它将一个 socket 与一个本地机器上的 socket 关联)有以下代码:
#define MY_PORT 60000
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons (MY_PORT),
.sin_addr = { htonl (INADDR_LOOPBACK) }
};
//...
err = sock->ops->bind (sock, (struct sockaddr *) &addr, sizeof(addr));
if (err < 0) {
/* 处理错误 */
}
//...
在以上代码中,用于传输与 socket 关联的地址和端口信息的是 struct sockaddr_in
结构体。
struct sock
结构¶
struct sock
结构描述了 INET
套接字。这样的结构与用户空间的 socket 相关联,并且与 struct socket
结构相关联,其中与 struct socket
结构的关联是隐式的。该结构用于存储关于连接状态的信息。结构体的字段和相关操作通常以 sk_
字符串开头。以下列出了一些字段:
struct sock {
//...
unsigned int sk_padding : 1,
sk_no_check_tx : 1,
sk_no_check_rx : 1,
sk_userlocks : 4,
sk_protocol : 8,
sk_type : 16;
//...
struct socket *sk_socket;
//...
struct sk_buff *sk_send_head;
//...
void (*sk_state_change)(struct sock *sk);
void (*sk_data_ready)(struct sock *sk);
void (*sk_write_space)(struct sock *sk);
void (*sk_error_report)(struct sock *sk);
int (*sk_backlog_rcv)(struct sock *sk,
struct sk_buff *skb);
void (*sk_destruct)(struct sock *sk);
};
sk_protocol
是套接字使用的协议类型;sk_type
是套接字类型 (SOCK_STREAM
,SOCK_DGRAM
等);sk_socket
是持有该套接字的 BSD 套接字;sk_send_head
是用于传输的struct sk_buff
结构列表;- 最后的函数指针是用于不同情况的回调函数。
使用从 net_families
创建的回调函数(称为 __sock_create()
) 来初始化 struct sock
并将其附加到 BSD 套接字。以下是在 inet_create()
函数中初始化 IP 协议的 struct sock
结构体的方法:
/*
* 创建 inet 套接字。
*/
static int inet_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
struct sock *sk;
//...
err = -ENOBUFS;
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
if (!sk)
goto out;
err = 0;
if (INET_PROTOSW_REUSE & answer_flags)
sk->sk_reuse = SK_CAN_REUSE;
//...
sock_init_data(sock, sk);
sk->sk_destruct = inet_sock_destruct;
sk->sk_protocol = protocol;
sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;
//...
}
struct sk_buff
结构体¶
struct sk_buff
(套接字缓冲区)描述了一个网络数据包。该结构的字段包含有关报头和数据包内容、使用的协议、使用的网络设备以及指向其他 struct sk_buff
的指针的信息。下面是该结构体的内容的概要描述:
struct sk_buff {
union {
struct {
/* 这两个成员必须放在最前面。 */
struct sk_buff *next;
struct sk_buff *prev;
union {
struct net_device *dev;
/* 一些协议可能会使用此空间来存储信息,此种情形下设备指针为 NULL。
* UDP 接收路径就是其中之一。
*/
unsigned long dev_scratch;
};
};
struct rb_node rbnode; /* 在 netem 和 tcp 栈中使用 */
};
struct sock *sk;
union {
ktime_t tstamp;
u64 skb_mstamp;
};
/*
* 这是控制缓冲区。每一层都可以自由使用它。
* 请将你的私有变量放在这里。如果要在多个层之间保留这些变量,首先必须进行 skb_clone()。
* 此缓冲区由当前此 skb 的队列的所有者拥有。
*/
char cb[48] __aligned(8);
unsigned long _skb_refdst;
void (*destructor)(struct sk_buff *skb);
union {
struct {
unsigned long _skb_refdst;
void (*destructor)(struct sk_buff *skb);
};
struct list_head tcp_tsorted_anchor;
};
/* ... */
unsigned int len,
data_len;
__u16 mac_len,
hdr_len;
/* ... */
__be16 protocol;
__u16 transport_header;
__u16 network_header;
__u16 mac_header;
/* 私有:*/
__u32 headers_end[0];
/* 公有:*/
/* 这些元素必须放在最后。有关详细信息,请参阅 alloc_skb()。*/
sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head,
*data;
unsigned int truesize;
refcount_t users;
};
其中:
next
和prev
是指向缓冲区列表中下一个和前一个元素的指针;dev
是发送或接收缓冲区内容的设备;sk
是与缓冲区相关联的套接字;destructor
是负责释放缓冲区的回调函数;transport_header
,network_header
和mac_header
是数据包起始位置和各个头部起始位置之间的偏移量。它们由数据包经过的各个处理层内部维护。要获取指向头部的指针,请使用以下函数之一:tcp_hdr()
,udp_hdr()
以及ip_hdr()
等。原则上,每个协议都对应一个函数。这些函数用于在接收到的数据包中,获取对该协议的头部的引用。请注意,当数据包到达网络层时,network_header
字段才被设置;而当数据包到达传输层时,transport_header
字段才被设置。
IP 头部 的结构 (struct iphdr
) 包含以下字段:
struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ihl:4,
version:4;
#elif defined (__BIG_ENDIAN_BITFIELD)
__u8 version:4,
ihl:4;
#else
#error "Please fix <asm/byteorder.h>"
#endif
__u8 tos;
__be16 tot_len;
__be16 id;
__be16 frag_off;
__u8 ttl;
__u8 protocol;
__sum16 check;
__be32 saddr;
__be32 daddr;
/* 可选项由此开始 */
};
其中:
protocol
是使用的传输层协议;saddr
是源 IP 地址;daddr
是目标 IP 地址。
TCP 头部 的结构 (struct tcphdr
) 具有以下字段:
struct tcphdr {
__be16 source;
__be16 dest;
__be32 seq;
__be32 ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16 res1:4,
doff:4,
fin:1,
syn:1,
rst:1,
psh:1,
ack:1,
urg:1,
ece:1,
cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16 doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
__be16 window;
__sum16 check;
__be16 urg_ptr;
};
其中:
source
是源端口;dest
是目标端口;- 常使用的 TCP 标志包括
syn
,ack
以及fin
;要想对其有更详细地了解,请参见此 图表。
UDP 头部 的结构(struct udphdr
)具有以下字段:
struct udphdr {
__be16 source;
__be16 dest;
__be16 len;
__sum16 check;
};
其中:
source
是源端口;dest
是目标端口。
访问网络数据包头部中的信息的示例如下:
struct sk_buff *skb;
struct iphdr *iph = ip_hdr(skb); /* IP 头部 */
/* iph->saddr - 源 IP 地址 */
/* iph->daddr - 目标 IP 地址 */
if (iph->protocol == IPPROTO_TCP) { /* TCP 协议 */
struct tcphdr *tcph = tcp_hdr(skb); /* TCP 头部 */
/* tcph->source —— 源 TCP 端口 */
/* tcph->dest —— 目标 TCP 端口 */
} else if (iph->protocol == IPPROTO_UDP) { /* UDP 协议 */
struct udphdr *udph = udp_hdr(skb); /* UDP 头部 */
/* udph->source —— 源 UDP 端口 */
/* udph->dest —— 目标 UDP 端口 */
}
转换¶
不同系统的字,有多种字节的顺序方式 (字节序),包括: 大端序 (最高位字节在前) 和 小端序 (最低位字节在前)。由于网络连接了具有不同平台的系统,因此互联网对于数值数据的存储已经强加了一种标准序列,称为 网络字节序。相反,主机计算机上,表示数值数据的字节序列称为主机字节序。从网络接收/发送的数据采用网络字节序格式,并且应该在该格式和主机字节序之间进行转换。
为了进行转换,我们使用以下宏:
u16 htons(u16 x)
将 16 位整数从主机字节序转换为网络字节序(主机到网络短整数);u32 htonl(u32 x)
将 32 位整数从主机字节序转换为网络字节序(主机到网络长整数);u16 ntohs(u16 x)
将 16 位整数从网络字节序转换为主机字节序(网络到主机短整数);u32 ntohl(u32 x)
将 32 位整数从网络字节序转换为主机字节序(网络到主机长整数)。
netfilter¶
Netfilter 是一个内核接口,用于捕获网络数据包以对其进行修改/分析(用于过滤、NAT 等)。在用户空间中,由 iptables 使用 netfilter 接口。
在 Linux 内核中,使用 netfilter 进行数据包捕获是通过附加钩子(hook)来实现的。钩子可以在内核网络数据包所经过的路径的不同位置指定,你可以根据需要进行配置。你可以在 这里 找到一张组织图,组织图上显示数据包所经过的路径以及钩子可能出现的区域。
使用 netfilter 时包含的头文件是 linux/netfilter.h
。
钩子通过 struct nf_hook_ops
结构体进行定义:
struct nf_hook_ops {
/* 用户从这里开始填写。*/
nf_hookfn *hook;
struct net_device *dev;
void *priv;
u_int8_t pf;
unsigned int hooknum;
/* 钩子按优先级升序排列。*/
int priority;
};
其中:
pf
是数据包类型 (PF_INET
等);priority
是优先级;优先级在uapi/linux/netfilter_ipv4.h
中定义如下:
enum nf_ip_hook_priorities {
NF_IP_PRI_FIRST = INT_MIN,
NF_IP_PRI_CONNTRACK_DEFRAG = -400,
NF_IP_PRI_RAW = -300,
NF_IP_PRI_SELINUX_FIRST = -225,
NF_IP_PRI_CONNTRACK = -200,
NF_IP_PRI_MANGLE = -150,
NF_IP_PRI_NAT_DST = -100,
NF_IP_PRI_FILTER = 0,
NF_IP_PRI_SECURITY = 50,
NF_IP_PRI_NAT_SRC = 100,
NF_IP_PRI_SELINUX_LAST = 225,
NF_IP_PRI_CONNTRACK_HELPER = 300,
NF_IP_PRI_CONNTRACK_CONFIRM = INT_MAX,
NF_IP_PRI_LAST = INT_MAX,
};
dev
是捕获操作针对的设备(网络接口);hooknum
是我们使用的钩子类型。当捕获到数据包时,处理模式由hooknum
和hook
字段定义。对于 IP,钩子类型在linux/netfilter.h
中定义:
enum nf_inet_hooks {
NF_INET_PRE_ROUTING,
NF_INET_LOCAL_IN,
NF_INET_FORWARD,
NF_INET_LOCAL_OUT,
NF_INET_POST_ROUTING,
NF_INET_NUMHOOKS
};
hook
是在捕获网络数据包时调用的处理程序(数据包以struct sk_buff
结构体形式发送)。private
字段是传递给处理程序的私有信息。捕获处理程序的原型由nf_hookfn
类型定义:
struct nf_hook_state {
unsigned int hook;
u_int8_t pf;
struct net_device *in;
struct net_device *out;
struct sock *sk;
struct net *net;
int (*okfn)(struct net *, struct sock *, struct sk_buff *);
};
typedef unsigned int nf_hookfn(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state);
捕获函数 nf_hookfn()
中, priv
参数是 struct nf_hook_ops
初始化时传递的私有信息。 skb
是指向捕获的网络数据包的指针。根据 skb
的信息,可以进行数据包过滤决策。函数的 state
参数是与数据包捕获相关的状态信息,包括输入接口、输出接口、优先级和钩子号。优先级和钩子号可使同一个函数被多个钩子调用。
捕获处理程序可以返回以下常量之一 (NF_*
):
/* hook 函数的响应结果 */
#define NF_DROP 0
#define NF_ACCEPT 1
#define NF_STOLEN 2
#define NF_QUEUE 3
#define NF_REPEAT 4
#define NF_STOP 5
#define NF_MAX_VERDICT NF_STOP
NF_DROP
用于过滤(忽略)数据包, NF_ACCEPT
用于接受数据包并将其转发。
通过使用在 linux/netfilter.h
中定义的函数来注册/注销一个 hook:
/* 注册/注销 hook 点的函数 */
int nf_register_net_hook(struct net *net, const struct nf_hook_ops *ops);
void nf_unregister_net_hook(struct net *net, const struct nf_hook_ops *ops);
int nf_register_net_hooks(struct net *net, const struct nf_hook_ops *reg,
unsigned int n);
void nf_unregister_net_hooks(struct net *net, const struct nf_hook_ops *reg,
unsigned int n);
注意
在 Linux 内核 3.11-rc2 版本之前,作为 netfilter 钩子参数的 struct sk_buff
结构,内部的头部提取函数有一些限制。虽然每次都可以使用 ip_hdr()
获取 IP 头部,但是用于获取 TCP 和 UDP 头部的 tcp_hdr()
和 udp_hdr()
函数,却只能对从系统内部,而非外部接收的数据包使用。对于后者的情况,需要手动计算数据包中的头部偏移量:
// TCP 数据包 (iph->protocol == IPPROTO_TCP)
tcph = (struct tcphdr*)((__u32*)iph + iph->ihl);
// UDP 数据包 (iph->protocol == IPPROTO_UDP)
udph = (struct udphdr*)((__u32*)iph + iph->ihl);
这段代码适用于所有过滤场景,因此建议使用它来替代头部访问函数。
下面是一个 netfilter hook 的使用示例:
#include <linux/netfilter.h>
#include <linux/netfilter_ipv4.h>
#include <linux/net.h>
#include <linux/in.h>
#include <linux/skbuff.h>
#include <linux/ip.h>
#include <linux/tcp.h>
static unsigned int my_nf_hookfn(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
/* 处理数据包 */
//...
return NF_ACCEPT;
}
static struct nf_hook_ops my_nfho = {
.hook = my_nf_hookfn,
.hooknum = NF_INET_LOCAL_OUT,
.pf = PF_INET,
.priority = NF_IP_PRI_FIRST
};
int __init my_hook_init(void)
{
return nf_register_net_hook(&init_net, &my_nfho);
}
void __exit my_hook_exit(void)
{
nf_unregister_net_hook(&init_net, &my_nfho);
}
module_init(my_hook_init);
module_exit(my_hook_exit);
netcat¶
在开发包含网络编程的应用程序时,常用的工具包括 netcat。它也被昵称为“TCP/IP 的瑞士军刀”。它可以用于以下功能:
- 发起 TCP 连接;
- 等待 TCP 连接;
- 发送和接收 UDP 数据包;
- 以十六进制转储(hexdump)格式显示流量;
- 在建立连接后运行程序(例如,一个 shell);
- 在发送的数据包中设置特殊选项。
发起 TCP 连接:
nc 主机名 端口号
监听 TCP 端口:
nc -l -p 端口号
发送和接收 UDP 数据包,需要添加 -u
命令行选项。
注解
命令是 nc;通常 netcat 是此命令的别名。还有其他实现 netcat 命令的版本,其中一些与经典实现参数略有不同。运行 man nc 或 nc -h 以查看如何使用它。
有关 netcat 的更多信息,请参阅以下 教程。
进一步阅读¶
练习¶
重要
我们强烈建议你使用 这个仓库 中的配置。
要解决练习问题,你需要执行以下步骤:
- 用模板来准备骨架
- 构建模块
- 启动虚拟机并在虚拟机中测试模块。
当前实验名称为 networking。请参阅任务名称的练习。
骨架代码是从位于 tools/labs/templates
的完整源代码示例中生成的。要解决任务,首先要为所有实验生成骨架代码:
tools/labs $ make clean
tools/labs $ LABS=<实验名称> make skels
你还可以使用以下命令为单个任务生成骨架代码:
tools/labs $ LABS=<实验名称>/<任务名称> make skels
生成骨架驱动程序后,构建源代码:
tools/labs $ make build
然后,启动虚拟机:
tools/labs $ make console
模块将放置在 /home/root/skels/networking/<任务名称> 目录中。
重新构建模块时,无需停止虚拟机!本地 skels 目录与虚拟机共享。
请查看 练习 部分以获取更详细的信息。
警告
在开始练习或生成骨架之前,请在 Linux 仓库中运行 git pull 命令,以确保你拥有最新版本的练习。
如果你有本地更改,pull 命令将失败。使用 git status
检查本地更改。如果要保留更改,在 pull
之前运行 git stash
,之后运行 git stash pop
。要放弃更改,请运行 git reset --hard master
。
如果你在 git pull
之前已经生成了骨架,你需要再次生成骨架。
重要
你需要确保内核支持 netfilter
。可以通过 CONFIG_NETFILTER
来启用它。要激活它,请在 linux
目录中运行 make menuconfig,并在 Networking support -> Networking options
中勾选 Network packet filtering framework (Netfilter)
选项。如果它未启用,请启用它(作为内置功能,而不是外部模块——必须带有 *
标记)。
1. 在内核空间中显示数据包¶
编写一个内核模块,显示发起出站连接的 TCP 数据包的源地址和端口。从 1-2-netfilter
中的代码开始,并填写标有 TODO 1
的区域,同时考虑下面的注释。
你需要注册一个类型为 NF_INET_LOCAL_OUT
的 netfilter 钩子,如 `netfilter`_ 部分所述。
借助 `struct sk_buff 结构`_, 你可以使用特定的函数访问数据包头部。ip_hdr()
函数以返回指向 struct iphdr
结构的指针的形式,返回 IP 头部。tcp_hdr()
函数以返回指向 struct tcphdr
结构的指针的形式,返回 TCP 头部。
图表 解释了如何建立 TCP 连接。连接初始化数据包在 TCP 头部中设置了 SYN
标志,并清除了 ACK
标志。
注解
要显示源 IP 地址,请使用 printk 函数的 %pI4
格式。详细信息可以在 内核文档 (IPv4 addresses
部分)中找到。以下是使用 %pI4
的示例代码片段:
printk("IP address is %pI4\n", &iph->saddr);
在使用 %pI4
格式时,printk 的实参是指针。因此,构造应为 &iph->saddr
(带有 & 运算符)而不是 iph->saddr
。
在 TCP 头部中,源 TCP 端口以 网络字节序 格式表示。请阅读 转换 部分。使用 ntohs()
进行转换。
为了进行测试,请使用 1-2-netfilter/user/test-1.sh
文件。该测试创建一个到本地主机的连接,然后由内核模块拦截和显示该连接。 make copy 命令仅会在该脚本标记为可执行的情况下,才会将该脚本复制到虚拟机上。该脚本使用静态编译得到的 netcat 工具,该工具的路径是 skels/networking/netcat
;此程序必须具有执行权限。
运行检查器后,输出应类似于下面的示例:
# ./test-1.sh
[ 229.783512] TCP connection initiated from 127.0.0.1:44716
Should show up in filter.
Check dmesg output.
2. 按目标地址进行过滤¶
扩展练习 1 中的模块,以便你可以通过 MY_IOCTL_FILTER_ADDRESS
ioctl 调用指定目标地址。注意只显示包含指定目标地址的数据包。为了解决这个任务,填写标有 TODO 2
的区域,并按照以下规范进行操作。
要实现 ioctl 例程,你必须填写 my_ioctl
函数。请查看 ioctl 部分的内容。从用户空间发送的地址使用 网络字节序, 因此 无需 进行转换。
注解
通过 ioctl
发送的 IP 地址是通过地址发送的,而不是通过值发送的。地址必须存储在 ioctl_set_addr
变量中。可以使用 copy_from_user()
进行复制。
要比较地址,请填写 test_daddr
函数。这里我们无需转换地址,即可将(使用网络字节序的)地址进行比较(如果从左到右相等,则反转后也相等)。
如果要显示按目标地址过滤出的连接初始化数据包(这些过滤出的数据包,其目标地址与我们通过 ioctl 例程发送的地址相符),那么 test_daddr
函数必须在 netfilter 钩子中调用。连接初始化数据包在 TCP 头部中设置了 SYN
标志,并清除了 ACK
标志。你需要检查两件事情:
- TCP 标志;
- 数据包的目标地址(使用
test_addr
)。
为了进行测试,请使用 1-2-netfilter/user/test-2.sh
脚本。此脚本需要编译 1-2-netfilter/user/test.c
文件以生成测试可执行文件。在物理系统上运行 make build 命令时,会自动进行编译。只有标记为可执行,该测试脚本才会复制到虚拟机上。该脚本使用静态编译的 netcat 工具(在 skels/networking/netcat
中);该可执行文件必须具有执行权限。
运行检查器后,输出应类似于下面的示例:
# ./test-2.sh
[ 797.673535] TCP connection initiated from 127.0.0.1:44721
Should show up in filter.
Should NOT show up in filter.
Check dmesg output.
测试首先要求对 127.0.0.1
IP 地址进行数据包过滤,然后对 127.0.0.2
IP 地址进行过滤。第一个连接初始化数据包(到 127.0.0.1
)被过滤器拦截并显示,而第二个数据包(到 127.0.0.2
)则未被拦截。
3. 监听 TCP socket¶
编写一个内核模块,在回环接口(loopback interface)(在 init_module
中)上创建监听连接的 TCP 套接字,监听端口为 60000
。从 3-4-tcp-sock
中的代码开始,填写标有 TODO 1
的区域,同时考虑以下观察结果。
请阅读 对 socket 结构的操作 和 struct proto_ops 结构 部分。
sock
socket 是 服务器套接字
,因此必须处于监听状态。也就是说,必须对该 socket 执行 bind
和 listen
操作。在内核空间中,要执行类似于 bind
和 listen
的操作,你需要调用类似 sock->ops->...;
的函数。你可以调用的示例函数包括 sock->ops->bind
, sock->ops->listen
等。
注解
例如,调用 sock->ops->bind
或 sock->ops->listen
函数,查看在 sys_bind()
和 sys_listen()
系统调用处理程序中调用它们的方式。
在 Linux 内核源代码树的 net/socket.c
文件中,查找系统调用处理程序。
注解
对于 listen
的第二个参数(backlog),请使用 LISTEN_BACKLOG
。
在模块的退出函数和标有错误标签的区域中记得释放 socket;可以使用 sock_release()
来释放。
要进行测试,请运行 3-4-tcp_sock/test-3.sh 脚本。只有在标记为可执行时,该脚本才会通过 make copy 复制到虚拟机上。
运行测试后,将显示一个 TCP 套接字,通过监听端口 60000
进行连接。
4. 在内核空间接受连接¶
扩展上一个练习中的模块,以允许外部连接(无需发送任何消息,只需接受新连接)。填写标有 TODO 2
的区域。
请阅读 对 socket 结构的操作 和 struct proto_ops 结构 部分。
对于内核空间的 accept
等效操作,请参阅 sys_accept4()
系统调用处理程序。请查看 lnet_sock_accept 实现,以及如何使用 sock->ops->accept
调用。将倒数第二个参数 (flags
) 的值设为 0
,将最后一个参数 (kern
) 的值设为 true
。
注解
在 Linux 内核源代码树的 net/socket.c
文件中查找系统调用处理程序。
注解
必须使用 sock_create_lite()
函数创建新套接字 (new_sock
),然后使用以下方式配置其操作:
newsock->ops = sock->ops;
打印目标 socket 的地址和端口。要查找 socket 的对等名称(即地址),请参考 sys_getpeername()
系统调用处理程序。
注解
sock->ops->getname
函数的第一个参数是连接套接字,即通过 accept
调用初始化的 new_sock
。
sock->ops->getname
函数的最后一个参数是 1
,表示我们想要了解有关端点或对等点 (远程端 或 对等点) 的信息。
使用文件中定义的 print_sock_address
宏显示对等地址(由 raddr
变量指示)。
在模块的退出函数中,以及在错误标签之后,释放新创建的套接字(接受连接后)。在将 accept
代码添加到模块初始化函数之后,insmod 操作将会阻塞,直到建立连接。你可以使用 netcat 在该端口上解锁。因此,上一个练习中的测试脚本将无法工作。
要进行测试,请运行 3-4-tcp_sock/test-4.sh
脚本。只有在标记为可执行时,该脚本才会通过 make copy 复制到虚拟机上。
不会显示任何特殊内容(在内核缓冲区中)。测试的成功将由连接的建立来确定。然后使用 Ctrl+c
停止测试脚本,然后可以移除内核模块。
5. UDP 套接字发送方¶
编写一个内核模块,其创建一个 UDP socket,并将来自 socket 的 MY_TEST_MESSAGE
宏的消息发送到回环地址的端口 60001
。
从 5-udp-sock
中的代码开始。
请阅读 对 socket 结构的操作 和 struct proto_ops 结构 部分。
要了解如何在内核空间中发送消息,请参阅 sys_send()
系统调用处理程序或 发送/接收消息。
提示
struct msghdr
结构的 msg_name
字段必须初始化为目标地址(指向 struct sockaddr
的指针), msg_namelen
字段必须初始化为地址大小。
将 struct msghdr
结构的 msg_flags
字段初始化为 0
。
将 struct msghdr
结构的 msg_control
和 msg_controllen
字段分别初始化为 NULL
和 0
。
要发送消息,请使用 kernel_sendmsg()
。
消息传输参数从内核空间中检索。在 kernel_sendmsg()
调用中,将 struct iovec
结构指针转换为 struct kvec
指针。
提示
kernel_sendmsg()
的最后两个参数分别为 1
(I/O 向量的数量) 和 len
(消息大小)。
要进行测试,请使用 test-5.sh
脚本。只有在标记为可执行时,该脚本才会通过 make copy 复制到虚拟机上。该脚本使用存储在 skels/networking/netcat
中的静态编译的 netcat
工具;该可执行文件必须具有执行权限。
如果正确实现,运行 test-5.sh
脚本后,将显示类似下面的输出中的 kernelsocket
消息:
/root # ./test-5.sh
+ pid=1059
+ sleep 1
+ nc -l -u -p 60001
+ insmod udp_sock.ko
kernelsocket
+ rmmod udp_sock
+ kill 1059