Android Iptables 客制化方法及基本使用

Android Iptables 客制化方法及基本使用

iptables 是一个在Linux内核集成的IP信息包过滤系统,用于控制IP信息包过滤和防火墙配置,通过存储在数据包过滤表中的规则,在不同的链中做出数据包过滤决定。
Android 是基于 Linux 的操作系统,支持 Iptables。执行 Iptables 命令需要 root 权限。

Android netd 的自定义链

以 INPUT 链为例子,Andorid 会自定义 bw_INPUT、fw_INPUT 等链,链到 INPUT 链上

shell 复制代码
# iptables -L 

Chain INPUT (policy ACCEPT)
target     prot opt source               destination                  
bw_INPUT   all  --  anywhere             anywhere            
fw_INPUT   all  --  anywhere             anywhere    

Controllers 执行 init() 方法时会先执行 initChildChains 方法, 在 INPUT 链下创造子链

c 复制代码
void Controllers::initChildChains() {
    /*
     * This is the only time we touch top-level chains in iptables; controllers
     * should only mutate rules inside of their children chains, as created by
     * the constants above.
     *
     * Modules should never ACCEPT packets (except in well-justified cases);
     * they should instead defer to any remaining modules using RETURN, or
     * otherwise DROP/REJECT.
     */

    // Create chains for child modules.
    createChildChains(V4V6, "filter", "INPUT", FILTER_INPUT, true);
    createChildChains(V4V6, "filter", "FORWARD", FILTER_FORWARD, true);
    createChildChains(V4V6, "raw", "PREROUTING", RAW_PREROUTING, true);
    createChildChains(V4V6, "mangle", "FORWARD", MANGLE_FORWARD, true);
    createChildChains(V4V6, "mangle", "INPUT", MANGLE_INPUT, true);
    createChildChains(V4, "nat", "PREROUTING", NAT_PREROUTING, true);
    createChildChains(V4, "nat", "POSTROUTING", NAT_POSTROUTING, true);

    createChildChains(V4, "filter", "OUTPUT", FILTER_OUTPUT, false);
    createChildChains(V6, "filter", "OUTPUT", FILTER_OUTPUT, false);
    createChildChains(V4, "mangle", "POSTROUTING", MANGLE_POSTROUTING, false);
    createChildChains(V6, "mangle", "POSTROUTING", MANGLE_POSTROUTING, false);
}

FILTER_INPUT 是子链的集合,定义顺序,会从上往下添加,先添加的子链具有较高的优先级,以 FILTER_INPUT 为例,FILTER_INPUT 定义为:

c 复制代码
/**
 * List of module chains to be created, along with explicit ordering. ORDERING
 * IS CRITICAL, AND SHOULD BE TRIPLE-CHECKED WITH EACH CHANGE.
 */
static const std::vector<const char*> FILTER_INPUT = {
        // Bandwidth should always be early in input chain, to make sure we
        // correctly count incoming traffic against data plan.
        BandwidthController::LOCAL_INPUT,
        FirewallController::LOCAL_INPUT,
};

createChildChains 方法定义如下

c 复制代码
void Controllers::createChildChains(IptablesTarget target, const char* table,
                                    const char* parentChain,
                                    const std::vector<const char*>& childChains,
                                    bool exclusive) {
    std::string command = StringPrintf("*%s\n", table);

    // We cannot just clear all the chains we create because vendor code modifies filter OUTPUT and
    // mangle POSTROUTING directly. So:
    //
    // - If we're the exclusive owner of this chain, simply clear it entirely.
    // - If not, then list the chain's current contents to ensure that if we restart after a crash,
    //   we leave the existing rules alone in the positions they currently occupy. This is faster
    //   than blindly deleting our rules and recreating them, because deleting a rule that doesn't
    //   exists causes iptables-restore to quit, which takes ~30ms per delete. It's also more
    //   correct, because if we delete rules and re-add them, they'll be in the wrong position with
    //   regards to the vendor rules.
    //
    // TODO: Make all chains exclusive once vendor code uses the oem_* rules.
    std::set<std::string> existingChildChains;
    if (exclusive) {
        // Just running ":chain -" flushes user-defined chains, but not built-in chains like INPUT.
        // Since at this point we don't know if parentChain is a built-in chain, do both.
        StringAppendF(&command, ":%s -\n", parentChain);
        StringAppendF(&command, "-F %s\n", parentChain);
    } else {
        existingChildChains = findExistingChildChains(target, table, parentChain);
    }

    for (const auto& childChain : childChains) {
        // Always clear the child chain.
        StringAppendF(&command, ":%s -\n", childChain);
        // But only add it to the parent chain if it's not already there.
        if (existingChildChains.find(childChain) == existingChildChains.end()) {
            StringAppendF(&command, CHILD_CHAIN_TEMPLATE, parentChain, childChain);
        }
    }
    command += "COMMIT\n";
    execIptablesRestore(target, command);
}

