Hacking Limbo

Reading / Coding / Hacking

OpenVPN 路由详解

理解 VPN 路由(以及任何网络路由)配置的关键是认识到一个 IP packet 如何被传输,以下描述的是极度简化后的单向传输过程:

  1. 机器 A (192.168.0.2) 发送了一个目标地址为 172.29.1.4 的 IP packet.
  2. 根据本地路由规则,172.29.1.0/24 的下一跳是虚拟网卡 tun0, 由 VPN 客户端接管。
  3. VPN 客户端将这个 packet 的来源地址从 192.168.0.2 改为 10.8.0.123, 转发给 VPN 服务端。
  4. VPN 服务端收到 packet. 根据本地路由规则,172.29.1.0/24 的下一跳是默认网关 172.29.0.1.
  5. 默认网关找到在同一个局域网内的机器 B (172.29.1.4).

客户端 -> 内网

为什么机器 A 的本地路由表里会有 172.29.1.0/24 这个网段的路由规则?通常情况下,这是 VPN 服务端推送给客户端,由客户端在建立 VPN 连接时自动添加的。例如 push "route 172.30.0.0 255.255.0.0" 的作用就是将 172.30.0.0/16 网段的路由推送给客户端。

内网 -> 客户端

这个时候,如果机器 B 想要回复 A(比如发个 ACK),就会出问题,因为 packet 的来源地址还是 10.8.0.123, 而 10.8.0.0/24 网段并不属于当前局域网,是 VPN 服务端私有的——机器 B 往 10.8.0.123 发送的 ACK 会在某个位置(比如默认网关)遇到 "host unreachable" 而被丢弃。对于机器 A 来说,表面现象可能是连接超时或 ping 不通。

解决方法是,在 packet 离开 VPN 服务端时,将其「伪装」成来自 172.29.0.3(VPN 服务端的局域网地址),这样机器 B 发送的 ACK 就能顺利回到 VPN 服务端,然后发给机器 A. 这就是所谓的 SNAT, 在 Linux 系统中由 iptables 来管理,具体命令是:iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE.

客户端 -> 另一个客户端的内网

连接 OpenVPN 的两个 client 之间可以互相通信,这是因为服务端推送的路由里包含了对应的网段。但是想从 Client A 到达 Client B 所在局域网的其他机器,还需要额外的配置。因为 OpenVPN 服务端缺少 Client B 局域网相关的路由规则。

配置示例:

# server.conf
## 给客户端推送 172.29.0.0/16 网段的路由
push "route 172.29.0.0 255.255.0.0" # client -> Client B

## 在 OpenVPN Server 上添加 172.29.0.0/16 网段的路由,
## 具体下一跳是哪里,由 client-config 里的 iroute 指定
route 172.29.0.0 255.255.0.0

## 启用 client-config, 目录里的文件名对应 client.crt 的 Common Name
client-config-dir /etc/openvpn/ccd

# /etc/openvpn/ccd/client-b
## 告诉 OpenVPN Server, 172.29.0.0/16 的下一跳应该是 client-b
iroute 172.29.0.0 255.255.0.0

内网与内网互访

在前两节所给的配置基础上,只需要再加一点配置,就能实现 OpenVPN 服务端所在局域网与客户端所在局域网的互访。配置内容是,在各自局域网的默认网关上添加路由,将对方局域网网段的下一跳设为 OpenVPN 服务端 / 客户端所在机器,同时用 iptables 配置相应的 SNAT 规则。

例如:

  1. Cluster A 的路由表里添加 172.31.0.0/16, 下一跳为 172.30.0.16 (cluster-a-relay).
  2. Cluster B 路由表里添加 172.30.0.0/16, 下一跳为 172.31.1.2 (cluster-b-relay).

参考信息

Docker 服务发现机制的简单实现

如果不想折腾 Kubernetes 或 Docker Swarm, 又需要一个能快速搭建并投入使用的 Docker 服务发现机制,这篇文章可能可以给你一点灵感。

Docker 容器的服务发现涉及到两个问题:

  1. DNS 注册 - 将 service name 映射到容器 IP, 并随容器的启动 / 停止实时更新。
  2. IP 路由 - 保证容器 IP 能被所有机器访问到。

以下是我解决这两个问题的思路。

DNS 注册

这里要用到两个开源组件,RegistratorCoreDNS. 前者负责监听 Docker 容器的启动和停止,根据容器的名字或 label 生成 DNS 信息,以 SkyDNS 2 的格式写入 etcd; 后者从 etcd 读取 DNS 信息以提供 service name 的解析。

