中国区 AWS 透明翻墙网关的搭建

多年未更新 blog,今天偶然打开,看到副标题 thoughs worth sharing 深感惭愧,最近繁忙的工作也告一段落,我想是时候该重新拿起博客了,毕竟分享是我改不掉的坏习惯。

背景

中国区 AWS 无论是在光环新网(北京)还是西云数据(宁夏)到海外的链路都十分糟糕,当我们需要在中国区 AWS 上搭建服务,尤其是需要使用到 Google/Github 的依赖时会十分的痛苦,此时就需要一个透明网关,当满足条件时走代理,否则走 AWS 的默认出口。

其中海外机器的条件比较苛刻,如非向电信申请专门的白名单线路,我们在海外的代理服务器很有可能随时被墙,所以本文选择的方案有几个关键需求:

  1. 给定一组域名,比如 proxy1.mydomain.com / proxy2.mydomain.com / … 通过 DDNS 或者人肉的方式使得可以自动更新代理服务器。
  2. 透明翻墙网关的软件需要有 failover 的特性,即代理失效时可以自动进行转移,或者有负载均衡使得所有的 endpoints 均为有效代理。
  3. 为了可以使用廉价的代理订阅服务(假设不是强安全需求),软件需要支持 Shadowsocks/V2Ray 等多种协议。
  4. UDP 也要能完美代理,最愉悦的方式自然是走 tun。

本文意在提供一个开箱即用的解决方案,即复制粘贴即可使用,对其中细节不会深入解释,具体可自行了解。

网络拓扑

配置步骤

根据需求描述,我们这里使用 Clash,注意原作者的版本和我们这里使用的版本并不一样,原作者的 tun 部分实现并未开源,我们暂不考虑非开源方案,这里选用 comzyh 的修改版 [2], 神奇的是,有一个非常漂亮的 one-click 的脚本可以协助我们完成整个过程。

首先我们使用 Ubuntu Server 20.04 LTS (HVM) 这个 AMI (ami-04effa29f4d91541f),笔者尝试过 Amazon Linux 2 AMIUbuntu Server 18.04 LTS (HVM),均出现一些莫名其妙的问题,应该是 AWS 对这俩镜像在网络部分有过魔改导致的,并且 Amazon Linux 2 的 systemd 的版本很神奇,没法直接使用下面这个脚本,系统的关系并不是很大,这里直接选择一个能用的,即 Ubuntu 20.04 这个镜像。

准备配置文件

启好 EC2 以后,连接,新建 /srv/clash 文件夹,新建配置文件 /srv/clash/config.yaml,这里给出一个参考配置,有细力度需求可参考 Clash 的相关文档。

mode: Rule
log-level: info
dns:
  enable: true
  ipv6: false
  enhanced-mode: redir-host
  listen: 0.0.0.0:53
  nameserver:
    - 1.2.4.8
    - 114.114.114.114
    - 223.5.5.5
    # - 内网服务 DNS
  fallback:
    - tls://1.0.0.1:853
    - tls://dns.google:853
proxies:
  - name: "ss1"
    type: ss
    server: "server1.yourproxyserver.com"
    port: "1080"
    cipher: aes-256-gcm
    password: "yourpassword"
    udp: true
  - name: "ss2"
    type: ss
    server: "server2.yourproxyserver.com"
    port: "1080"
    cipher: aes-256-gcm
    password: "yourpassword"
    udp: true

proxy-groups:
  - name: "Proxy"
    type: select
    proxies: ["ss1", "ss2"]

tun:
  enable: true
  device-url: dev://clash0
  stack: system

rules:
  - DOMAIN-SUFFIX,local,DIRECT
  - IP-CIDR,127.0.0.0/8,DIRECT
  - IP-CIDR,172.16.0.0/12,DIRECT
  - IP-CIDR,192.168.0.0/16,DIRECT
  - IP-CIDR,10.0.0.0/8,DIRECT
  - IP-CIDR,17.0.0.0/8,DIRECT
  - IP-CIDR,100.64.0.0/10,DIRECT

  # Final
  - GEOIP,CN,DIRECT
  - FINAL,Proxy

并且下载 IP 地理位置数据库[4] 到 /srv/clash 下

