Linux路由表的抽象扩展应用于nf_conntrack

Posted llguanli

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux路由表的抽象扩展应用于nf_conntrack相关的知识,希望对你有一定的参考价值。

思想

标准IP路由查找的过程为我们提供了一个极好的“匹配-动作”的例程。

即匹配到一个路由项。然后将数据包发给该路由项指示的下一跳。
假设我们把上面对IP路由查找的过程向上抽象一个层次,就会发现,事实上它还能够有别的用。抽象后的表述为:以数据包的源地址或者目标地址为键值去查询一张表。查到结果项以后运行结果项指示的一个动作。一个结果项为:

struct result_node {
       uint32 network;
       uint32 netmask;
       void *action;
};
以上这个思想多亏了路由查找中的“最长前缀匹配”原则,该原则是隐式的,可是该原则保证了最精确的匹配。


需求

在我的特殊场景中。我希望一个网络段(一个大的网络,一个小一点的网络。一台主机)发出的数据流和一个字符串描写叙述关联起来。该字符串能够是描写叙述,能够是username,它甚至能够是别的随意什么东西...以往这样的情况会被觉得是不符合UNIX哲学的。而且在以往,内存使用方式太小气,内存太奢侈。

可是现如今,不须要吝啬内存了。我们便能够在内核里面塞入不论什么能够塞入的东西。仅仅要设计得当,让它符合UNIX哲学精神就可以。
       总的来讲。我希望从一个到达的数据包上取出一个事先配置好的字符串。

为何Linux没有实现

实际上我的想法一開始就是错误的。正确性在于我知道我是错的。为何一直以来我一直在“修补”Linux内核协议栈以及Netfilter扩展的各种不良或者不完备的实现呢。比方“马上生效NAT”。比方双向静态NAT。比方不完备的conntrack confirm机制,不一而足。

这些缺陷难道Linux内核以及Netfilter社区的那帮大牛们意识不到吗?绝对不是这样,由于他们遵循的是Worse is better原则。该原则的核心就是简单主宰一切,为了简单能够舍弃该舍弃的一切。
       而我的做法,即实现非常多协议栈不包括的东西,看起来就是违背的就是Worse is better原则,我用Completeness(完备性)原则替换了Simplicity(简单性)原则。而这两个的原则的正确表述应该是:

Simplicity
The design must be simple, both in implementation and interface. It is more important for the implementation to be simple than the interface. Simplicity is the most important consideration in a design.
Completeness
The design must cover as many important situations as is practical. All reasonably expected cases should be covered. Completeness can be sacrificed in favor of any other quality. In fact, completeness must be sacrificed whenever implementation simplicity is jeopardized. Consistency can be sacrificed to achieve completeness if simplicity is retained; especially worthless is consistency of interface.
所以,Linux的社区开发者严格遵循了该原则,没有引入“复杂且不经常使用”的机制。而我为何非要实现它们呢?我的想法是一切为了满足我的需求,按照Simplicity优先原则,我不得不这么做,由于我这么做是为了不引入更复杂的机制。

分析

既然是给数据流绑定一个字符串,非常显然,扩展nf_conntrack是绝佳的选择,怎样扩展它我在前文已经做了详述。接下来须要考虑的是怎样设置规则,非常显然,写一个INFO iptables模块是一个选择:
iptables -t ...-A .... -j INFO --set-info "aaaaaaaaaaaa"
INFO模块的思想是:从skb取出conntrack结构体,进而取出acct扩展(我总是喜欢拿account扩展开刀)。然后把set-info參数指示的信息复制到acct扩展中。
可是,假设有10000个info信息须要设置,我就要建立10000条规则。每个数据包都要在内核中顺序的遍历以上全部的规则,iptables规则太多(超过5000条)的话,确实会严重影响网络性能。当然你能够灵活安排规则的顺序以便使数据包高速结束一个链的匹配过程,比方下面这样:
iptables ... -m state --state ESTABLISHED -j ACCEPT  (设置在第一条,由于仅仅有针对一个流的第一个NEW状态的数据包才会有set-info的必要)
.... -j INFO --set-info aaaaa
.... -j INFO --set-info bbbbb
.... -j INFO --set-info ccccc

