Android定制实现上网限制iptables

  • Post author:
  • Post category:其他


随着智能手机和平板的普及,现在的孩子几乎人手一部手机或平板,所以常常能看到一些孩子抱着手机玩游戏或是浏览网页,一玩就是一整天,家长们不免担心自己的孩子是不是会浏览不适合他们看的网页?是不是玩的时间太长,导致他们对其他的事情(比如运动、学习和沟通等)丧失兴趣,或者对身体发育造成不良影响。所以,有必要对孩子们用的这些移动智能设备做些上网限制,主要就是上网时间和允许浏览的网站的限制。当然,现在已经有许多应用能够实现这样的功能,但大多都是在应用层实现,要么强制使用自己定制的浏览器,要么强制使用自己的launcher,看起来都不太友好。当我们能够定制一款自己的android设备的时候,貌似有更好的解决方案。下面我们来梳理一下可能的解决方案。

一. 选择方案。

  1. 分家长端和孩子端,通常还会有个门户网站。

    这种方式一般都是在其他应用之上显示内容,即强制使用定制的launcher或浏览器。孩子端一般是android移动设备,如android手机、平板。家长端要么是另一台android设备,要么是跟孩子同一台设备,分两种不同的模式,通过密码切换。家长端也有可能直接是一个门户网站,通过登录此网站,监管孩子端的行为。代表应用有”NQ Family Guardian”、 “中国联通绿色上网管理平台”、 “绿色上网”、”kid launcher”等。

    此种方案的优点是管理全面,容易做网站过滤;缺点是操作复杂,且不友好,还有被强制删除的风险。

  2. 通过代理Proxy服务器。

    在代理服务器端进行网站过滤和上网时间限制,android端无线WLAN设置使用代理上网。具体可参考CCProxy 。

    该方案优点是网站过滤较为灵活,可以过滤网站、站点、内容,禁止下载文件,禁止用webmail收发邮件等;缺点是要维护服务器,且android对代理的支持没有那么成熟。

  3. DNS变换。

    该方式是修改android设备的域名服务器为openDNS FamilyShield的primary DNS(208.67.222.123)或alternative DNS(208.67.220.123),这两个域名服务器会帮我们过滤掉不良网站。

    优点是实现简单,缺点是服务器在国外,使用起来会严重降低网页的响应速度,且需要root设备。

  4. 修改hosts文件。

    hosts文件的作用是将常用的网址域名与其对应的IP地址建立关联。当用户在浏览器中输入一个需要登录的网址时,系统会首先自动从hosts文件中寻找对应的IP地址,一旦找到,系统会立即打开对应网页,如果没有找到,则系统会再将网址提交到DNS域名解析服务器进行IP地址的解析。当我们将要限制的网址域名与回路地址127.0.0.1对应时,那么就相当于禁止了该网站。如,我们打开android设备的/ets/hosts文件,新起一行,输入

    127.0.0.1 www.baidu.com

    ,之后,我们在浏览器中输入”www.baidu.com”,网页打不开。

    优点是简单快捷,缺点是不灵活,不能限制上网时间,且需要root设备,因为普通的权限不能写hosts文件。

  5. iptables.

    iptables是Linux系统的IP信息包过滤工具,实际就是一个Linux命令,通过这个命令,可以对整个系统发出去的包,接收到的包,以及转发的包进行拦截、修改、拒绝等操作。刚好Android也是基于Linux内核的系统,也集成了iptables,是否可以用它来限制上网行为呢?经过试验,发现是可行的。

    优点:能从底层限制访问某些网站,而不必在浏览器层面阻止,不仅减少了定制或修改浏览器的成本,而且控制灵活,因为能够定制各种策略;缺点:运行iptables需要root权限。

经过综合比较,最后还是选择了用iptables实现上网控制的方案。然而我们不能假想设备一定具有root权限,所以直接在应用层调用iptables命令肯定是不行的。于是想是不是能在android系统层调用iptables命令呢,这样不就轻易解决问题了?如何在android系统层添加服务下篇文章再讨论,先看看在系统层调用iptables命令关闭网络的代码:

二. 系统层具体实现。

String cmd = new String[] {
    "iptables", "-P", "OUTPUT", "DROP"
};
try {
    process = Runtime.getRuntime().exec(cmd);
} catch (IOException e1) {
    e1.printStackTrace();        
}