该方法的核心思路就是拼 command 字符串,语法与 iptables-save 生成文件的语法类似,最后调用 execIptablesRestore 执行 command,向 iptables 发送命令

此处生成的 command 为

复制代码
04-03 15:31:55.872  1106  1106 E Netd    :  *filter
04-03 15:31:55.872  1106  1106 E Netd    : :INPUT -
04-03 15:31:55.872  1106  1106 E Netd    : -F INPUT
04-03 15:31:55.872  1106  1106 E Netd    : :bw_INPUT -
04-03 15:31:55.872  1106  1106 E Netd    : -A INPUT -j bw_INPUT
04-03 15:31:55.872  1106  1106 E Netd    : :fw_INPUT -
04-03 15:31:55.872  1106  1106 E Netd    : -A INPUT -j fw_INPUT
04-03 15:31:55.872  1106  1106 E Netd    : COMMIT

NetdConstants.cpp 的 execIptablesRestore 方法

execIptablesRestore 方法的实现定义在 NetdConstants.cpp

c 复制代码
int execIptablesRestoreWithOutput(IptablesTarget target, const std::string& commands,
                                  std::string *output) {
    return android::net::gCtls->iptablesRestoreCtrl.execute(target, commands, output);
}

int execIptablesRestore(IptablesTarget target, const std::string& commands) {
    return execIptablesRestoreWithOutput(target, commands, nullptr);
}

本质上是调用 IptablesRestoreController 的 execute 方法

IptablesRestoreController 的 execute 方法

c 复制代码
int IptablesRestoreController::execute(const IptablesTarget target, const std::string& command,
                                       std::string *output) {
    std::lock_guard lock(mLock);

    std::string buffer;
    if (output == nullptr) {
        output = &buffer;
    } else {
        output->clear();
    }

    int res = 0;
    if (target == V4 || target == V4V6) {
        res |= sendCommand(IPTABLES_PROCESS, command, output);
    }
    if (target == V6 || target == V4V6) {
        res |= sendCommand(IP6TABLES_PROCESS, command, output);
    }
    return res;
}

sendCommand 方法定义如下:

c 复制代码
int IptablesRestoreController::sendCommand(const IptablesProcessType type,
                                           const std::string& command,
                                           std::string *output) {
   std::unique_ptr<IptablesProcess> *process =
           (type == IPTABLES_PROCESS) ? &mIpRestore : &mIp6Restore;


    // We might need to fork a new process if we haven't forked one yet, or
    // if the forked process terminated.
    //
    // NOTE: For a given command, this is the last point at which we try to
    // recover from a child death. If the child dies at some later point during
    // the execution of this method, we will receive an EPIPE and return an
    // error. The command will then need to be retried at a higher level.
    IptablesProcess *existingProcess = process->get();
    if (existingProcess != nullptr && !existingProcess->outputReady()) {
        existingProcess->stop();
        existingProcess = nullptr;
    }

    if (existingProcess == nullptr) {
        // Fork a new iptables[6]-restore process.
        IptablesProcess *newProcess = IptablesRestoreController::forkAndExec(type);
        if (newProcess == nullptr) {
            LOG(ERROR) << "Unable to fork ip[6]tables-restore, type: " << type;
            return -1;
        }

        process->reset(newProcess);
    }

    if (!android::base::WriteFully((*process)->stdIn, command.data(), command.length())) {
        ALOGE("Unable to send command: %s", strerror(errno));
        return -1;
    }

    if (!android::base::WriteFully((*process)->stdIn, PING, PING_SIZE)) {
        ALOGE("Unable to send ping command: %s", strerror(errno));
        return -1;
    }

    if (!drainAndWaitForAck(*process, command, output)) {
        // drainAndWaitForAck has already logged an error.
        return -1;
    }

    return 0;
}