这确实是一种技巧。玩过iptables的都知道,可是由于存在下面4个问题导致我不得不寻找其他的方案:
1.当INFO和INFO之外的其他target并不一致允许通过以上的方式跳出规则匹配链的时候。就须要安排还有一条自己定义链来解决。
2.由于上述第1点的频繁操作,终于会演变成针对iptables的编程,规则集本身会越来越复杂,像写得不好的C++代码那样。调整一条规则的顺序都将是极其困难的。
3.退一万步说话。对一个NEW状态的包,它真的就须要遍历规则(注意,iptables是遍历)匹配,我须要一种比O(n)高效的算法(nf-HiPAC在5000+条规则情况下会好非常多)。
4.Netflter的nf-HiPAC项目可能是个创举,可是我没有时间具体研究它。


UNIX思想教导我们要将问题拆成互相独立的小问题,然后让它们相互配合去解决终于的大问题,可是相互配合本身有时会成为新的问题,须要付出巨大的管理成本。并非每一类问题都能够拆成cat file|grep key那样的方式的。
       当我发现IP路由的查找过程事实上就是一个“匹配-动作”的模式之后,我便改了“动作”的解释,将“发往下一跳”改为“取出info信息”,注意。仅仅是取出info信息,并没有设置它到conntrack,为何不把这两件事一起做呢?由于这两件事之间的关系和cat file|grep key之间的关系非常像。上一小节的4个问题。我是这么回答的:
1.使用路由查找模块是全然独立于iptables,和iptables没有半毛钱关系;
2.使用路由查找模块不须要和不论什么其他模块接口,你仅仅须要调用下面接口:
int nf_route_table_search(const u_int32_t src, char *info, int len);
假设src找到。则将和该地址所在的最长前缀匹配的网络关联的info信息取出来,假设没有与之关联的,则返回非0
3.效率更不用说,全然就是Linux内核的hash路由查找算法,用了十几年了。跑在各种环境下。


4.尽管我没有时间去研究nf-HiPAC项目,可是我对路由查找算法却早已精通,一个思想是:永远使用自己最熟悉的技术。


再次扩展

话说能够使用路由查找模块高速匹配到一个数据包关联的一个“路由项”,那么该路由项里面仅仅存一个字符串信息是不是太浪费了呢?能不能把路由项携带的信息也搞成可扩展的呢?答案无疑是肯定的,由于我已经将“下一跳”改动成了字符串信息,接下来就是取消这个类型定义就能够了。在路由项里面加一个void *指针无疑是可取的,可是为了使内存更紧凑。增加一个0长度数组是更好的选择。


       在内核中,struct fib_node指示一个路由项,为了和标准的区分,我加了一个nf_前缀表明它是和Netfilter关联并由nf_conntrack使用的“路由项”。同一时候去掉携带数据类型的定义:

/*
 *      下面结构体指示一个“路由节点”。它能够携带一个extra_data
 *      你能够随意定义它,比方能够例如以下定义:
 *      struct my_extra {
 *              char info[INFO_SIZE];
 *              int  policy; // NF_ACCEPT,NF_DROP或者其他?
 *              // .... extra of extra ??
 *      };
 *      它带来了无限的扩展性,你能够使用这个“路由”节点存储不论什么数据。
 */
struct nf_fib_node {
        struct hlist_node       nf_fn_hash;
        u_int32_t               fn_key;
        int                     len;
        // extra_len指示你传入的extra data的长度
        int                     extra_len;
        // 0长度数组,你能够随意定义它
        char                    extra[0];
};

PS:《JAVA编程思想》第15章,15.16小节一開始:介绍过这样的思想,即要编写能够尽可能广泛地应用的代码。为了实现这一点,我们须要各种途径来放松对我们的代码将要作用的类型所作的限制,同一时候不丢失静态类型检查的优点。

进一步扩展

ACL的全称为訪问控制列表。注意“列表”这个词。表明它的结构是一维线性。

在非常多的系统上。ACL对数据包的具体匹配行为依赖管理员的规则配置顺序。注意以上说的是ACL的用户配置接口,当然对于这一点,Linux的iptables以及Cisco的ACL是一致的,可是对于内部的实现,各家系统就有所差别了。以Linux的iptables为例,考虑下面的规则序列:

i=1;
# 增加巨量的iptables规则
for ((; i < 255; i++)); do
        j=1;
        for ((; j < 255; j++)); do
                iptables -A FORWARD -s 192.168.$i.$j -j DROP;
        done
