zoukankan      html  css  js  c++  java
  • netfilter 的扩展功能 helper tftp-nat

    /*
     需要对conntrack进行功能扩展的协议,会初始化一个struct  nf_conntrack_helper 实例,把该实例注册到Netfilter中管理的全局哈希表中。
    
    查找helper使用的hash 算法
    
    static unsigned int helper_hash(const struct nf_conntrack_tuple *tuple)
    
    {
    
        return (((tuple->src.l3num << 8) | tuple->dst.protonum) ^
    
            (__force __u16)tuple->src.u.all) % nf_ct_helper_hsize;
    
    }
    
    查找时使用的L3协议类型,L4协议类型及L4协议的源端口三元组信息来查找的helper。因为识别不同的L5协议一般都是通过这三元组来识别的。
    
    注册helper实例
    
    Netfilter中定义了一个哈希表来管理和存储不同协议注册的helper实例。
    
    static struct hlist_head *nf_ct_helper_hash __read_mostly; 
    注意nf_conn_help 和 nf_conntrack_helper的区别
    */
    int nf_conntrack_helper_register(struct nf_conntrack_helper *me)
    {
        struct nf_conntrack_tuple_mask mask = { .src.u.all = htons(0xFFFF) };
        unsigned int h = helper_hash(&me->tuple);
        struct nf_conntrack_helper *cur;
        int ret = 0;
    
        BUG_ON(me->expect_policy == NULL);
        BUG_ON(me->expect_class_max >= NF_CT_MAX_EXPECT_CLASSES);
        BUG_ON(strlen(me->name) > NF_CT_HELPER_NAME_LEN - 1);
    
        mutex_lock(&nf_ct_helper_mutex);
        hlist_for_each_entry(cur, &nf_ct_helper_hash[h], hnode) {
            if (nf_ct_tuple_src_mask_cmp(&cur->tuple, &me->tuple, &mask)) {
                ret = -EEXIST;
                goto out;
            }
        }
        hlist_add_head_rcu(&me->hnode, &nf_ct_helper_hash[h]);
        nf_ct_helper_count++;
    out:
        mutex_unlock(&nf_ct_helper_mutex);
        return ret;
    }

    conntrack关联相关的helper处理时是在

    init_conntrack --{

    if (!exp) {// 如果不存在 从新赋值  ct->ext->...->help->helper = helper
            __nf_ct_try_assign_helper(ct, tmpl, GFP_ATOMIC);
            NF_CT_STAT_INC(net, new);
        }

    }

     

    看下tftp的例子:

    struct nf_conntrack_helper {
        struct hlist_node hnode;    /* Internal use. */
    //helper的名字,iptables扩展match helper会使用
        char name[NF_CT_HELPER_NAME_LEN]; /* name of the module */
        struct module *me;        /* pointer to self */
         //协议的期望连接的策略参数,包括最大期望连接数及最大连接超时时间*/
        const struct nf_conntrack_expect_policy *expect_policy;
    
        /* length of internal data, ie. sizeof(struct nf_ct_*_master) */
        size_t data_len;
    //元组信息,表示不同协议的helper,使用该元组的一些字段来在Netfilter中查找helper
        /* Tuple of things we will help (compared against server response) */
        struct nf_conntrack_tuple tuple;
    
        /* Function to call when data passes; return verdict, or -1 to
               invalidate. *///不同协议自己实现自己的helper处理函数
        int (*help)(struct sk_buff *skb,
                unsigned int protoff,
                struct nf_conn *ct,
                enum ip_conntrack_info conntrackinfo);
    
        void (*destroy)(struct nf_conn *ct);
    
        int (*from_nlattr)(struct nlattr *attr, struct nf_conn *ct);
        int (*to_nlattr)(struct sk_buff *skb, const struct nf_conn *ct);
        unsigned int expect_class_max;
    
        unsigned int flags;
        unsigned int queue_num;        /* For user-space helpers. */
    };
    static const struct nf_conntrack_expect_policy tftp_exp_policy = {
        .max_expected    = 1,
        .timeout    = 5 * 60,
    };
    
    static void nf_conntrack_tftp_fini(void)
    {
        int i, j;
    
        for (i = 0; i < ports_c; i++) {
            for (j = 0; j < 2; j++)
                nf_conntrack_helper_unregister(&tftp[i][j]);
        }
    }
    /*
    list add  struct nf_conntrack_helper add nf_ct_helper_hash
    */
    
    
    /*
    tftp[i][0].tuple.src.l3num = AF_INET;
                tuple.dst.protonum = IPPROTO_UDP;
                uple.src.u.udp.port = htons(ports[i]);
                help = tftp_help;
    */
    
    static int __init nf_conntrack_tftp_init(void)
    {
        int i, j, ret;
    
        if (ports_c == 0)
            ports[ports_c++] = TFTP_PORT;
    
        for (i = 0; i < ports_c; i++) {
            memset(&tftp[i], 0, sizeof(tftp[i]));
    
            tftp[i][0].tuple.src.l3num = AF_INET;
            tftp[i][1].tuple.src.l3num = AF_INET6;
            for (j = 0; j < 2; j++) {
                tftp[i][j].tuple.dst.protonum = IPPROTO_UDP;
                tftp[i][j].tuple.src.u.udp.port = htons(ports[i]);
                tftp[i][j].expect_policy = &tftp_exp_policy;
                tftp[i][j].me = THIS_MODULE;
                tftp[i][j].help = tftp_help;
    
                if (ports[i] == TFTP_PORT)
                    sprintf(tftp[i][j].name, "tftp");
                else
                    sprintf(tftp[i][j].name, "tftp-%u", i);
    
                ret = nf_conntrack_helper_register(&tftp[i][j]);
                if (ret) {
                    pr_err("failed to register helper for pf: %u port: %u
    ",
                           tftp[i][j].tuple.src.l3num, ports[i]);
                    ports_c = i;
                    nf_conntrack_tftp_fini();
                    return ret;
                }
            }
        }
        return 0;
    }
    /*/在init函数中对nf_conntrack_helper结构体的tuple进行了部分初始化,由于tftp数据包的协议和port是固定的,
    同时注册了help函数为tftp_help(),函数定义如下,----------,参数skb是当前处理的数据包,ct是相应的conntrack,处理tftp read/write request的时候会进入这个函数。
    */
    /*
    函数主要做了两件事情,首先初始化了一个ip_conntrack_expect结构,并将conntrack中应答方向的tuple结构附值给exp-〉tuple,然后调用:ip_conntrack_expect_related(exp, ct)
    
    将这个expect结构与当前的连接状态关联起来,并把它注册到一个专门组织expect结构的全局链表ip_conntrack_expect_list里。
    expect结构有什么用呢?当有返回的数据包时,首先仍然是搜索hash表,如果找不到可以匹配的连接,还会在全局链表里搜索匹配的expect结构,然后找到相关的连接状态。
    
    为什么要这样做呢?假设有主机A向主机B发送消息,如下
    
    A(10.10.10.1:1001) ——>  B(10.10.10.2:69)
    
    从A的1001端口发往B的69端口,连接跟踪模块跟踪并记录了次条连接,保存在一个ip_conntrack结构里(用tuple来识别),但是我们知道,一个连接有两个方向,我们怎么确定两个方向相反的数据包是属于同一个连接的呢?最简单的判断方法就是将地址和端口号倒过来,就是说如果有下面的数据包:
    
    B(10.10.10.2:69)——>  A(10.10.10.1:1001)
    
    虽然源/目的端口/地址全都不一样,不能匹配初始数据包的tuple,但是与对应的repl_tuple完全匹配,所以显然应该是同一个连接,所以见到这样的数据包时就可以直接确定其所属的连接了,当然不需要什么expect
    
    然而不是所有协议都这么简单, 对于tftp协议,相应的数据包可能是
    
    B(10.10.10.2:1002)——>  A(10.10.10.1:1001)
    
    并不完全颠倒,就是说不能直接匹配初始数据包的tuple的反防向的repl_tuple,在hash表里找不到对应的节点,但我们仍然认为它和前面第一条消息有密切的联系,甚至我们可以明确,将所有下面形式的数据包都归属于这一连接的相关连接
    
    B(10.10.10.2:XXX)——>  A(10.10.10.1:1001)
    
    怎么实现这一想法呢,只好再多创建一个expect了,它的tuple结构和repl_tuple完全相同,只是在mask中将源端口位置0,就是说不比较这一项,只要其他项匹配就OK。
    
    (注意一下,ip_conntrack_expect结构里有个mask,ip_conntrack_helper里也有个mask,即使是同一个连接,它们的值也是不一样的。)
    
    以上就是helper和expect的作用了,但是具体的实现方法还跟协议有关,像ftp的连接跟踪就相当复杂。
    
    从help函数返回后,连接跟踪的第一阶段就结束了。
    */
    static int tftp_help(struct sk_buff *skb,
                 unsigned int protoff,
                 struct nf_conn *ct,
                 enum ip_conntrack_info ctinfo)
    {
        const struct tftphdr *tfh;
        struct tftphdr _tftph;
        struct nf_conntrack_expect *exp;
        struct nf_conntrack_tuple *tuple;
        unsigned int ret = NF_ACCEPT;
        typeof(nf_nat_tftp_hook) nf_nat_tftp;
    
        tfh = skb_header_pointer(skb, protoff + sizeof(struct udphdr),
                     sizeof(_tftph), &_tftph);
        if (tfh == NULL)
            return NF_ACCEPT;
    
        switch (ntohs(tfh->opcode)) {
        case TFTP_OPCODE_READ:
        case TFTP_OPCODE_WRITE:
            /* 
    在nf_ct_expect_cachep上分配一个expect连接,同时赋两个值:
    exp->master = ct,
    exp->use = 1。 
    */
            /* RRQ and WRQ works the same way 
    ORGINAL :A(10.10.10.1:1001) ——>  B(10.10.10.2:69)
    REPLY  B(10.10.10.2:69)——>  A(10.10.10.1:1001)
    生成的expect为:

    ORGINAL tuple: A(10.10.10.1:0) ——>  B(10.10.10.2:69)

    exp->master = ct。注意,expect只有一个tuple,即只有一个方向,
    这里只看到ORIGNAL方向的tuple,只是因为tuple的dir没赋值,默认为0。
    */ nf_ct_dump_tuple(&ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple); nf_ct_dump_tuple(&ct->tuplehash[IP_CT_DIR_REPLY].tuple); exp = nf_ct_expect_alloc(ct);//exp->master = ct; if (exp == NULL) { nf_ct_helper_log(skb, ct, "cannot alloc expectation"); return NF_DROP; } /* 根据ct初始化expect */ tuple = &ct->tuplehash[IP_CT_DIR_REPLY].tuple; /*初始化expect exp->class=NF_CT_EXPECT_CLASS_DEFAULT*/ nf_ct_expect_init(exp, NF_CT_EXPECT_CLASS_DEFAULT, nf_ct_l3num(ct), &tuple->src.u3, &tuple->dst.u3, IPPROTO_UDP, NULL, &tuple->dst.u.udp.port); pr_debug("expect: "); nf_ct_dump_tuple(&exp->tuple); nf_nat_tftp = rcu_dereference(nf_nat_tftp_hook); /* 数据包需要走NAT时,if成立,局域网传输则else成立。 */ if (nf_nat_tftp && ct->status & IPS_NAT_MASK)
    //*如果ct做了NAT,就调用nf_nat_tftp指向的函数,这里它指向nf_nat_tftp.c中的help()函数。*/ ret
    = nf_nat_tftp(skb, ctinfo, exp);//如果ct做了NAT,就调用nf_nat_tftp指向的函数,这里它指向nf_nat_tftp.c中的help()函数 else if (nf_ct_expect_related(exp) != 0) {//调用nf_ct_expect_insert 插入 nf_ct_expect_hash 同时ct.expect_count++ nf_ct_helper_log(skb, ct, "cannot add expectation"); ret = NF_DROP; } nf_ct_expect_put(exp); break; case TFTP_OPCODE_DATA: case TFTP_OPCODE_ACK: pr_debug("Data/ACK opcode "); break; case TFTP_OPCODE_ERROR: pr_debug("Error opcode "); break; default: pr_debug("Unknown opcode "); } return ret; } /* 首先仍然是获取协议信息 proto = ip_ct_find_proto((*pskb)->nh.iph->protocol); 然后调用resolve_normal_ct() ct = resolve_normal_ct(*pskb, proto,&set_reply,hooknum,&ctinfo) 在resolve_normal_ct函数内部, get_tuple(skb->nh.iph, skb, skb->nh.iph->ihl*4, &tuple, proto) 然后查找hash表 h = ip_conntrack_find_get(&tuple, NULL) 这时候,还是举刚才的例子,如果之前曾有这样的数据包通过: A(10.10.10.1:1001) ——> B(10.10.10.2:69) 那么相应的连接状态应该已经建立了,这时候如果有完全相反的数据包 B(10.10.10.2:69) ——> A(10.10.10.1:1001) 那么在搜索hash表的时候就能顺利找到对应的连接,找到以后就简单了 skb->nfct = &h->ctrack->infos[*ctinfo] 将连接状态附值给nfct位,接下来就没什么特别的事情要做了,设置几个标志位,检查一下数据包等等 我们主要关心的还是这样的情况,如果后续的数据包是这样的: B(10.10.10.2:1002) ——> A(10.10.10.1:1001) 那么程序执行到ip_conntrack_find_get处时,会发现在hash表里找不到与当前数据包相匹配的连接,于是还是调用init_conntrack()创建连接 接下来的部分都和初始连接一样,计算hash值,初始化一个ip_conntrack等等,直到: expected = LIST_FIND(&ip_conntrack_expect_list, expect_cmp, struct ip_conntrack_expect *, tuple) 之前注册的expect被找到了,找到以后,进行下面的操作 if (expected) { DEBUGP("conntrack: expectation arrives ct=%p exp=%p ", conntrack, expected); /* 设置状态位为IPS_EXPECTED_BIT,当前的连接是一个预期的连接 */ __set_bit(IPS_EXPECTED_BIT, &conntrack->status); /* conntrack的master位指向搜索到的expected,而expected的sibling位指向conntrack……..解释一下, 这时候有两个conntrack,一个是一开始的初始连接(比如69端口的那个)也就是主连接conntrack1, 一个是现在正在处理的连接(1002)子连接conntrack2,两者和expect的关系是: 1. expect的sibling指向conntrack2,而expectant指向conntrack1, 2. 一个主连接conntrack1可以有若干个expect(int expecting表示当前数量),这些 expect也用一个链表组织,conntrack1中的struct list_head sibling_list就是该 链表的头。 3. 一个子连接只有一个主连接,conntrack2的struct ip_conntrack_expect *master 指向expect 通过一个中间结构expect将主连接和子连接关联起来 */ conntrack->master = expected; expected->sibling = conntrack; /* 将此连接从预期连接全局链表里删除(只是从全局链表里删除,但连接本身还在),因为此连接即将被正式添加到全局的连接表里, 所以下次如果再有B(10.10.10.2:1002) ——> A(10.10.10.1:1001)这样的数据包,就能直接从hash表里找到了,不必再借用expect。 因此主连接中的预期连接数expecting也自动减1 LIST_DELETE(&ip_conntrack_expect_list, expected); expected->expectant->expecting--; nf_conntrack_get(&master_ct(conntrack)->infos[0]); } 接下来,如果expect里的expectfn函数有定义的话就执行它(一般是没有的) if (expected && expected->expectfn) expected->expectfn(conntrack); 后面的过程略去了,和之前差不多 */

    1/ 根据数据包的ct初始化一个expect连接;tftp请求(读或写)只能从client到server

    static int nf_ct_expect_insert(struct nf_conntrack_expect *exp)
    {
    
        /* 获得exp->master的help */
        struct nf_conn_help *master_help = nfct_help(exp->master);
        struct nf_conntrack_helper *helper;
        struct net *net = nf_ct_exp_net(exp);
        unsigned int h = nf_ct_expect_dst_hash(net, &exp->tuple);
    
        /* two references : one for hash insert, one for the timer */
        atomic_add(2, &exp->use);
    /* 插入到help->expectations链表 */
        hlist_add_head(&exp->lnode, &master_help->expectations);////如果有多个相关联的期望连接,链接起来
        master_help->expecting[exp->class]++;
    /* 插入到全局的expect_hash表 */
        hlist_add_head_rcu(&exp->hnode, &nf_ct_expect_hash[h]);
        net->ct.expect_count++;
    /* 设置并启动定时器 */
        setup_timer(&exp->timeout, nf_ct_expectation_timed_out,
                (unsigned long)exp);
        helper = rcu_dereference_protected(master_help->helper,
                           lockdep_is_held(&nf_conntrack_expect_lock));
        if (helper) {
            exp->timeout.expires = jiffies +
                helper->expect_policy[exp->class].timeout * HZ;
        }
        add_timer(&exp->timeout);
    
        NF_CT_STAT_INC(net, expect_create);
        return 0;
    }

    当tftp请求包进入nf_conntrack_in的时候,由于没有ct条目,所以调用init_conntrack()尝试新建一个条目,在这个函数中,根据skb新建两个方向的tuple,:

        local_bh_disable();
        /*
    会在全局的期望连接链表expect_hash中查找是否有匹配新建tuple的期望连接。第一次过来的数据包肯定是没有的,
    于是走else分支,__nf_ct_try_assign_helper()函数去nf_ct_helper_hash哈希表中匹配当前tuple,
    由于我们在本节开头提到nf_conntrack_tftp_init()已经把tftp的helper extension添加进去了,
    所以可以匹配成功,于是把找到的helper赋值给nfct_help(ct)->helper,而这个helper的help方法就是tftp_help()。
    当tftp请求包走到ipv4_confirm的时候,会去执行这个help方法,即tftp_help(),也就是建立一个期望连接
    当后续tftp传输数据时,在nf_conntrack_in里面,新建tuple后,在expect_hash表中查可以匹配到新建tuple的期望连接(因为只根据源端口来匹配),
    因此上面代码的if成立,所以ct->master被赋值为exp->master,并且,还会执行exp->expectfn()函数,这个函数上面提到是指向nf_nat_follow_master()的,
    该函数根据ct的master来给ct做NAT,ct在经过这个函数处理前后的tuple分别为:
        */
        /* 在helper 函数中 回生成expect 并加入全局链表 同时 expect_count++*/
        if (net->ct.expect_count) {
            /* 如果在期望连接链表中 */
            spin_lock(&nf_conntrack_expect_lock);
            exp = nf_ct_find_expectation(net, zone, tuple);
             /* 如果在期望连接链表中 */
            if (exp) {
                pr_debug("expectation arrives ct=%p exp=%p
    ",
                     ct, exp);
                /* Welcome, Mr. Bond.  We've been expecting you... */
                __set_bit(IPS_EXPECTED_BIT, &ct->status);
    /* conntrack的master位指向搜索到的expected,而expected的sibling位指向conntrack……..解释一下,这时候有两个conntrack,
    一个是一开始的初始连接(比如69端口的那个)也就是主连接conntrack1,
    一个是现在正在处理的连接(1002)子连接conntrack2,两者和expect的关系是:
    1. expect的sibling指向conntrack2,而expectant指向conntrack1,
    2. 一个主连接conntrack1可以有若干个expect(int expecting表示当前数量),这些
    expect也用一个链表组织,conntrack1中的struct list_head sibling_list就是该
    链表的头。
    3. 一个子连接只有一个主连接,conntrack2的struct ip_conntrack_expect *master
        指向expect
    通过一个中间结构expect将主连接和子连接关联起来 */
                /* exp->master safe, refcnt bumped in nf_ct_find_expectation */
                ct->master = exp->master;
                if (exp->helper) {/* helper的ext以及help链表分配空间 */
                    help = nf_ct_helper_ext_add(ct, exp->helper,
                                    GFP_ATOMIC);
                    if (help)
                        rcu_assign_pointer(help->helper, exp->helper);
                }
    
    #ifdef CONFIG_NF_CONNTRACK_MARK
                ct->mark = exp->master->mark;
    #endif
    #ifdef CONFIG_NF_CONNTRACK_SECMARK
                ct->secmark = exp->master->secmark;
    #endif
                NF_CT_STAT_INC(net, expect_new);
            }
            spin_unlock(&nf_conntrack_expect_lock);
        }
        
        /*在全局的期望连接链表expect_hash中查找是否有匹配新建tuple的期望连接。  
    第一次过来的数据包肯定是没有的,于是走else分支,__nf_ct_try_assign_helper() 
    函数去nf_ct_helper_hash哈希表中匹配当前tuple,由于我们在本节开头提到nf_conntrack_tftp_init() 
    已经把tftp的helper extension添加进去了,所以可以匹配成功,于是把找到的helper赋值给nfct_help(ct)->helper, 
    而这个helper的help方法就是tftp_help()  */
        if (!exp) {// 如果不存在 从新赋值  ct->ext->...->help->helper = helper
            __nf_ct_try_assign_helper(ct, tmpl, GFP_ATOMIC);
            NF_CT_STAT_INC(net, new);
        }
    
        /* Now it is inserted into the unconfirmed list, bump refcount */
        nf_conntrack_get(&ct->ct_general);
        /* 将这个tuple添加到unconfirmed链表中,因为数据包还没有出去,
        所以不知道是否会被丢弃,所以暂时先不添加到conntrack hash中 */
        nf_ct_add_to_unconfirmed_list(ct);
    
        local_bh_enable();
    
        if (exp) {
            if (exp->expectfn)
                exp->expectfn(ct, exp);
            nf_ct_expect_put(exp);

    在全局的期望连接链表expect_hash中查找是否有匹配新建tuple的期望连接。第一次过来的数据包肯定是没有的,于是走else分支,

    __nf_ct_try_assign_helper()函数去nf_ct_helper_hash哈希表中匹配当前tuple,由于nf_conntrack_tftp_init()已经把tftp的helper extension添加进去了,

    所以可以匹配成功,于是把找到的helper赋值给nfct_help(ct)->helper,而这个helper的help方法就是tftp_help
    当tftp请求包走到ipv4_confirm的时候,会去执行这个help方法,即tftp_help(),也就是建立一个期望连接
    当后续tftp传输数据时,在nf_conntrack_in里面,新建tuple后,在expect_hash表中查可以匹配到新建tuple的期望连接(因为只根据源端口来匹配),

    因此上面代码的if成立,所以ct->master被赋值为exp->master,并且,还会执行exp->expectfn()函数,这个函数上面提到是指向nf_nat_follow_master()的;

    tftp_help回调函数中会执行nf_nat_tftp_hook---》也就是 nat的help函数

     黄色部分内容是处理tftp请求包时初始化的,蓝色的数组是全局hash表。tftp数据包就根据上面的内容进行NAT转换;在nf_ct_find_expectation()查找期望连接时,

    如果找到了,就把该期望连接从struct nf_conn_help结构的链表以及全局的期望连接链表expect_hash中删除。在expect_hash中的连接通过设置的超时时间来超时;

    初始化help没有 命中tftp_help---》触发tftp_help时 会设置生成 nf_conntrack_expect 并插入全局连表, 第二次时 能找到exp  所以执行exp ---expertfn

    来自:https://blog.csdn.net/jasonchen_gbd/article/details/44877343

    static unsigned int help(struct sk_buff *skb,
                 enum ip_conntrack_info ctinfo,
                 struct nf_conntrack_expect *exp)
    {
        const struct nf_conn *ct = exp->master;
    
        exp->saved_proto.udp.port
            = ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple.src.u.udp.port;
        exp->dir = IP_CT_DIR_REPLY;
        /*
    还注意到在help()函数中将exp-> expectfn赋值为nf_nat_follow_master(),这个函数的作用在后面会提到。
    上面的内容是在客户端发送tftp请求后触发的动作,主要的效果就是生成了一个期望连接并可以被使用了。
    下面以请求读数据来看一下传输数据时的数据包变化。
        */
        exp->expectfn = nf_nat_follow_master;
        if (nf_ct_expect_related(exp) != 0) {
            nf_ct_helper_log(skb, exp->master, "cannot add expectation");
            return NF_DROP;
        }
        return NF_ACCEPT;
    }
    
    static void __exit nf_nat_tftp_fini(void)
    {
        RCU_INIT_POINTER(nf_nat_tftp_hook, NULL);
        synchronize_rcu();
    }
    
    static int __init nf_nat_tftp_init(void)
    {
        BUG_ON(nf_nat_tftp_hook != NULL);
        RCU_INIT_POINTER(nf_nat_tftp_hook, help);
        return 0;
    }


  • 相关阅读:
    Java Formatter 阅读心得
    Android 应用的动画实践View Animation篇
    Hexo 简明入门教程(一)
    用Gradle 构建你的android程序依赖管理篇
    MQTT 折腾笔记协议简读
    ingress 在中国大众篇
    谈谈常见的移动应用设计风格
    用nodejs 改造一个移动版本的网站
    Android AdapterView 源码分析以及其相关回收机制的分析
    关于计算机类课程实验教学的思考
  • 原文地址:https://www.cnblogs.com/codestack/p/12853803.html
Copyright © 2011-2022 走看看