这里 process 其实就是 /system/bin/iptables-restore 的进程,WriteFully 方法所在位置实际就是把 command 数据重定向输入到 /system/bin/iptables-restore ,与下面命令等价

shell 复制代码
iptables-restore < command

使用 oem-iptables-init.sh 添加自定义的防火墙规则

/system/netd/server/oem_iptables_hook.cpp

/system/netd/server/NetdConstants.cpp

在 Controllers::initIptablesRules 先通过 initChildChains() 初始化 INPUT OUTPUT 等链,再通过 setupOemIptablesHook 加载 ODM 规则,随后在加载系统防火墙规则

c 复制代码
void Controllers::initIptablesRules() {
    Stopwatch s;
    initChildChains(); // 初始化 INPUT OUTPUT 等链
    ALOGI("Creating child chains: %.1fms", s.getTimeAndReset());

    // Let each module setup their child chains
    setupOemIptablesHook(); // 加载 OEM 规则
    ALOGI("Setting up OEM hooks: %.1fms", s.getTimeAndReset());

    /* When enabled, DROPs all packets except those matching rules. */
    firewallCtrl.setupIptablesHooks(); // 加载防火墙规则
    ALOGI("Setting up FirewallController hooks: %.1fms", s.getTimeAndReset());

    /* Does DROPs in FORWARD by default */
    tetherCtrl.setupIptablesHooks();
    ALOGI("Setting up TetherController hooks: %.1fms", s.getTimeAndReset());

    /*
     * Does REJECT in INPUT, OUTPUT. Does counting also.
     * No DROP/REJECT allowed later in netfilter-flow hook order.
     */
    bandwidthCtrl.setupIptablesHooks();
    ALOGI("Setting up BandwidthController hooks: %.1fms", s.getTimeAndReset());

    /*
     * Counts in nat: PREROUTING, POSTROUTING.
     * No DROP/REJECT allowed later in netfilter-flow hook order.
     */
    idletimerCtrl.setupIptablesHooks();
    ALOGI("Setting up IdletimerController hooks: %.1fms", s.getTimeAndReset());

    /*
     * Add rules for detecting IPv6/IPv4 TCP/UDP connections with TLS/DTLS header
     */
    strictCtrl.setupIptablesHooks();
    ALOGI("Setting up StrictController hooks: %.1fms", s.getTimeAndReset());
}

oem-iptables-init.sh 示例文件

shell 复制代码
#!/bin/bash
/system/bin/iptables -A INPUT -p tcp --destination-port 5555 -s 127.0.0.1 -j ACCEPT
/system/bin/iptables -A INPUT -p tcp --destination-port 5555 -j DROP

基本概念

Iptables 链

  • INPUT:处理入站数据包
  • OUTPUT:处理出站数据包
  • FORWARD:处理转发数据包
  • POSTROUTING:在进行路由选择后处理数据包(对数据链进行源地址修改转换)
  • PREROUTING:在进行路由选择前处理数据包(做目标地址转换)

Iptables 表

  1. raw表:确定是否对该数据包进行状态跟踪以及处理异常,表内包含两个链:OUTPUT、PREROUTING
  2. mangle表:为数据包的 TOS(服务类型)、TTL(生命周期)值,或者为数据包设置 Mark 标记,以实现流量整形、策略路由等高级应用。其对应 iptable_mangle,表内包含五个链:PREROUTING、POSTROUTING、INPUT、OUTPUT、FORWARD
  3. nat表:修改数据包中的源、目标IP地址或端口;其对应的模块为 iptable_nat,表内包括三个链:PREROUTING、POSTROUTING、OUTPUT(centos7中还有 INPUT,centos6 中没有)
  4. filter表:确定是否放行该数据包(过滤);其对应的内核模块为 iptable_filter,表内包含三个链:INPUT、FORWARD、OUTPUT。

表、链规则检查顺序

按顺序依次检查,匹配即停止(LOG策略例外),若找不到相匹配的规则,则按该链的默认策略处理

规则

匹配条件

  1. 源地址 Source IP,目标地址 Destination IP
  2. 源端口 Source Port, 目标端口 Destination Port
  3. ...等等

处理动作