done

此时来了一个源地址是172.16.0.0/24网段的数据包,它是否要经过6万多条规则呢?当然了。你能够将一条针对非192.168.0.0/16段的ACCEPT规则放在第一条,可是这里的重点却是在实现层面而非配置层面的:在数据包匹配的过程中,怎样迅速躲开那些明显不匹配的规则。这也是nf-HiPAC项目的目的之中的一个。

这样的思想全然颠倒了以往以规则中的地址来匹配数据包的匹配主导权,变成了按数据包的源/目标IP去匹配规则。

使用路由查找的思想无疑是非常方便的!


       按照上一小节我对struct nf_fib_node的扩展。在这个意义上,它的extra的定义已经相当明白:

struct my_extra {
        // 替换iptables中的matches
        struct list_head matches_list;
        // 替换iptables的target
        int policy; // NF_ACCEPT,NF_DROP或者其他?
}; 

表述出来就是,用数据包的源IP地址或者目标IP地址去“查一张路由表”。假设查找到,则取出它的extra数据。指示数据包怎样被处理。


实现

上述的“进一步扩展”一节中说明的思想仅仅指出了一种可行性,我还没有想出把它放在哪个位置好。写下本文的目的最初是为了实现一个叫做conntrack_table的工具,针对nf_conntrack来做操作。比方:
1.在conntrack里面绑定两个方向的路由。实现仅仅针对conntrack进行查找,不再针对每个数据包都去查路由表;
2.用最长前缀匹配的IP路由查找机制而不是用iptables匹配机制来将一个conntrack的第一个数据包匹配到一个路由项,然后从该路由项中取出信息设置到conntrack结构体中。

由于周末的时间有限,平时回家又太晚,因此该版本号的实现仅仅包括了使用路由机制保存网段的字符串信息。然后将其设置在conntrack结构体中。

内核机制实现思路

内核态一共须要实现两个部分。第一个部分是将标准的路由查找算法复制移植一份到nf_conntrack_ipv4模块中,第二部分就是在nf_conntrack被confirm的地方查询这个路由表,取出结果路由项中保存的info信息存储在conntrack中。这个操作仅仅针对一个流的第一个数据包。

兴许的数据包不再查询这个路由表。

内核的路由查找模块的移植代码一会儿我再给出,如今先给出针对confirm的改动。改动的仅有ipv4_confirm函数。它是挂接于INET IPv4协议族的一个HOOK函数,这样我便不必考虑其他的协议了,从而省略了协议推断:

static unsigned int ipv4_confirm(unsigned int hooknum,
                                 struct sk_buff *skb,
                                 const struct net_device *in,
                                 const struct net_device *out,
                                 int (*okfn)(struct sk_buff *))
{
...
out:
    /* We‘ve seen it coming out the other side: confirm it */
    ret = nf_conntrack_confirm(skb);
        if (ret == NF_ACCEPT) {
#include "nf_conntrack_info.h"
#define MAX_LEN    128
struct nf_conn_priv {
    struct nf_conn_counter ncc[IP_CT_DIR_MAX];
    char *info;
};
        struct nf_conn_priv *ncp;
        struct nf_conn_counter *acct;
        acct = nf_conn_acct_find(ct);
        if (acct) {
            char buf[MAX_LEN] = {0};
            int len = MAX_LEN;
            struct iphdr *iph = ip_hdr(skb);
            // 查询“路由表”,获取结果
            int rv = nf_route_table_search(iph->saddr, buf, &len);
            if (!rv) {
                ncp = (struct nf_conn_priv *)acct;
                if (ncp->info == NULL) {
                    ncp->info = (char *)kcalloc(1, len+1, GFP_ATOMIC);
                }
                // 拷贝获取的结果到conntrack
                memcpy(ncp->info, buf, len);
            }
        }
    }
        return ret;
}

注意,这个ipv4_confirm里面的这个位置是我精心挑选的,一个好的插入点将会降低大量的if语句。无论是古老的语言还是OO语言,if语句都是在不得不用的时候才要用的。程序运行到一个位置。它本身应该知道自己的上下文信息,who am i这样的问题不该问。


       好了,如今该把对Linux路由查找算法的移植展示出来了,我想hash算法已经经得住了这么多年的性能考验,也就没有移植trie树算法。同一时候我裁减掉了rehash机制。这个移植非常easy。基本就是net/ipv4/fib_hash.c的改动操作,去除了fib_node的类型化定义。也就是说将其提升到了“泛型”的层次。