具体配置是:

  1. 搭建 etcd - 照着 https://play.etcd.io 建议的参数来启动即可。
  2. 在每台机器上启动一个 Registrator 实例,监听本机的 Docker 容器事件。关键参数:registrator -cleanup -internal=true -explicit=true skydns2://HOST_IP:2379/cluster.local.
  3. 在每台机器上启动一个 CoreDNS 实例,数据源设为 etcd 的 /skydns, 响应本机的 DNS 解析请求。
  4. 修改 Docker daemon 的 DNS server 为 127.0.0.1. UPDATED: 错的,应该写宿主机 IP.

遇到的一些问题:

  • Registrator 生成的名字不太符合 DNS 规范,可以在容器启动时指定 SERVICE_ID 覆盖掉默认值。另外这个项目看起来已经很久没有更新了,用的还是 etcd v2 API.

  • CoreDNS 解析域名时会返回当前「目录」的所有记录,比如 redis.qcloud.local 的解析包含 redis-1redis-2 两个实例的 IP.

  • 覆盖 Docker daemon 的 DNS server 后,CoreDNS 容器也会受影响(虽然指定了 --network=host),导致后者的 /etc/resolv.conf 里写的也是 127.0.0.1, 所有 DNS 查询变成无限递归。

  • Docker daemon 的 DNS server 不能写 127.0.0.1, 因为 CoreDNS 监听的是宿主机的 IP.

IP 路由

Docker 容器与宿主机使用的是不同的 IP 网段,比如宿主机的 CIDR 是 172.30.0.0/16, 而容器网络用的是 10.32.0.0/16. 默认情况下宿主机所在子网不太可能给容器 IP 网段配置路由,意味着我们只能自行解决路由问题。

如果宿主机是 Vultr / DigitalOcean / Linode 的 VPS, 在开启 Private Network 功能之后,直接给每台机器添加类似 10.32.0.0/16 via 172.30.x.x dev ethX 的路由即可。

而类似 AWS / GCP / 腾讯云这类比较「正规」的云服务,就稍微麻烦一点,因为他们的虚拟机网卡通常都开启了 IP 包的来源 / 目标地址检查1,所有不属于 VPC 子网网段的 IP 包都没法转发。简单粗暴的解决方法是在 VPC 路由表里添加容器 IP CIDR 的路由规则。

有精力折腾的话也可以试试 Calico 或 Flannel 的 IP-in-IP 方案。

对外暴露服务

解决上述两个问题,只能让不同机器间的 Docker 容器互相访问——如果想将服务暴露出去,比如提供公网的 HTTP 服务,还需要一些反向代理的配置。以 Nginx (OpenResty) 为例,如果所有服务都绑定在 *.example.com 这个公网域名上,可以将子域名映射为集群内的 service name, 再 proxy pass 过去。

具体实现:

  1. 后端服务 abc 的容器指定环境变量 SERVICE_8000_ID=web-0, 这里 8000 是 abc 服务暴露的端口。
  2. Registrator 生成对应的 DNS SRV 记录 web-0.abc.cluster.local.
  3. 在 Nginx server 里用 server_name ~(?P<subdomain>[^.]+).example.com; 抽取子域名,比如访问 abc.example.comsubdomain 就是 abc.
  4. access_by_lua 作用域里用 ngx.var.subdomain .. '.cluster.local' 拼接出 service name.
  5. resty.dns.resolver 解析 service name, 从 A 记录拿到 upstream IP, 从名为 web-[0-9]+ 的 SRV 记录拿到端口号,记录到 ngx.ctx
  6. Nginx upstream 用 balancer_by_lua_blockngx.ctx 里取出 IP 和端口号,作为 proxy_pass 的目标地址。

完整的 Nginx 配置和 Lua 代码见这个 Gist.

参考


  1. AWS 可以关掉这个检查,见官方文档里提到的 Changing the Source or Destination Checking, 其他云服务就不太清楚了。

微信扫码支付

为了应付一个奇葩的审核需求,给公司官网集成了网页版的微信扫码支付,这里记录一下大致流程。

集成方式

根据官方文档的描述,我选了看起来最方便的对接流程模式二

  1. [服务端] 调用微信支付「统一下单 API」生成预付交易,获得 code_url.
  2. [客户端] 生成 code_url 对应的二维码。
  3. [微信] 用户扫码支付后完成交易,交易结果通过 HTTP POST 请求发送到调用方指定的 callback url.
  4. [服务端] 校验交易结果,更新订单状态。

注意:该模式的预付订单有效期为 2 小时,过期后无法支付。以及每个二维码只能被扫码支付一次。

API 调用

所谓「统一下单 API」,就是 POST https://api.mch.weixin.qq.com/pay/unifiedorder, 只不过跟所有微信 API 一样,调用时要给 request payload 签名,而且只能用 XML 编码 🙄️。

计算 request payload 签名:

def calc_signature(data: dict) -> str:
    api_key = '<微信商户 API Key>'
    pairs = sorted(order.items())
    pairs.append(('key', api_key))
    encoded = urlencode(pairs, quote_via=no_quote)
    return md5(encoded.encode('utf-8')).hexdigest().upper()

将 request payload 编码为 XML:

def dump_as_xml(data) -> str:
    root = Element('xml')

    for key, value in data.items():
        elem = Element(key)
        elem.text = str(value)
        root.append(elem)

    buffer = StringIO()
    ElementTree(root).write(buffer, encoding='unicode', xml_declaration=True)
    return buffer.getvalue()

一些小问题

  • urlencode() 拼接请求参数时,要指定 quote_via 为一个 stub 函数(原样返回字符串),因为在 XML 里传递的参数并没有被 quote, 需要保持两边的值一致才能通过签名校验。

  • 微信返回的 code_url 是类似 weixin://wxpay/bizpayurl?pr=<ID> 这样的 URL, 跟官方文档示例里的有一点不一样,URL 参数名并不是 sr 而是 pr.

  • 支付完成后,在「微信商户平台」里能看到订单信息,但是公众号管理员并没有收到通知。

  • 微信 API response header 里没有指定编码, 导致 HTTP client 认为 response body 编码是 ISO-8859-1, 解析出来的 XML 会有乱码,解决方法是强行指定 resp.encoding = 'utf-8.

参考

Kafka Partition Reassignment

第一次操作 topic partition 的迁移,有点紧张,实际过程很简单,而且迁移也很快(可能是因为数据不多)。要吐槽的是 Kafka 官方文档里对 partition / replica / broker 的编号规则有点混乱,一会是 0-based, 一会又变成 1-based 🙄. 具体操作过程如下:

  1. 运行 kafka-topics.sh --zookeeper zk:2181 --describe --topic <topic> 确认 topic partition 分布情况(集群操作只支持 zookeeper 定位,不能传 bootstrap server 地址),可以看到这个 topic 的 partition / replica 数量和所在节点。

  2. 构造 reassignment.json, 内容是:

    {
      "version": 1,
      "partitions": [
        {
          "topic": "<topic>",
          "partition": 0,
          "replicas": [2, 3]
        }
      ]
    }
  3. 运行 kafka-reassign-partitions.sh --zookeeper zk:2181 --reassignment-json-file reassignment.json —verify 看看 JSON 有没有写对──写对了的话会看到报错(是的)ERROR: Assigned replicas (1) don't match the list of replicas for reassignment (2,3) for partition [<topic>,0].

  4. 运行 kafka-reassign-partitions.sh --zookeeper zk:2181 --reassignment-json-file reassignment.json —execute 发起变更,然后再用 —verify 确认是否生效。

从官方文档里抄几个名词解释:

Replicas is the list of nodes that replicate the log for this partition regardless of whether they are the leader or even if they are currently alive.

Isr is the set of "in-sync" replicas. This is the subset of the replicas list that is currently alive and caught-up to the leader.

对于 PartitionCount == 1 && ReplicationFactor == 1 的 topic partition, 在 kafka-topics.sh --describe 输出里看到的 ReplicasIsr 应该只有(相同的)一个 broker 编号(假定 replica 和 broker 编号是一一对应的)。如果想列出只在 broker-1 上才有的 topics, 可以用命令 kafka-topics.sh --zookeeper zk:2181 --describe | grep -E 'Isr: 1$' 来过滤。

参考:

  1. Step 6: Setting up a multi-broker cluster
  2. Kafka Replication - Replica placements

Octave in Docker

试图在 macOS 上用 Homebrew 安装 Octave, 然而依赖多得有点可怕,居然还要从源码编译 GCC, 吓得赶紧 Ctrl-C. 在 Docker Hub 上找了一个镜像,凑合能用,但觉得 plot() 输出的 ASCII Art 太惨,就想折腾一下 X Server 的转发。

参考 Google 到的信息,成功地在 macOS 上运行了 Octave in Docker 的 QT GUI. 关键步骤如下:

  1. 启用 XQuartz 的 "Allow connections from network clients" 设置。
  2. 运行命令 env DISPLAY=:0 /opt/X11/bin/xhost + $(ipconfig getifaddr en0), 将本机 IP 加入白名单(必须加上 env DISPLAY=:0, 否则 xhost 会报错)。
  3. 启动容器时传入 DISPLAY 环境变量, docker run -it -e DISPLAY="$(ipconfig getifaddr en0):0" octave:latest.

还有一个小问题:使用 plot 之前要先执行 graphics_toolkit gnuplot, 不然会闪退,原因不明。于是我把这个命令加到 Startup File 里了。

参考:

- More Articles in Archives -