处理动作在 iptables 中被称为 target,此处列出一些常用的动作:
ACCEPT :允许数据包通过
DROP :直接丢弃数据包,不给任何回应信息,客户端不会收到任何回应
REJECT :拒绝数据包通过,必要时会给数据发送端一个响应的信息,客户端刚请求就会收到拒绝的信息

SNAT:源地址转换,解决内网用户用同一个公网地址上网的问题

MASQUERADE:是 SNAT 的一种特殊形式,适用于动态的、临时会变的 ip 上

DNAT:目标地址转换

REDIRECT:在本机做端口映射

iptables 规则管理

信息查询

shell 复制代码
# filter负责过滤功能,比如允许哪些IP地址访问,拒绝哪些IP地址访问,允许访问哪些端口,禁止访问哪些端口
# filter表会根据我们定义的规则进行过滤,filter表应该是我们最常用到的表了
# 下面两种都可以
# 默认不加-t就是指的filter表
iptables -t filter --list
iptables -t raw -L
iptables -t mangle -L
iptables -t nat -L

# -v是显示详细的信息,列出INPUT链的详细信息
iptables -vL INPUT
# 不让IP进行反解
iptables -nvL INPUT
# 显示规则的序号,--line-numbers选项表示显示规则的序号,注意,此选项为长选项,不能与其他短选项合并,不过此选项可以简写为--line
iptables --line-numbers -t 表名 -L
# 表示查看表中的所有规则,并且显示更详细的信息(-v选项),不过,计数器中的信息显示为精确的计数值,而不是显示为经过可读优化的计数值,-x选项表示显示计数器的精确值
iptables -t 表名 -v -x -L
# 可以合起来,不过-L在最后
iptables --line -t filter -nvxL INPUT

# -----------------------显示界面解释-----------------------
# Chain INPUT (policy ACCEPT 170M packets, 33G bytes)
# policy表示当前链的默认策略,policy ACCEPT表示INPUT的链的默认动作为ACCEPT,换句话说就是,默认接受通过INPUT关卡的所有请求,所以我们在配置INPUT链的具体规则时,应该将需要拒绝的请求配置到规则中
# 说白了就是"黑名单"机制,默认所有人都能通过,只有指定的人不能通过,当我们把INPUT链默认动作设置为接受(ACCEPT),就表示所有人都能通过这个关卡,此时就应该在具体的规则中指定需要拒绝的请求,就表示只有指定的人不能通过这个关卡,这就是黑名单机制
# packets表示当前链(上例为INPUT链)默认策略匹配到的包的数量,0 packets表示默认策略匹配到0个包。
# bytes表示当前链默认策略匹配到的所有包的大小总和。
# 其实,我们可以把packets与bytes称作"计数器",上图中的计数器记录了默认策略匹配到的报文数量与总大小,"计数器"只会在使用-v选项时,才会显示出来

# pkts:对应规则匹配到的报文的个数。
# bytes:对应匹配到的报文包的大小总和。
# target:规则对应的target,往往表示规则对应的"动作",即规则匹配成功后需要采取的措施。
# prot:表示规则对应的协议,是否只针对某些协议应用此规则。
# opt:表示规则对应的选项。
# in:表示数据包由哪个接口(网卡)流入,即从哪个网卡来。
# out:表示数据包将由哪个接口(网卡)流出,即到哪个网卡去。
# source:表示规则对应的源头地址,可以是一个IP,也可以是一个网段。
# destination:表示规则对应的目标地址。可以是一个IP,也可以是一个网段

规则添加

注意:添加规则时,规则的顺序非常重要,哪个先匹配就执行哪个,后面就算有一模一样的也不会执行

shell 复制代码
# 在指定表的指定链的尾部添加一条规则,-A选项表示在对应链的末尾添加规则,省略-t选项时,表示默认操作filter表中的规则
iptables -t 表名 -A 链名 匹配条件 -j 动作
# 举例,表示丢弃来自192.168.1.146的数据包
# 使用-s选项,指明"匹配条件"中的"源地址",即如果报文的源地址属于-s对应的地址,那么报文则满足匹配条件,-s为source之意,表示源地址
iptables -t filter -A INPUT -s 192.168.1.146 -j DROP

# 在指定表的指定链的首部添加一条规则,-I选型表示在对应链的开头添加规则
iptables -t 表名 -I 链名 匹配条件 -j 动作
# 举例
iptables -t filter -I INPUT -s 192.168.1.146 -j ACCEPT