部署 Clash

注意由于我们的目的是将 Clash 作为透明网关的服务使用, 由于 Clash 自带有 DNS 服务, 所以我们可能要关闭掉系统原有的占用 53 端口的 DNS 服务, 具体系统具体分析, 这里不再赘述.

找个地方如 /tmp 下,依次执行

git clone https://github.com/Kr328/clash-tun-for-linux
cd clash-tun-for-linux && chmod +x *
./install.sh build # 由于墙的原因,建议本地编译好上传到服务器或者直接下作者 prebuilt 的版本[3],改名为 clash 放在该目录下
sudo ./install install 
sudo systemctl daemon-reload && sudo systemctl enable clash-tun && sudo systemctl start clash-tun

systemd 会在启动 clash 之前自动执行路由表的配置,默认内网流量不过 tun,大致上长这样 (我这里执行 ip rule list 的结果)

0:	from all lookup local
32758:	from all uidrange 65534-65535 goto 32766
32759:	from all to 172.31.255.253/30 goto 32767
32760:	from all to 127.0.0.0/8 goto 32766
32761:	from all to 10.0.0.0/8 goto 32766
32762:	from all to 192.168.0.0/16 goto 32766
32763:	from all to 224.0.0.0/4 goto 32766
32764:	from all to 172.16.0.0/12 goto 32766
32765:	from all lookup 354
32766:	from all lookup main
32767:	from all lookup default

此时,Clash 的部署就完成了。接下来我们要考虑按照设计好的网络拓扑在 AWS 进行配置,将其作为一个 NAT 网关。

AWS 配置

本文考虑到受众用户不同,所以采用在 AWS 的 WebUI 下进行操作,读者可使用 aws-cli 进行配置或将整套结构使用 Cloudformation 进行部署。

创建子网

配置网络接口

路由表及防火墙配置

首先我们打开 NAT 实例上的转发

sudo iptables -t nat -A POSTROUTING -j MASQUERADE

保存防火墙配置并且每次重启时应用,首先创建 /etc/network/if-pre-up.d/iptables,并记得给可执行权限

#!/bin/bash

iptables-restore < /etc/iptables.rules

为了能让其在网络 Ready 以后运行,创建 systemd 的 daemon 配置, 新建文件 /etc/systemd/system/iptables-rules.service 写入以下内容

[Unit]
Description = Apply iptables rules 

[Service]
Type=oneshot
ExecStart=/etc/network/if-pre-up.d/iptables

[Install]
WantedBy=network-pre.target

然后让 systemd 的配置生效

sudo systemctl daemon-reload
sudo systemctl enable iptables-rules
sudo systemctl start iptables-rules

然后便是网卡配置

首先要抱怨一下 Ubuntu 的 netplan 真是个大坑, 新加接口以后如果配置从 DHCP 来,则会默认添加 default 路由,而且 Ubuntu 20.04 的 netplan 版本过老不支持 use-routes: false 选项来屏蔽这个行为,所以只能 workaround 这个事情了

首先我们添加一个接口配置, 新建文件 /etc/netplan/60-nat.yaml

network:
    ethernets:
        ens6:
            dhcp4: true
            dhcp4-overrides:
                use-routes: false
            dhcp6: false
            match:
                macaddress: 02:45:b0:69:f4:8c
            set-name: ens6
    version: 2

其中 ens6 是之前创建的网络接口 (eni-XXXXX) 对应在 NAT Instance 上的网卡名称,如果你是第一次附加网络接口,一般为 ens6,如果有多个附加则具体情况具体分析。

为了解决会自动增加 default 路由的问题,笔者这里采用了一个非常 dirty 的方案,即将下列内内容写到 /etc/networkd-dispatcher/routable.d/ 里[6],并给予可执行权限

#!/bin/sh

# Only remove the default route on the second interface, e.g. eth1
[ "$IFACE" != ens6 ] && exit 0

# delete the default route for this interface
ip route del default dev ens6

至此所有的配置均以结束。