运行之后,网络并没有关闭,连上设备,进入shell,输入


iptables -L -n


发现OUTPUT链是这样的:


Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         
mark       all  --  0.0.0.0/0            0.0.0.0/0           
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0           
oem_out    all  --  0.0.0.0/0            0.0.0.0/0           
fw_OUTPUT  all  --  0.0.0.0/0            0.0.0.0/0           
bw_OUTPUT  all  --  0.0.0.0/0            0.0.0.0/0           
st_filter_OUTPUT  all  --  0.0.0.0/0            0.0.0.0/0


说明OUTPUT链的规则并没有改变,也就是说命令没有执行或是执行了但是没效果。于是,尝试打印process的错误流,像这样:


InputStream stderr = process.getErrorStream();
...
while (true) {
    ...
    if (stderr.available() > 0) {
      read = stderr.read(buf);
      if (result != null) {
          result.append(new String(buf,   0, read));
      }
      Slog.d(TAG, "stderr: " + result.toString());
    }
}
Slog.d(TAG, "exec command over");


查看log,发现了这些信息:


02-18 13:55:29.720 546-7708/system_process D/GreenNetworkService: stderr: [-] set uid permission denied: Operation not permitted
02-18 13:55:29.720 546-7708/system_process D/GreenNetworkService: exec command over


提示没有权限, 说明不能在系统service进程直接执行iptables命令,那我们就换一个思路。好在我们在开机那段时间抓到了这一段log:




V/NatController(  122): runCmd(/system/bin/iptables -F natctrl_FORWARD) res=0
V/NatController(  122): runCmd(/system/bin/iptables -A natctrl_FORWARD -j DROP) res=0
D/NvOsDebugPrintf(  127):  modeIndex =2,xres =960,yres =720
V/NatController(  122): runCmd(/system/bin/iptables -t nat -F natctrl_nat_POSTROUTING) res=0
D/NvOsDebugPrintf(  127):  modeIndex =1,xres =1280,yres =720
I/AudioPolicyManagerBase(  127): loadAudioPolicyConfig() loaded /system/etc/audio_policy.conf
V/NatController(  122): runCmd(/system/bin/ip rule flush) res=0
V/NatController(  122): runCmd(/system/bin/ip -6 rule flush) res=0
V/NatController(  122): runCmd(/system/bin/ip rule add from all lookup default prio 32767) res=0
V/NatController(  122): runCmd(/system/bin/ip rule add from all lookup main prio 32766) res=0
V/NatController(  122): runCmd(/system/bin/ip -6 rule add from all lookup default prio 32767) res=0
V/NatController(  122): runCmd(/system/bin/ip -6 rule add from all lookup main prio 32766) res=0
V/NatController(  122): runCmd(/system/bin/ip route flush cache) res=0
V/NatController(  122): runCmd(/system/bin/iptables -F natctrl_tether_counters) res=1
V/NatController(  122): runCmd(/system/bin/iptables -X natctrl_tether_counters) res=1
V/NatController(  122): runCmd(/system/bin/iptables -N natctrl_tether_counters) res=0

为什么会特意找这一段,原因就是看看系统是不是调用了iptables.果不其然,被我们发现了,就是上面这一段,只要搜索关键字”iptables”就好。从上面的log看来,有在执行iptables的命令,因为有个”runCmd”关键字,再顺着此关键字找到打印此log的类NatController.cpp. 顺着这条线索,我们发现了NatController存在于Netd进程,而Netd又是作为Android Linux Kernel与Framework之间通信的桥梁,这就不难理解为什么系统Framework层调用Kernel层的iptables命令是通过Netd进程的NatController。