代码例如以下:
头文件:nf_conntrack_rtable.h

#include <linux/types.h>
#include <linux/list.h>

#define SIZE    128
struct nf_fn_zone {
        struct nf_fn_zone               *fz_next;       /* Next not empty zone  */
        struct hlist_head       *fz_hash;       /* Hash table pointer   */
        int                     fz_nent;        /* Number of entries    */
        int                     fz_divisor;     /* Hash divisor         */
        u32                     fz_hashmask;    /* (fz_divisor - 1)     */
#define FZ_HASHMASK(fz)         ((fz)->fz_hashmask)
        int                     fz_order;       /* Zone order           */
        u_int32_t               fz_mask;
#define FZ_MASK(fz)             ((fz)->fz_mask)
};

struct nf_fn_hash {
        struct nf_fn_zone       *nf_fn_zones[33];
        struct nf_fn_zone       *nf_fn_zone_list;
};

/*
 *      下面结构体指示一个“路由节点”,它能够携带一个extra_data
 *      你能够随意定义它,比方能够例如以下定义:
 *      struct my_extra {
 *              char info[128];
 *              int  policy; // NF_ACCEPT,NF_DROP或者其他?
 *              // .... extra of extra ??
 *      };
 *      它带来了无限的扩展性。你能够使用这个“路由”节点存储不论什么数据。
 */
struct nf_fib_node {
        struct hlist_node       nf_fn_hash;
        u_int32_t               fn_key;
        int                     len;
        // extra_len指示你传入的extra data的长度
        int                     extra_len;
        // 0长度数组,你能够随意定义它
        char                    extra[0];
};

// 查找接口
int nf_route_table_search(const u_int32_t dst, void *res, int *res_len);
// 增加接口
int nf_route_table_add( u_int32_t network, u_int32_t netmask, void *extra, int extra_len);
// 节点删除接口
int nf_route_table_delete(u_int32_t network, u_int32_t mask);
// 清除接口
void nf_route_table_clear(void);

C文件:nf_conntrack_rtable.c
#include <linux/types.h>
#include <linux/inetdevice.h>
#include <linux/slab.h>
#include <linux/kernel.h>

#include "nf_conntrack_info.h"

#ifndef NULL
#define NULL    0
#endif

// 使用lock总是安全的。内核编程两要素:1.安全;2.高效
static DEFINE_RWLOCK(nf_hash_lock);

struct nf_fn_hash *route_table = NULL;

static inline u_int32_t nf_fz_key(u_int32_t dst, struct nf_fn_zone *fz)
{
        return dst & FZ_MASK(fz);
}

static inline u32 nf_fn_hash(u_int32_t key, struct nf_fn_zone *fz)
{
        u32 h = key>>(32 - fz->fz_order);
        h ^= (h>>20);
        h ^= (h>>10);
        h ^= (h>>5);
        h &= FZ_HASHMASK(fz);
        return h;
}

static struct hlist_head *fz_hash_alloc(int divisor)
{
        unsigned long size = divisor * sizeof(struct hlist_head);
        return kcalloc(1, size, GFP_ATOMIC);
}

static struct nf_fn_zone * fn_new_zone(struct nf_fn_hash *table, int z)
{
        int i;
        struct nf_fn_zone *fz = kcalloc(1, sizeof(struct nf_fn_zone), GFP_ATOMIC);
        if (!fz)
                return NULL;
        if (z) {
                fz->fz_divisor = 16;
        } else {
                fz->fz_divisor = 1;
        }
        fz->fz_hashmask = (fz->fz_divisor - 1);
        fz->fz_hash = fz_hash_alloc(fz->fz_divisor);
        if (!fz->fz_hash) {
                kfree(fz);
                return NULL;
        }
        fz->fz_order = z;
        fz->fz_mask = inet_make_mask(z);

        /* Find the first not empty zone with more specific mask */
        for (i=z+1; i<=32; i++)
                if (table->nf_fn_zones[i])
                        break;
        write_lock_bh(&nf_hash_lock);
        if (i>32) {
                /* No more specific masks, we are the first. */
                fz->fz_next = table->nf_fn_zone_list;
                table->nf_fn_zone_list = fz;
        } else {
                fz->fz_next = table->nf_fn_zones[i]->fz_next;
                table->nf_fn_zones[i]->fz_next = fz;
        }
        table->nf_fn_zones[z] = fz;
        write_unlock_bh(&nf_hash_lock);
        return fz;
}