参考

  1. https://docs.aws.amazon.com/zh_cn/vpc/latest/userguide/VPC_NAT_Instance.html
  2. https://github.com/comzyh/clash
  3. https://github.com/comzyh/clash/releases/download/20200510/clash-linux-amd64
  4. https://github.com/Dreamacro/maxmind-geoip/releases/latest/download/Country.mmdb
  5. https://github.com/Kr328/clash-tun-for-linux
  6. https://unix.stackexchange.com/questions/517995/prevent-netplan-from-creating-default-routes-to-0-0-0-0-0

Brief view of coroutine / asynchronous in Python [进行中]

我们先从 协程 开始,具体对术语的定义在此就不凑字数了,可以具体的参考一下别人的文章,相信比我写的更优秀。

本文不会去讨论当前流行的异步框架,如 tornado gevent asyncio 等的具体实现方式,因为工程框架会更优秀更严谨,我会在另外几篇文章中搞点大新闻(逃,Python到目前为止,涉及到 coroutine 的东西,其底层的物理载体是 generator , Python 的 generator 在语义上已经具有了 暂停函数并返回保存断点处函数的上下文 的含义,所以 generator 的行为和 coroutine 是非常类似的,必然是实现 coroutine 的最佳载体。

我们先来看一段有关 generator 的直观体验 (注意运行环境是 Python 2)

def coroutine():
    counter = 0

    print '[coroutine] enter'
    for i in range(1, 4):
        print '[coroutine] return val', i
        val = yield i
        print '[coroutine] received', val
    print '[coroutine] exit'

obj = coroutine()
print '[main] run coroutine'
first_ret = obj.next()
print '[main] coroutine return and reach first breakpoint', first_ret
print '[main] start resume coroutine'

for i in range(1, 4):
    print '[main] will send %d to coroutine' % i
    val = obj.send(i)
    print '[main] coroutine return', val

Output:

[main] run coroutine
[coroutine] enter
[coroutine] return val 1
[main] coroutine return and reach first breakpoint 1
[main] start resume coroutine
[main] will send 1 to coroutine
[coroutine] received 1
[coroutine] return val 2
[main] coroutine return 2
[main] will send 2 to coroutine
[coroutine] received 2
[coroutine] return val 3
[main] coroutine return 3
[main] will send 3 to coroutine
[coroutine] received 3
[coroutine] exit
Traceback (most recent call last):
  File "t.py", line 19, in <module>
    val = obj.send(i)
StopIteration

下面我会用 coroutine 的方式去说明如此调用 generator 的方法的含义。

  • L11: 初始化一个 coroutine 此时该 coroutine 的状态在 READY, 但是并没有执行
  • L13: 等于对线程调用了 start(), 此时 coroutine 的状态在 RUNNING,注意看输出的 L2, L3, 这时候执行流等于从主协程(这个术语可能不大准确)中切换到了 coroutine 中,开始执行其语句, 达到第一个 yield 的点.
  • L7: yield 语句等于一次 trap,并且执行流切换到 stack 的上一层, 即主协程,并且携带了一个值 i,(等于做了一次 mov EAX, i; JMP _main_thread),注意我并没有用 返回 这个词语,意味着此时 coroutine 并没有真正的返回,他的栈空间并没有释放,并且保留了 resume 时执行的语句位置.
  • 主协程继续执行,到达循环, 用 send 方法去 resume 一个 coroutine 并且向其传递一个值, 这个值是在 coroutine 中断点语句的返回值.
  • 来回反复,直到 coroutine 内的 for 循环 i == 3 时,此时外部主协程再次向 * coroutine* 发送 3coroutine 被激活,继续执行输出 [coroutine] received 3 完成以后,跳出 for 循环,输出 [ciroutine] exit 然后结束,注意我的术语是结束,并不是返回

为什么用 结束 而不是 返回 ?
这里的结束有两个含义
1. 协成的指令全部运行完成
2. 切到其他地方

有点类似于 分时系统的切换线程 的概念, 可怜的协程君并没有谁在期待他返回什么,甚至于你给他加一个return,会爆出这样的错误

  File "t.py", line 10
    return 0
SyntaxError: 'return' with argument inside generator

实在有点惨,为什么会这样?再看这样一段代码

def coroutine():
    print '[c] running'
    for i in range(1, 3):
        val = yield i
        print '[c]', val


c = coroutine()
c.next()


print '[m] yoooooo'
print '[m] I do lots of things here and leave the poor coroutine alone to death'
c.send(None)

Output:

[c] running
[m] yoooooo
[m] I do lots of things here and leave the poor coroutine alone to death
[c] None

这段代码反映的一个现象是:我想切换到你的时候切换到你,等你切出来的时候我不一定期待你马上再运行 所以,并没有谁在期待着谁的返回,所谓的 send 不过只是一个 trick 让你方便的传状态给它~
C1 表示 yield 前的 coroutine 的状态, 用 C2 表示从主协程切回去时 coroutine 的状态, C1≠C2, 区别在哪儿? C2的上下文中多了一个入参

那么, coroutine 在 python 中用 generator 来实现的大概是已经有个映像了,那么就涉及到一个问题:coroutine 是如何被调度的?意思就是如果把 coroutine 类比成 thread,怎么来回切换的,调度的策略又是什么呢?

我们先来引用一波 Wikipedia 上的段落[1]

One important difference between threads and coroutines is that threads are typically preemptively scheduled while coroutines are not. Because threads can be rescheduled at any instant and can execute concurrently, programs using threads must be careful about locking. In contrast, because coroutines can only be rescheduled at specific points in the program and do not execute concurrently, programs using coroutines can often avoid locking entirely. (This property is also cited as a benefit of event-driven or asynchronous programming.)

抽出几个关键点就是:

  1. coroutines is that threads are typically preemptively scheduled while coroutines are not – 协程要自己调度
  2. coroutines can only be rescheduled at specific points – 协程仅在一些特定的调度点进行调度
  3. programs using coroutines can often avoid locking entirely – 用协程可以完全的避免锁(因为调度点自己控制的)
  4. a benefit of event-driven or asynchronous programming – 适合搞事件驱动型的异步编程

这么短的一段话居然解释了协程好处都有啥(误,在这里感慨一下词条编辑者很6. 顺便说一句协程无锁化那都是扯,因为大多数时间用的都是框架而不是

那我们先从这些特点,在 Python 中怎么搞事情分别去做一些实验。

怎么做调度?

前文已经提到,Python的 generator 可以通过 yield 语法保存函数运行堆栈(类似 闭包 的概念),并且切换出和切换回,这个语法特性是 让出执行权切换上下文 的核心。

我们显然不是想做成函数调用的形式,链式一波流走完回到头,所以,我们要设计一个 Scheduler 来决定 谁可以下一个执行

那么我们就以一个事件驱动的调度模型来实际去搞点事情,引入 Future 概念,这个东西表达的是一个 值在未来确定 的对象,并且在返回值被设置的时候,调用callback函数,所以,一个 coroutine 可以在断点处返回一个 Future 对象,并且将这个 coroutine 存入该 Future 对象的 callback 属性。

future.py

class Future:

    def __init__(self):
        self.callback = None
        self._ret_val = None

    def set_callback(self, callback):
        self.callback = callback

    def set_return(self, val):
        self._ret_val = val

然后我们看看一个 coroutine 的例子,假设是设计某种 worker,你可以把它当作是 HTTPClient 一类的东西

def worker():
    future = Future()

    # 设计成传出 future 传入 future.ret_val
    in_data = yield future

    ret_val = in_data
    yield ret_val

下面就是 Scheduler 的实现了:

class Scheduler:

    def __init__(self):
        self.futures = []

    def add_future(self, future):
        self.futures.append(future)

    def loop(self):
        while 1:
            # pop_a_ready_future 弹出一个 future.ret_val 不为 None 的 future
            future = self.pop_a_ready_future()
            # 传入 future.ret_val 给 future 的下文
            ret = future.send(future.ret_val)

            # 如果返回值是个 Future 则继续丢进等待设值的futures中
            if isinstance(ret, Future):
                self.futures.append(ret)
            else:
                pass  # 忽略返回值

Final

为了避免坑没有填完,嘛,几句话概括就是

  1. 调度器用户态实现,一般不涉及抢占式调度
  2. 原语是yield和resume
  3. 状态是 suspend/running/dead
  4. 一般用在事件驱动开发

其他的什么保存现场都是琐碎的工作大概就和线程切换类似吧。

References

  1. Coroutine

Hook libnfc 来实现 android 刷卡的ID产生 未完

手上的机器是 Oneplus 3T, Android 6.0.1 有时候 NFC Emulator 好用,有时候又不行,手工改 libnfc-nxp.conf 有时候可以有时候不可以,主要表现为可能会变成 随机ID 或者 固定为某个特别的ID 所以就很气,想折腾一下能不能从底层搞事情。

基于 libinject2-64 这个库并没有实现 hook 机制,没办法只能搞点事情了。
根据官方的解释,Aarch64已经不支持直接读写PC了,原文:

FA4B3D60-5F27-4277-A02D-D5BC6CFBA929.png

根据官方的意思,应该用控制流的指令去改变PC,

29EB8E3A-7F0D-425F-B5BC-F45A4C9167ED.png

所以用 BR Xm是可以搞事情的。尝试这么去构造一下跳转,把这些指令覆盖掉准备要hook的函数的内存的前一段空间

_hook_start_s:
# 把被hook函数的地址放入x28
LDR x28, _target_fn_address
# 跳转到prehook函数地址
BR x28
_target_fn_address:
nop

编译一下,objdump得到字节码

a.out: file format elf64-littleaarch64

Disassembly of section .text:

0000000000000000 :
0: 5800005c ldr x28, 8
4: d61f0380 br x28

0000000000000008 :
8: d503201f nop

所以可以有以下代码

struct hook_t {
unsigned int jump[3];
unsigned int store[3];
unsigned char jumpt[20];
unsigned char storet[20];
unsigned int orig;
unsigned int patch;
unsigned char thumb;
unsigned char name[128];
void *data;
};
h->thumb = 0;
h->patch = (unsigned int)hook_arm; // prehook函数的地址
h->orig = addr; // 目标函数入口
h->jump[0] = 0x5800005c; // ldr x28, 8
h->jump[1] = 0xd61f0380; // br x28
h->jump[2] = h->patch; // 目标地址

for (i = 0; i < 3; i++) h->store[i] = ((int*)h->orig)[i]; //保存被hook函数的前12字节,方便之后恢复

for (i = 0; i < 3; i++) ((int*)h->orig)[i] = h->jump[i]; // 替换掉被hook函数的前三条指令

上面的代码就完成了对目标函数跳走的逻辑了

python的eval

eval(X, type("_Dummy", (dict,), {'__getitem__': lambda name, value: name}))

这段代码最重要的一个应用是可以格式化非标准的JSON数据,比如

X = { key: "value" }
eval(X, type("", (dict,), {'__getitem__': lambda s, n: n})())

这样可以转化成标准字典

那么这一句神奇的东西该怎么解释呢?
首先type在这里充当的不是返回类型的作用,这里作为一个class constructor的存在,第一个参数是类名,第二个参数为类的基类,第三个参数为一个字典,表示这个类的成员和方法。
那么,eval(X, type("", (dict,), {'__getitem__': lambda s, n: n})())就是创建了一个临时的 _Dummy 类,上面的语句规约为

eval(X, _Dummy)

那么eval的第二个的参数什么意思呢?

>>> help(eval)
eval(...)
eval(source[, globals[, locals]]) -> value

Evaluate the source in the context of globals and locals.
The source may be a string representing a Python expression
or a code object as returned by compile().
The globals must be a dictionary and locals can be any mapping,
defaulting to the current globals and locals.
If only globals is given, locals defaults to it.

我们就知道了,这个求X的值里涉及到的变量 key 这个变量会放到 _Dummy() 里去求,则会等价于调用 _Dummy()['key'],此时就会调用到我们的 __getitem__,这个方法我们定义了返回 name 也就是 “key” ,所以,一个变量名在这里就转换成了字符串。

但是eval毕竟是eval,在使用它的时候永远不要相信用户输入的参数,无论哪个语言的eval,滥用的结果都是惨重的。比如说:

福尔摩喵 (大喵神) 给出了一个 Referencence,写出了如何构造有害的东西来搞事情。

这里有篇文章涉及 Python 解释器安全 Paving the Way to Securing the Python Interpreter

还有个Pypy的sandbox Pypy feature#sandboxing