什么是Netd呢?Netd是Network Daemon 的缩写,表示Network守护进程。Netd负责跟一些涉及网络的配置,操作,管理,查询等相关的功能实现,比如,例如带宽控制(Bandwidth),流量统计,带宽控制,网络地址转换(NAT),个人局域网(pan),PPP链接,soft-ap,共享上网(Tether),配置路由表,interface配置管理,等等。(摘自博客

http://blog.csdn.net/xiaokeweng/article/details/8130218

Framework部分通常会有一个对应的service与本地进程(如Netd)通信,并且提供API供应用层调用,那么Android系统中哪个service负责网络这一块呢?带着这个问题,我们轻易地找到了NetworkManagementService,在源码中位于/frameworks/base/services/java/com/android/server/NetworkManagementService.java

查看NetworkManagementService的接口,看到了这个

@Override
    public void setFirewallEnabled(boolean enabled) {
        enforceSystemUid();
        try {
            mConnector.execute("firewall", enabled ? "enable" : "disable");
            mFirewallEnabled = enabled;
        } catch (NativeDaemonConnectorException e) {
            throw e.rethrowAsParcelableException();
        }
    }

找到核心语句,mConnector是NativeDaemonConnector的对象 ,位于源码中的 /frameworks/base/services/java/com/android/server/NativeDaemonConnector.java,通过socket发送字符串命令给Netd,并接收Netd返回的结果。

到此,思路就清晰了。我们可以尝试在NetworkManagementService中提供网络开关和允许上某个网站的API,应用层调用这些API,就可以实现不必root, 也可以控制上网的功能了。

回头再看刚才setFirewallEnabled那一段代码,像是开启/关闭防火墙的,我们在应用程序中调用看看。

@Override
    public void setFirewallEnable(boolean enable) {
        GreenNetworkManager networkManagement =   (GreenNetworkManager)mContext.getSystemService(com.test.greennetwork.util.CommonUtil.GREEN_NETWORK_SERVICE);
        if(enable) {
            networkManagement.disableAll();
        }
        else {
            networkManagement.enableAll();
        }
        CommonUtil.setEnabled(mContext, enable);
    }

结果表明,的确是可以达到开关网络的目的,且不需要root权限,证明此思路可行。那么上网白名单怎么实现呢?显然NetworkManagementService里没有直接提供,需要我们自己添加。

三. 修改系统,增加“上网过滤”功能。

所谓“网络白名单”就是一串能访问的网址,除这串名单之外的网址不允许访问。这里最基本的功能就是只允许访问某一个站点,我们就从这儿着手。先看看如果调用iptables命令如何实现。

iptables -A Filter -p udp --dport 53 -j ACCEPT
iptables -A Filter -p tcp --dport 53 -j ACCEPT
iptables -A Filter -d www.163.com -j ACCEPT
iptables -A Filter -j DROP


上面这一串命令的作用就是只允许访问”www.163.com”站点。那么在安卓里面怎么实现呢?一种想法是先打开DNS解析的端口,加入上面第三条命令,再禁止所有,即第四条命令。另一种思路是先禁止所有站点,再打开DNS端口,再允许访问某一个站点。第一种思路是策略型的,需要自己定义默认策略(链表chain默认DROP),而我们复用的fw_OUTPUT、fw_INPUT的规则是非策略型的,如下




int FirewallController::enableFirewall(void) {
    int res = 0;

    // flush any existing rules
    disableFirewall();

    // create default rule to drop all traffic
    res |= execIptables(V4V6, "-A", LOCAL_INPUT, "-j", "DROP", NULL);
    res |= execIptables(V4V6, "-A", LOCAL_OUTPUT, "-j", "REJECT", NULL);
    res |= execIptables(V4V6, "-A", LOCAL_FORWARD, "-j", "REJECT", NULL);

    return res;
}

如果用这种思路就要修改这条链,或者自己再建一条链,比较麻烦,反而不如将“关闭所有网络”独立出来更方便。为了更灵活,我们选第二种思路。

  1. 关闭网络

    在应用层调用NetworkManagementService的setFirewallEnabled(true)就好。
  2. 打开DNS端口

    仿照setFirewallEnabled方法,在它实现的类FirewallController.cpp添加
int FirewallController::enableDNSPort(int protocol, int port) {
        char protocolStr[16];
        sprintf(protocolStr, "%d", protocol);

        char portStr[16];
        sprintf(portStr, "%d", port);

    int res = 0;
    res |= execIptables(V4, "-I", LOCAL_INPUT, "-p", protocolStr, "--sport", portStr, "-j", "ACCEPT", NULL);
    res |= execIptables(V4, "-I", LOCAL_OUTPUT, "-p", protocolStr, "--dport", portStr, "-j", "ACCEPT", NULL);
    return res;
}


别忘了在对应的头文件中声明这个函数。




接着在维护接收framework传来的字符串命令的socket线程的CommandListener.cpp里处理上层发送来的命令:




int CommandListener::FirewallCmd::runCommand(SocketClient *cli, int argc,
        char **argv) {
        ...
        // 第二个参数对应发送命令端的第二个参数
        if(!strcmp(argv[1], "enable_dns_port")) {
        // 3对应发送端的参数个数
        if (argc < 3) {
                cli->sendMsg(ResponseCode::CommandSyntaxError, "Missing argument", false);
                 return 0;
         }
         // port为发送端的第三个参数
         int port = atoi(argv[2]);
         int res = 0;
         res |= sFirewallCtrl->enableDNSPort(PROTOCOL_UDP, port);
         res |= sFirewallCtrl->enableDNSPort(PROTOCOL_TCP, port);
         return sendGenericOkFail(cli, res);
    }

    cli->sendMsg(ResponseCode::CommandSyntaxError, "Unknown command", false);
     return 0;
}


接下来是发命令的地方NetworkManagementService,也就是命令的发送端:




mConnector.execute("firewall", "enable_dns_port", 53);


3.允许访问某一个站点




同打开DNS端口的流程一样,先在FirewallController.cpp中增加如下:

int FirewallController::enableUrl(const char* addr) {
    int res = 0;
    res |= execIptables(V4, "-I", LOCAL_INPUT, "-s", addr, "-j", "ACCEPT", NULL);
    res |= execIptables(V4, "-I", LOCAL_OUTPUT, "-d", addr, "-j", "ACCEPT", NULL);
    return res;
}


同样别忘了声明。接着在CommandListener.cpp添加




 if(!strcmp(argv[1], "enable_url")) {
        if (argc < 3) {
            cli->sendMsg(ResponseCode::CommandSyntaxError,
                                             "Missing argument",
                                             false);
                                return 0;
        }
        const char* addr = argv[2];
        int res = sFirewallCtrl->enableUrl(addr);
        return sendGenericOkFail(cli, res);
    }


发送命令处:




@Override
    public void enableUrl(String url) {
        Slog.d(TAG, "enable url is: " + url);
        Preconditions.checkState(mFirewallEnabled);
        if(mFirewallEnabled) {
            try {
                mConnector.execute("firewall", "enable_dns_port", 53);
                Slog.d(TAG, "Firewall is enabled. Open dns port is: " + 53);
            } catch (NativeDaemonConnectorException e) {
                throw e.rethrowAsParcelableException();
            }
        }
        InetAddress[] ipArray;
        try {
            ipArray = InetAddress.getAllByName(url);
              if(ipArray != null) {
                for(int i = 0; i < ipArray.length; i++) {
                    Slog.d(TAG, "ipArray[" + i +"].getHostAddress() = " + ipArray[i].getHostAddress());
                    mConnector.execute("firewall", "enable_url", ipArray[i].getHostAddress());
                }
            }
        } catch (UnknownHostException e) {
            Slog.e(TAG, "Gets all IP addresses associated with the given host \"" + url +"\" faild. Because the host name can not be resolved.");
            e.printStackTrace();
        } catch (GaiException e) {
            Slog.e(TAG, "Get host address to string faild.");
            e.printStackTrace();
        } catch (NativeDaemonConnectorException e) {
            e.rethrowAsParcelableException();
        }
    }


有没有体会到分开控制的方便,只有在防火墙是开启的状态才打开DNS端口 ,这段方法其实已经做了DNS解析,如果解析失败,可以方便让应用程序知道。如果在底层让iptables自动解析也是可以的,就像上面提到的命令那样,但是不好返回解析的结果。




这里需要注意的有三点:一是这里DNS的端口是53,但是别的平台可能不一样,最好不要写成静态的;二是可以在执行此方法之前先判断一下网络的状态,如果网络OK,再执行(添加规则到iptables chain)。三是这里修改的iptables链表的规则都是临时的,重启之后就失效,需要保存。




到这里,是不是就做完了呢?当然不是。如果这样子,即使修改了系统,应用程序也没法编译通过,因为这段功能并没有在原先的API里面啊。这就涉及到增加系统API的方法,这会在下篇文章详细介绍,不过可以先来看看这个功能的最后一点实现,自定义一个类,封装我们的API:

public class NetworkManagement {

    private final INetworkManagementService mService;

    public NetworkManagement(INetworkManagementService mService) {
        this.mService = mService;
    }

    public void setFirewallEnabled(boolean enabled) {
        try {
            mService.setFirewallEnabled(enabled);
        } catch (RemoteException ex) {
            ex.printStackTrace();
        }
    }

    public void enableUrl(String url) {
        try {
            mService.enableUrl(url);
        } catch (RemoteException ex) {
            ex.printStackTrace();
        }
    }
}

将编译出来的jar引入到应用程序,就可以直接调用新生成的API啦。

四. 应用程序实现上网时间限制。

上网时间的限制最基本的两种情形:一种是玩一段时间,提醒该休息了,并限制网络或锁屏;另一种是每天的一段时间内限制网络,其余时间不限制,或是一段时间不限制,其余时间限制。无论是哪一种都会涉及到采用哪种重复方式,就像闹钟里设置的重复方式一样,要么是一次性的,要么每天,要么每周的某几天…怎么做到定时重复呢,而且是全局的精确定时?当然iptables命令有个匹配参数-m time可以做到,然而可惜的是android并没有很好的支持,且上层不容易控制,那么还是在应用层做吧。既然谈到这个定时跟闹钟很像,我们不妨采用闹钟的定时方式,看起来完全可行呢!下面我们以第二种时间限制方式为例。

查阅资料不难发现闹钟的定时功能用的是AlarmManager,一个系统service, 获得这个service很容易:

AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Activity.ALARM_SERVICE);