# 在指定表的指定链的指定位置添加一条规则
iptables -t 表名 -I 链名 规则序号 匹配条件 -j 动作
iptables -t filter -I INPUT 5 -s 192.168.1.146 -j REJECT

# 设置指定表的指定链的默认策略(默认动作),并非添加规则。
iptables -t 表名 -P 链名 动作
# 表示将filter表中FORWARD链的默认策略设置为ACCEPT
iptables -t filter -P FORWARD ACCEPT

规则删除

shell 复制代码
# 按照规则序号删除规则,删除指定表的指定链的指定规则,-D选项表示删除对应链中的规则
iptables -nvL --line-numbers
iptables -t 表名 -D 链名 规则序号
# 表示删除filter表中INPUT链中序号为3的规则
iptables -t filter -D INPUT 3

# 按照具体的匹配条件与动作删除规则,删除指定表的指定链的指定规则
iptables -t 表名 -D 链名 匹配条件 -j 动作
# 表示删除filter表中INPUT链中源地址为192.168.1.146并且动作为DROP的规则
iptables -t filter -D INPUT -s 192.168.1.146 -j DROP

# 删除指定表的指定链中的所有规则,-F选项表示清空对应链中的规则,执行时需三思
iptables -t 表名 -F 链名
iptables -t filter -F INPUT

# 删除指定表中的所有规则,执行时需三思
iptables -t 表名 -F
iptables -t filter -F

规则修改

shell 复制代码
# 修改指定表中指定链的指定规则,-R选项表示修改对应链中的规则,使用-R选项时要同时指定对应的链以及规则对应的序号,并且规则中原本的匹配条件不可省略
iptables -t 表名 -R 链名 规则序号 规则原本的匹配条件 -j 动作
# 修改filter表中INPUT链的第3条规则,将这条规则的动作修改为ACCEPT, -s 192.168.1.146为这条规则中原本的匹配条件,如果省略此匹配条件,修改后的规则中的源地址可能会变为0.0.0.0/0
iptables -t filter -R INPUT 3 -s 192.168.1.146 -j ACCEPT
# 其他修改规则的方法:先通过编号删除规则,再在原编号位置添加一条规则

# 修改指定表的指定链的默认策略(默认动作),并非修改规则,可以使用如下命令
iptables -t 表名 -P 链名 动作
# 将filter表中FORWARD链的默认策略修改为ACCEPT
iptables -t filter -P FORWARD ACCEPT

规则匹配

当规则中同时存在多个匹配条件时,多个条件之间默认存在"与"的关系,即报文必须同时满足所有条件,才能被规则匹配

shell 复制代码
# --------------------匹配条件:目标IP地址
# -s用于匹配报文的源地址,可以同时指定多个源地址,每个IP之间用逗号隔开,也可以指定为一个网段
# 逗号两侧均不能包含空格,多个IP之间必须与逗号相连
iptables -t filter -I INPUT -s 192.168.1.111,192.168.1.118 -j DROP
iptables -t filter -I INPUT -s 192.168.1.0/24 -j ACCEPT
# 只要发往本机的报文的源地址不是192.168.1.146,就接受报文
iptables -t filter -I INPUT ! -s 192.168.1.0/24 -j ACCEPT

# -d用于匹配报文的目标地址,可以同时指定多个目标地址,每个IP之间用逗号隔开,也可以指定为一个网段
# 所有IP发送往111,118的报文都将被丢弃
iptables -t filter -I OUTPUT -d 192.168.1.111,192.168.1.118 -j DROP
iptables -t filter -I INPUT -d 192.168.1.0/24 -j ACCEPT
# 不管是-s选项还是-d选项,取反操作与同时指定多个IP的操作不能同时使用
iptables -t filter -I INPUT ! -d 192.168.1.0/24 -j ACCEPT

# -------------------匹配条件:协议类型
# -p用于匹配报文的协议类型,可以匹配的协议类型tcp、udp、udplite、icmp、esp、ah、sctp等(centos7中还支持icmpv6、mh)
iptables -t filter -I INPUT -p tcp -s 192.168.1.146 -j ACCEPT
iptables -t filter -I INPUT ! -p udp -s 192.168.1.146 -j ACCEPT