// 路由表操作接口:1.查找;2.删除。參数过于多。相似Win32 API,风格不好,但使用方便
int nf_route_table_opt(const u_int32_t dst, const u_int32_t mask, int del_option, void *res, int *res_len)
{
        int rv = 1;
        struct nf_fn_zone *fz;
        struct nf_fib_node *del_node = NULL;

        if (NULL == route_table) {
                printk("");
                return 1;
        }   
        read_lock(&nf_hash_lock);
        for (fz = route_table->nf_fn_zone_list; fz; fz = fz->fz_next) {
                struct hlist_head *head;
                struct hlist_node *node;
                struct nf_fib_node *f;

                u_int32_t k = nf_fz_key(dst, fz);
                head = &fz->fz_hash[nf_fn_hash(k, fz)];
                hlist_for_each_entry(f, node, head, nf_fn_hash) {
                        if (f->fn_key == k){
                                if ( 1 == del_option && mask == FZ_MASK(fz)){
                                        del_node = f;
                                } else if (0 == del_option){
                                        // 将用户传入的extra数据拷贝给调用者。
                                        memcpy(res, (const void *)(f->extra), f->extra_len);
                                        *res_len = f->extra_len;
                                }
                                rv=0;
                                goto out;
                        }
                }
        }
        rv = 1;
out:
        read_lock(&nf_hash_lock);
        if (del_node) {
                write_lock_bh(&nf_hash_lock);
                __hlist_del(&del_node->nf_fn_hash);
                kfree(del_node);
                write_unlock_bh(&nf_hash_lock);
        }
        return rv;
}

static inline void fib_insert_node(struct nf_fn_zone *fz, struct nf_fib_node *f)
{
        struct hlist_head *head = &fz->fz_hash[nf_fn_hash(f->fn_key, fz)];
        hlist_add_head(&f->nf_fn_hash, head);
}

int nf_route_table_search(u_int32_t dst, void *res, int *res_len)
{
        return  nf_route_table_opt(dst, 32, 0, res, res_len);
}

int nf_route_table_delete(u_int32_t network, u_int32_t mask)
{
        return  nf_route_table_opt(network, mask, 1, NULL, NULL);
}

int nf_route_table_add(u_int32_t network, u_int32_t netmask, void *extra, int extra_len)
{
        struct nf_fib_node *new_f;
        struct nf_fn_zone *fz;
        new_f = kcalloc(1, sizeof(struct nf_fib_node) + extra_len, GFP_ATOMIC);
        new_f->len = inet_mask_len(netmask);
        new_f->extra_len = extra_len;
        new_f->fn_key = network;
        memcpy(new_f->extra, extra, extra_len);
        if (new_f->len > 32) {
                return -1;
        }
        INIT_HLIST_NODE(&new_f->nf_fn_hash);
        if ( NULL == route_table){
                route_table = kcalloc(1, sizeof(struct nf_fn_hash), GFP_ATOMIC);
                fz = fn_new_zone(route_table,new_f->len);
        } else {
                fz = route_table->nf_fn_zones[new_f->len];
        }
        if (!fz && !(fz = fn_new_zone(route_table,new_f->len))) {
                return -1;
        }
        fib_insert_node(fz, new_f);
        return 0;
}

void nf_route_table_clear( void )
{
        struct nf_fn_zone *fz,*old;
        if (NULL == route_table) {
                printk("");
                return;
        }   
        write_lock_bh(&nf_hash_lock);
        for (fz = route_table->nf_fn_zone_list; fz; ) {
                if (fz != NULL){
                        kfree(fz->fz_hash);
                        fz->fz_hash=NULL;
                        old = fz;
                        fz = fz->fz_next;
                        kfree(old);
                        old=NULL;
                }
        }
        kfree(route_table);
        route_table=NULL;
        write_unlock_bh(&nf_hash_lock);
        return;
}