一种最典型的用法:




alarmManager.setRepeating(AlarmManager.RTC, timeStart.getTimeInMillis(), AlarmManager.INTERVAL_DAY, start_pendingIntent);


参数一表示定时时间的类型是系统绝对时间,休眠时不提醒;




参数二是定时任务首次触发的时间,毫秒数表示;




参数三是重复的间隔时间,也是毫秒数,这里是一天;




参数四是定时任务,即时间到了,该执行什么操作。




整个方法是设置了一个每日重复的定时任务。




看看某一个定时任务:

ArrayList<String> list = new ArrayList(acceptSet);
        Intent start_intent = new Intent(mContext, TimerBroadcastReceiver.class);
        start_intent.putExtra("repeat_type", repeatType);
        start_intent.putExtra("isWhiteListMode", isWhiteListMode);        
...
PendingIntent start_pendingIntent = PendingIntent.getBroadcast(mContext, 0, start_intent, PendingIntent.FLAG_UPDATE_CURRENT);


定时时间到,发送广播。传一些基本的数值,具体的操作在接收广播的地方处理。




这里需要注意的主要有两点:一是因为是时间段,所以有起始时间和结束时间,两个时间点要做的任务不同,所以应该分开设置定时任务;二是Android在API 19之后,所有的重复闹钟(如setRepeating)都不精确了 , 据说是为了优化电源性能,不再实时响应每一个定时器,而是每隔5分钟一起响应这5分钟内的所有定时器,当然5分钟只是打个比方。那么在API 19之后怎样做到精确的重复定时呢?办法在API文档中已经提示了,就是使用一次性的精确定时,然后每一次时间到的时候重新设置。

alarmManager.setExact(AlarmManager.RTC, timeStart.getTimeInMillis(), start_pendingIntent);
...
nextStartTime.setTimeInMillis(times[2] + AlarmManager.INTERVAL_DAY);
setAlarm(startDate, stopDate, nextStartTime, stopTime, set, true, repeatType);


上面这个例子就是每收到定时时间到的广播就设置下一天同一时刻的定时器。另外还要注意保存设置的信息,等到下次开机的时候还能拿到之前的数据,默默地重新设置。




到此,Android定制实现上网限制的基本功能就写完了,算是一个完整的节点,以后有机会再扩展。

查询
adb shell iptables -L OUTPUT -nv  --line-number 

新增(2条,顺序不能颠倒)
adb shell iptables -A OUTPUT -m limit  --limit 10/s -j ACCEPT
adb shell iptables -A OUTPUT -j DROP

删除
adb shell iptables -D OUTPUT -m limit  --limit 10/s -j ACCEPT
adb shell iptables -D OUTPUT -j DROP