# ---------------匹配条件:网卡接口
# -i用于匹配报文是从哪个网卡接口流入本机的,由于匹配条件只是用于匹配报文流入的网卡
# 所以在OUTPUT链与POSTROUTING链中不能使用此选项
# 拒绝由网卡eth4流入的ping请求报文
iptables -t filter -I INPUT -p icmp -i eth4 -j DROP
iptables -t filter -I INPUT -p icmp ! -i eth4 -j DROP

# -o用于匹配报文将要从哪个网卡接口流出本机,于匹配条件只是用于匹配报文流出的网卡,所以在INPUT链与PREROUTING链中不能使用此选项。
iptables -t filter -I OUTPUT -p icmp -o eth4 -j DROP
iptables -t filter -I OUTPUT -p icmp ! -o eth4 -j DROP

自定义链

shell 复制代码
# 创建自定义链
#示例:在filter表中创建IN_WEB自定义链,省略-t选项时,缺省操作的就是filter表
iptables -t filter -N IN_WEB
# 可以看到,这条自定义链的引用计数为0 (0 references),就是说,这条自定义链还没有被任何默认链所引用
iptables -nvL

# 自定义链中配置规则,和其他一样
iptables -t filter -I IN_WEB -s 192.168.1.139 -j REJECT
iptables -I IN_WEB -s 192.168.1.188 -j REJECT
iptables -t filter --line -nvL IN_WEB


# 引用自定义链
#示例:在INPUT链中引用刚才创建的自定义链
iptables -t filter -I INPUT -p tcp --dport 80 -j IN_WEB

# 重命名自定义链
#示例:将IN_WEB自定义链重命名为WEB
iptables -E IN_WEB WEB

# 删除自定义链
# 删除自定义链需要满足两个条件:自定义链没有被引用;自定义链中没有任何规则
#示例:删除引用计数为0并且不包含任何规则的WEB链
iptables -D INPUT 1
iptables -t filter -F WEB
# 使用"-X"选项可以删除一个引用计数为0的、空的自定义链
iptables -X WEB

Iptables 保存规则

命令

shell 复制代码
iptables-save [-M modprobe] [-c] [-t table] [-f filename]

-M, --modprobe <modprobe_program>
        指定 modprobe 程序的路径。默认情况下,iptables-save 将检查 /proc/sys/kernel/modprobe 以确定可执行文件的路径。
-f, --file <filename>
        指定要记录输出到的文件名。如果没有指定,将输出到 STDOUT。
-c, --counters
        在输出中包含所有数据包和字节计数器的当前值。
-t, --table <tablename>
        将输出限制为一个表。如果内核配置了自动模块加载,如果该表不存在,则会尝试为该表加载相应的模块。

        如果未指定,输出所有可用的表。

示例

shell 复制代码
iptables-save  -t filter > filter.bak

储存结果

shell 复制代码
iptables-save -c
# Generated by iptables-save v1.4.21 on Sat Dec 17 17:05:12 2022
*filter
:INPUT ACCEPT [8397115:1774409253]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [8446458:1291918085]
COMMIT
# Completed on Sat Dec 17 17:05:12 2022

其中:

复制代码
"#"号开头的表示注释;
"*filter"表示所在的表;
":链名默认策略"表示相应的链及默认策略,具体的规则部分省略了命令名"iptables";
在末尾处"COMMIT"表示提交前面的规则设置。

导入规则

iptables-restore 命令

shell 复制代码
iptables-restore < 文件名称

参考

https://blog.csdn.net/lemon_TT/article/details/130018867

相关推荐
Jerry说前后端40 分钟前
Android 数据可视化开发:从技术选型到性能优化
android·信息可视化·性能优化
三坛海会大神5551 小时前
计算机网络参考模型与子网划分
网络·计算机网络
云卓SKYDROID1 小时前
无人机激光测距技术应用与挑战
网络·无人机·吊舱·高科技·云卓科技
Meteors.2 小时前
Android约束布局(ConstraintLayout)常用属性
android
alexhilton2 小时前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw6 小时前
安卓图片性能优化技巧
android
风往哪边走6 小时前
自定义底部筛选弹框
android
iナナ7 小时前
传输层协议——UDP和TCP
网络·网络协议·tcp/ip·udp
Yyyy4827 小时前
MyCAT基础概念
android
Android轮子哥8 小时前
尝试解决 Android 适配最后一公里
android