编译相关我将上述代码放入net/ipv4/netfilter文件夹下,改动Makefile,增加nf_conntrack_rtable.o:
nf_conntrack_ipv4-objs  :=  nf_conntrack_l3proto_ipv4.o nf_conntrack_proto_icmp.o nf_conntrack_info.o
然后编译就可以。出来的nf_conntrack_ipv4.ko就已经增加自己的扩展了。

用户接口

在抛弃iptables之后,我选择了procfs作为用户接口。由于它使用标准的文件IO接口。能够方便的在各种脚本中操作。这里还有三个思想。
       第一个思想是,假设已经有了现成的能够完毕要求的机制,就不要编写C代码。内容越来越多。代码越来越少!
       另外一个思想就是要使用事务型的操作,而不是自己封装事务序列。相同对于写文件。假设用C或者JAVA来写,那么你就必须完毕open-readinfo-write-write-write...write end-close这样的序列,你操纵的不是文件,很多其他的是在玩弄编程语言本身,假设使用bash脚本,一个echo就可以。echo程序内部封装了一个完整的序列(strace echo sth >./a便知),你又何必自己反复实现一个相似的呢?当然这又引出了第三个思想。
       使用文本而不是二进制来做配置。

仅仅要不是纯机器操作,就不要使用二进制,二进制是机器的世界,仅仅要涉及到人的因素,文本是最好的信息交换方式。
       因此,我选择了procfs作为和内核机制通信的方式,以它来配置策略,或者说增加/删除“扩展路由”。


效果
当我运行下面的操作:
echo +192.168.10.0 255.255.255.0 1234abcd >/proc/nfrtable
此时有来自192.168.10.30的数据包过来,你就会在/proc/net/nf_conntrack中看到和该数据流关联的1234abcd信息,反之假设有来自192.168.20.0/24网段的数据包,将不会有这个信息。此外,当你运行iptables-save的时候。再也看不到关于info的设置了,事实上。INFO模块根本就不须要了。

关于算法的重用

Linux内核是不拘一格的。其算法是公开的。因此也就没有什么Copy right一说,所以不论什么人都能够使用。关键的一点是,移植它的算法须要付出一点代价,正如Linus所说的那句经典的RTFS。Linux源代码不提供不论什么的可移植性接口供用户使用,一切都须要你自己来做。比方,你不能在编译内核的时候将一些可重用的模块编译成一个Library。假设你须要,你必须自己来改动Makefile。


关于Linux内核API

Linux内核API是多变的,这样的运动给它带来的最大优点在于灵活。还是那句话,RTFS。源代码就在你眼前。看到有须要的自己拿去,只是内核社区不提供不论什么搬运支持。

正如一座金矿,管理者仅仅是在管理它,不论什么淘金者都能够挖走自己须要的金子,可是管理者不提供不论什么车辆负责搬运。即使你有钱想租也没有。要知道,金子是非常重的哦!
       Linux金矿还有还有一项规则,即不论什么人都能够在里面增加自己的代码,工具以及不论什么其他东西。因此就引出一个问题,那就是Linux内核API的不稳定性。你别指望有一个品管团队来检查你的代码实现以及接口定义。社区在乎的是。仅仅要它攻克了实际问题,就会被採纳。假设你在使用2.6.18内核。然后你觉得不好,然后你改动了它的一部分接口,确实没有被Linus骂,那么这些接口将成为2.6.18-x或者2.6.19的标准接口。

然而Linux提供了LKM机制,而LKM须要调用的正是内核接口。因此LKM的版本号号必须和其宿主的内核版本号号绝对一致。由于内核接口随时都是能够变化的。
       Linux的风格决定了它的灵活性,代价就是诸多的关于LKM的限制。Windows正好相反。它提供最大限度的兼容性接口,无论是内核驱动还是用户应用都是如此。当然也有代价,那就是Windows内核和拥护库的日益臃肿。由于须要大量的适配器来兼容新老接口。

微软的系统的API也是不断变化的,仅仅是它向用户隐藏了这样的变化而已,实施这样的隐藏的正是微软的在职人员...



































































以上是关于Linux路由表的抽象扩展应用于nf_conntrack的主要内容,如果未能解决你的问题,请参考以下文章

TCP/IP协议原理与应用笔记23:路由选择模块 和 路由表的设计

浅谈线性表的基本操作与应用

filter-Policy 实验

LVM基础应用扩展及缩减

抽象模型

如何在Linux中备份,恢复和迁移Docker容器