Total Pageviews

Friday 9 August 2019

利用全局(指整台机器的)代理-SNET翻墙

transparent proxy works on linux desktop, MacOS, router。

It's a solution like: (redsocks + ss-local)/ss-redir + ChinaDNS. But all in one binary, don't depend on dnsmasq.

Features

  • SS/http-tunnel as upstream server
  • Sytemwide tcp proxy (via iptables redirect) on linux desktop/server, MacOS desktop
  • Works on openwrt router
  • Bypass traffic in China
  • Handle DNS in the way like ChinaDNS, so website have CDN out of China won't be redirected to their overseas site
  • Local DNS cache based on TTL
  • block by domain name

Limation:

  • tcp only (but dns is handled)
  • ipv4 only

Tested on:

Desktop:
  • manjaro
  • ubuntu 18.04
  • MacOS 10.13.6
Router:
  • hiwifi2
  • ubnt er-x

Usage

For linux: ensure iptables and ipset installed in your system.
For macos: pfctl is included by default, no extra dependences.
Example config.json:
{
    "listen-host": "127.0.0.1",
    "listen-port": 1111,
    "proxy-type": "ss",
    "proxy-timeout":  5,
    # `bypassCN` or `global`, default to `bypassCN`
    "proxy-scope": "bypassCN",

    # config used when proxy-type is "http"
    "http-proxy-host": "",
    "http-proxy-port": 8080,
    "http-proxy-auth-user": "",
    "http-proxy-auth-password": "",

    # config used when proxy-type is "ss"
    "ss-host": "ss.example.com",
    "ss-port": 8080,
    # https://github.com/shadowsocks/shadowsocks-go/blob/1.2.1/shadowsocks/encrypt.go#L159
    "ss-chpier-method": "aes-256-cfb",
    "ss-passwd": "passwd",

    "cn-dns": "114.114.114.114",  # dns in China
    "fq-dns": "8.8.8.8",  # clean dns out of China
    "enable-dns-cache": true,
    "enforce-ttl": 0,  # if > 0, will use this value otherthan A record's TTL
    "disable-qtypes": ["AAAA"], # return empty dns msg for those query types
    "force-fq": ["*.cloudfront.net"], # domain pattern matched will skip cn-dns query
    "host-map": {
        "google.com": "2.2.2.2"  # map host and ip
    },
    "block-host-file": "", # if set, domain name in this file will return 127.0.0.1 to client
    "block-hosts": ["*.hpplay.cn"], # support block hosts with wildcard
    "mode": "local"   # run on desktop: local, run on router: router
}
supported proxy-type:
  • ss: use ss as upstream server
  • http: use http proxy server as upstream server(should support CONNECT method, eg: squid)
Since snet will modify iptables/pf, root privilege is required.
sudo ./snet -config config.json
Test (proxy-scope = bypassCN):
  • curl ifconfig.me, ip should be your ss server ip.
  • curl myip.ipip.net, ip should be your local ip in China.
If proxy-scope is global, both should return ss server ip.
If you use it on router, change mode to router, and listen-host should be your router's ip or 0.0.0.0

Notice

If crash or force killed(kill -9), snet will have no chance to cleanup iptables/pf rules, it will make you have no internet access.
You need to clean them manually(If restart snet, it will try to cleanup) or restart your laptop :(
Linux:
sudo iptables -t nat -F  
# if you install docker, docker's iptable rules will be flushed as well, just restart docker it will recreate them.
MacOS:
sudo pfctl -d

Known issue:

  • Manjaro's NetworkManager will create a ipv6 dns nameserver in /etc/resolv.conf, eg: nameserver fe80::1%enp51s0. If it's first nameserver, dns query will bypass snet(since I didn't handle ipv6), you need to disable ipv6 or put it on second line.
  • Chrome's cache for google.com is wired.If you can visit youtube.com or twitter.com, but can't open google.com, try to restart chrome to clean dns cache.
  • cn-dns should be different with the one in your /et/resolv.conf, otherwise dns lookup will by pass snet (iptable rules in SNET chain)
from https://github.com/monsterxx03/snet
(下载地址:
https://github.com/monsterxx03/snet/releases/download/v0.5.0/snet_darwin_amd64
https://github.com/monsterxx03/snet/releases/download/v0.5.0/snet_linux_amd64 )
--------------

我的补充说明:
在mac机器上。
wget https://github.com/monsterxx03/snet/releases/download/v0.5.0/snet_darwin_amd64
chmod 755  snet_darwin_amd64
wget https://github.com/monsterxx03/snet/raw/master/config.json.example -O snet-config.json
nano snet-config.json
内容如下:
{
    "listen-host": "127.0.0.1",
    "listen-port": 1111,
    "proxy-type": "ss",
    "proxy-timeout": 5,
    "proxy-scope": "bypassCN",
    "http-proxy-host": "",
    "http-proxy-port": 8080,
    "http-proxy-auth-user": "",
    "http-proxy-auth-password": "",
    "ss-host": "vps-ip",
    "ss-port": vps上的ss的端口号,
    "ss-chpier-method": "aes-256-cfb",
    "ss-passwd": "vps上的ss的密码",
    "cn-dns": "223.6.6.6",
   
"fq-dns": "8.8.8.8",
    "enable-dns-cache": true,
    "enforce-ttl": 3600,
    "disable-qtypes": ["AAAA", "PTR"],
    "force-fq": ["*.cloudfront.net", "*.amazonaws.com"],
    "host-map": {},
    "block-host-file": "",
    "block-hosts": ["*.hpplay.cn"],
    "mode": "local"
}


sudo ./snet_darwin_amd64 -config snet-config.json
会显示:
2019/08/10 09:46:28 /Users/yejia/repos/snet/server.go:26: Info:Proxy server listen on tcp: 127.0.0.1:1111
2019/08/10 09:46:29 /Users/yejia/repos/snet/redirector/pfctl_darwin.go:78: Error:exit status 1
2019/08/10 09:46:29 /Users/yejia/repos/snet/main.go:124: Fatal:exit status 1
yudeMacBook-Air:~ brite$ ls /Users/yejia/
ls: /Users/yejia/: No such file or directory
yudeMacBook-Air:~ brite$ mkdir -p /Users/yejia/repos/
mkdir: /Users/yejia/repos/: Permission denied
yudeMacBook-Air:~ brite$ sudo mkdir -p /Users/yejia/repos/
Password:
yudeMacBook-Air:~ brite$ cd /Users/yejia/repos/
yudeMacBook-Air:repos brite$ ls
yudeMacBook-Air:repos brite$ git clone https://github.com/monsterxx03/snet
fatal: could not create work tree dir 'snet': Permission denied
yudeMacBook-Air:repos brite$ sudo git clone https://github.com/monsterxx03/snet
yudeMacBook-Air:repos brite$ cd ~
yudeMacBook-Air:~ brite$ sudo ./snet_darwin_amd64 -config snet-config.json
2019/08/10 09:56:23 /Users/yejia/repos/snet/server.go:26: Info:Proxy server listen on tcp: 127.0.0.1:1111
2019/08/10 09:56:23 /Users/yejia/repos/snet/dns/server.go:109: Info:DNS server listen on udp: 127.0.0.1:1211
不要关闭此terminal窗口。
然后,你的整台mac机器就处于翻墙状态了。就好像连上了vpn一样。

如果过一段时间,你用SNET翻墙失败,那么运行sudo pfctl -d ,然后再运行
sudo ./snet_darwin_amd64 -config snet-config.json即可。

snet内置的fq-dns还是有些问题,所以在运行sudo ./snet_darwin_amd64 -config snet-config.json之后,仍然建议在mac上使用某个dns proxy
先编辑snet-config.json,修改"fq-dns": "8.8.8.8", 为 "fq-dns": "127.0.0.1",
然后,运行某个dns proxy。比如下述的crappydns。

cd ~/CrappyDNS-by-nekolab/src && sudo ./crappydns --listen 127.0.0.1 --port 53 --good-dns tcp://8.8.8.8:53,tcp://8.8.4.4:53
(crappydns的用法详见https://briteming.blogspot.com/2019/08/crappydnsdns.html

如果在运行sudo ./snet_darwin_amd64 -config snet-config.json之后,出现了提示:
too many open files,那么:
cd /etc/security
sudo nano limits.conf
其内容为:
* soft nofile 65536 
* hard nofile 65536  


然后,在一个新开的terminal窗口里,运行
sudo ./snet_darwin_amd64 -config snet-config.json即可。

第三方开发的gui:
https://github.com/xinshangshangxin/snet-x/releases/download/v0.4.2/SnetX-0.4.2.dmg

  • 支持哪些快捷指令(不区分大小写)
    • h/help/帮助/帮助文档: 打开帮助文档
    • IP: 检测本机 IP 地址
    • password/密码: 设置 sudo 密码
    • init/初始化: 初始化向导
    • log/日志: 打开日志目录文件夹
    • db/存储: 打开数据存储目录文件夹
    • exit/quit: 退出
  • 为什么需要开机密码(sudo 密码)
    • snet 运行本身需要 sudo 执行
    • 停止 snet 采用的是 kill
  • 开机密码(sudo 密码) 存储于哪里?
    密码存储使用 keytar, 存储于 钥匙串访问(Keychain Access.app)
  • 设置界面找不到
    托盘(Tray) 中点击 设置 菜单即可显示. 原因: 本程序设置界面并非主要操作, 托盘(Tray)才是主要操作, 所以默认会销毁/隐藏
  • 如何退出
    退出 SnetX 仅能通过 托盘(Tray) 中点击 退出 菜单, 或者设置界面中 退出按钮, 使用 Cmd+Q 仅仅隐藏设置界面
  • 域名检测如何实现的
    用 dig 解析 IP, 查看 IP 是否为国内 IP, 是则显示直连. 原作者解释
  • 即使退出了 SnetX, 依然无法访问网络
    请点击托盘的 设置 打开设置界面, 再点击 停止重置 按钮后, 查看是否网络正常
  • 网络不正常可能原因
    • SnetX 崩溃
    • snet 有端口冲突
    • 启动多个SnetX
    • SS 服务器无法访问
    • 本身网络有异常
  • 每次打开都要求输入开机密码 / 每次启动失败, 需要设置开机密码
    打开 访达-应用程序-实用工具-钥匙串访问(Keychain Access.app), 右键"登录"锁定钥匙串,然后再解锁即可. 原文

问题反馈

如果是针对界面操作的问题, 请在此 repo 反馈, 如果是针对 snet 相关, 请前往 snet 反馈, 本项目仅仅是一个可视化界面

本地如何开发

  1. 安装 Node.js, 版本 >=v12.14.1
  2. git clone https://github.com/xinshangshangxin/snet-x
  3. 进入snet-x, 执行 npm run start:pre
  4. 下载 snet brew install jq && bash .github/shells/download-github-release.sh
  5. cd main && npx electron-rebuild --version $(cat package-lock.json | jq '.dependencies.electron.version') 原文
  6. 本地实时预览, 第一个命令窗口: cd render && npm start; 第二个命令窗口: cd main && npm start, 其中render实时刷新, 而 main 需要手动重启
  7. 本地构建: npm run build:local
------

技术文档

日常使用 Linux 工作, Linux 下实现全局透明代理可以用 iptables + ss-redir, 要有比较好的上网体验还需要 ChinaDNS 配合 dnsmasq, 这一整套在路由器上搞一遍就算了, 在本地太麻烦了. 仔细想想这几个加起来的功能实现起来也并不复杂, 前阵子就写了个小东西, 用一个进程完成全局透明代理 + ChinaDNS + 国内外分流: https://github.com/monsterxx03/snet
目前的限制:
  • 不支持 ipv6
  • 只支持 tcp (因为我的测试服务器不支持 udp, 以后再加上吧)
  • 上游 server 只支持 ss
目的是一个进程 + 一个配置文件完成所有事情. 需要的 iptable 规则也全部内置了(包括 CN ip 段), 缺少灵活但对我够用了, 以后有需要再加上选项不自动配吧.
需要手工装下 ipset.
配置文件示例:
{
    "listen-host": "127.0.0.1",
    "listen-port": 1111,
    "ss-host": "ss.example.com",
    "ss-port": 8080,
    "ss-chpier-method": "aes-256-cfb",
    "ss-passwd": "passwd",
    "cn-dns": "114.114.114.114",
    "fq-dns": "8.8.8.8",
    "enable-dns-cache": true,
    "mode": "local" 
}
ss 协议实现用的是 shadowsocks-go, 所以 cipher 就是 go 版支持的那些: https://github.com/shadowsocks/shadowsocks-go/blob/1.2.1/shadowsocks/encrypt.go#L159
cn-dns: 选择一个国内的 dns server.
fq-dns: 选择一个国外的干净的 dns server.
enable-dns-cache: 默认会按 A 记录的 TTL 缓存查询结果, 不需要可以关闭.
mode: 在桌面版 linux 上用选 local, 在 openwrt 路由器上选 router, 区别只是自动设置的 iptables 规则不一样.
sudo ./snet -config config.json 就能跑起来啦, 并接管了全局的 TCP 流量.

实现

看一下用 local 模式启动时候到底干了什么, iptables -t nat -L:
Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
SNET       tcp  --  0.0.0.0/0            0.0.0.0/0
SNET       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:53

Chain SNET (2 references)
target     prot opt source               destination
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            match-set BYPASS_SNET dst
REDIRECT   tcp  --  0.0.0.0/0            0.0.0.0/0            redir ports 1111
RETURN     all  --  0.0.0.0/0            114.114.114.114
DNAT       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:53 to:127.0.0.1:1111
我建了一张叫 SNET 的 chain, 并把所有出去的 tcp 流量和查询 53 端口的 udp 流量(dns) 转到这个 chain 里.
解释下 SNET chain 里的内容:
  • BYASS_SNET 这个 ipset 中的 ip 全部跳过, 里面是保留 ip 段 + cn ip 段 + ss server ip(不然就死循环啦).
  • 剩下的所有 tcp 流量全部重定向到本地的 1111 端口(snet 的监听端口)
  • 所有 114 dns 的流量不处理(配置文件里 设置的 cn-dns).
  • 剩余的发往 53 端口 udp 全部转发到 snet 监听的 udp 端口.
BYPASS_SNET 的内容可以用 ipset list BYPASS_SNET 查看.
除了设置 iptables, 这个程序具体只干了两件事:
  • 将转发过来的 tcp 流量用 ss 协议转发到 ss server.
  • 绕过 dns 污染, 并实现 ChinaDNS 的功能.
流量转发部分没什么好说的, 关键是要获取 tcp connection 的原始目标 ip + 端口, 因为被 iptables 重定向后目标就变成了 snet 的监听地址, 这里看了下 redsocks 的代码, 是通过 getsockopt(fd, SOL_IP, SO_ORIGINAL_DST, dstaddr, &socklen) 实现的: https://github.com/darkk/redsocks/blob/release-0.5/base.c#L223
DNS 部分讨了个巧, 一般处理 DNS 污染两种方式: 查询支持 EDNS 之类的加密协议的 DNS server, 或隧道到国外查询.
我目前的实现不支持 UDP, 就算支持了, 我也不想让 DNS 查询依赖上游 ss server 必须开启 UDP. 但现在既然已经有了 tcp 的加密隧道, 直接用呗, 没必要必须用支持 EDNS 的 server 呀.
DNS 协议其实本来就是支持 TCP 的, 只不过一般的流程是 client 发送 udp 的 DNS 查询, 如果 response 超过 512 字节, DNS header 中有个 TC 比特位会被置为1, client 会用 tcp 再发起一次 DNS 查询, server 用 tcp 返回完成的 response.
直接用 tcp 向 server 发送 DNS 查询 也行: dig baidu.com @1114.114.114.114 +tcp.
所以现在 DNS 部分的实现是: 收到 iptables 转发过来的 DNS 查询后, 会同时向 cn-dns 和 fq-dns 发送 DNS 查询, cn-dns 直接走 udp(所以在 SNET 中跳过了 cn-dns), fq-dns 通过 ss 走tcp.
如果 cn-dns 的返回结果是国内 ip 就用 cn-dns 的结果, 是国外 ip 就用 fq-dns 的结果. 这是 ChinaDNS 的逻辑, 这么做的前提是目前墙对被污染的域名只会返回国外 ip, (不然国内就乱掉了). 如果该网站在国内外都有 CDN, 也不会出现被跳转到国外站点去的情况(淘宝, 微博...). 网易云之类的也不会因为用国外 ip 而无法使用.
顺便根据 A 记录的 TTL 对查询结果做了个缓存, 目前工作良好.

一些小坑

开始时候没看 RFC, 处理 tcp DNS 的时候直接把 UDP payload 通过 tcp 灌过去了, 以为 DNS 协议里是有 length, 结果怎么都不对, 后来才发现要在 UDP 的 DNS 数据包前面加两字节标记长度才行(DNS 协议的 header 里并没有包长度...)
测试 openwrt 的时候, 手头只有一台几年前的极路由2, 这东西架构是 32 位的 mips, 用 go 1.12 交叉编译后却跑不起来, 原来极路由的 cpu 不支持硬件浮点数运算指令, 需要在 openwrt 编译固件的时候开启 FPU 模拟, 重编译固件太麻烦啦, 后来查到 go 1.10 开始支持软件模拟浮点数,编译 mips 时加上环境变量 GOMIPS=softfloat 就行了.
有的发行版开了 ipv6 后会在 /etc/resolv.conf 里加入一个本地 ipv6 的 dns server 地址, 如果它在第一行, snet 的 dns udp 重定向就没用了,需要关闭 ipv6 或把这条 nameserver 删掉.

后续

目前个人的需求基本已经满足啦, 可能还需要的是:
  • 按域名指定使用 cn-dns 或 fq-dns 的结果(有时还真是需要访问海外站)
  • 加点统计接口:域名查询, 流量分析...
from https://github.com/monsterxx03/blog/blob/master/content/posts/2019-03-31-snet-linux-transparent-proxy.md
------------------------------

完成 SNET 初版后又做了些后续更新, 记录一点.

支持 http tunnel

配置文件里增加一个 proxy-type 选项, 默认为 ss, 可改成 http, 这样可以将 支持 http tunnel 的代理服务器作为 upstream(例如 squid). 填上 http-proxy- 开头 的选项就行.
实现上 client 端要对接 http tunnel 非常简单:
  • client 发送请求: Connect tgt-host:tgt-port HTTP/1.1
  • server response: HTTP/1.1 200, 即表示 server 端支持 http tunnel
  • client 后续向该 tcp connection 写入的数据都会被 server 转发到 tgt-host:tgt-port
改动的时候把 upstream proxy 的部分重构了一下, 抽了个 Proxy interface 出来, 后续想对接其他协议方便扩展.

对 udp 支持的尝试

对 tcp 流量的转发能通过 iptables REDIRECT 实现的, 通过 getsockoption 可以知道 tcp connection 的原目标, 但这对 udp 行不通, REDIRECT 之后拿不到原 target.
shadowsocks-libev 对 udp 转发的支持是通过 iptables 里的 tproxy target 实现的, 尝试了一下 tproxy, 但是 tproxy 只能 用在 prerouting chain 里. 那意味着只能当该程序所在机器作为网关的时候才能接收到流量, 我写这个主要不是为了在路由器上 而是本机用, tproxy 就 pass 了.
还有一种方式是通过 tun 设备来实现流量接管, tun 工作在 3 层上, 从上面读出来的流量是裸的 ip 数据包. badvpn 之类的工具 实现了 tun2socks 的方式, 用 lwip 来在用户态实现 ip/tcp reassembly, 得到 tcp 流之后再转发给 socks5 程序. 好处是可以实现跨平台的流量接管.
还看到了一种非常扭曲的实现方式, 通过 raw socket 得到出去的 udp, 再用 普通的 socket 连回 udp client socket, 将数据写回: https://github.com/EtiennePerot/sshuttle/blob/master/firewall.py#L201 脑洞很大, 看上去很麻烦...没尝试.
目前我对 udp 实在没什么需求, 暂时没什么动力弄了, 要做的话估计只能通过 tun 来搞(引入 lwip 总有点杀鸡用牛刀的感觉).

dns 处理的一个 bug

用了一整子一直都很稳定, 但今天在用 kubectl 倒腾 k8s 的时候发现了问题, k8s cluster 在国外, api server 也是国外 ip. 开了 snet 后用 kubectl 巨慢无比, 而且经常 timeout. 关了 snet 直连却很快.
在 upsteam server 上试了下, 到 k8s api server 速度没问题.
kubectl version -v=10 能打印出对应 http request 的 curl 命令, 拷贝出来用 curl 直接试了下, 也没问题, 只有用 kubectl 的时候才会 timeout.
抓包看了下 kubectl 发起 api 请求时候, 先尝试解析了 AAAA 记录, 如果 AAAA 记录不存在, 再尝试 A 记录, 貌似调用了 glibc 里 gethostname 函数的默认行为 都是这样的.
问题出在我实现的 DNS 缓存上, 简单的根据查询域名缓存了查询结果, 实际上我只解析 A 记录, 所以从缓存里取出来的都是 A 记录的结果.
重现方式:
第二次 dig 就会报 query result mismatch, 因为返回的是第一次的 A 记录. 比较坑的是 kubectl 报的是 dail timeout, 看上去像连不上 tcp 端口, 比较难看原因.
修复很简单, cache key 把 query type 也加上就行啦.

from https://github.com/monsterxx03/blog/blob/master/content/posts/2019-04-10-snet-dev-notes.md
-------------

这两天得了空, 让 snet 支持了下 MacOS.
snet 的大致原理是通过系统防火墙的流量重定向功能,将所有去往国外的流量导到 snet 监听的端口, 在程序内部 将流量传递给上游的 proxy server(ss, http), 拿到响应后再回给客户端.
实现关键是要在 snet 内部获取到流量的原目标地址, 因为重定向之后 tcp connnection 的目标地址变成了 snet 监听 的地址.
Linux 上的实现,以前讲过: https://blog.monsterxx03.com/2019/03/31/snet-transparent-ss-proxy-on-linux/ 是通过 SO_ORIGINAL_DST 这个 socket option 实现的.
MacOS 上没有 iptables, 类似的工具是系统自带的 pfctl, 捣鼓了一下也能实现一样的功能.

用 pfctl 做流量重定向

pfctl 的文档可以通过 man pfctl, man pf.conf 查看. 我也只是看了个大概, 细节并不清楚.
流量重定向需要用到 pf 的 rdr 规则, 但是 rdr 只能处理 incoming 的流量,对 outgoing 的流量无效.
tricky 的地方是要把流量先重路由到 lo0, 再对 lo0 上的流量实行 rdr, 例子(顺序很重要):
dev="en0"
lo="lo0"
rdr on $lo proto tcp from $dev to any port 1:65535 -> 127.0.0.1 port 1100  # let proxy handle tcp 
pass out on $dev route-to $lo proto tcp from $dev to any port 1:65535  # re-route outgoing tcp

获取重定向后流量的原始目标地址

darwin 上没有 SO_ORIGINAL_DST, 翻了点 OpenBSD 的资料, 找到了几段通过 C 获取原地址的代码, 尝试用 CGO 做 wrapper, 最后试成功了.
Mac 上坑爹的地方在于, 虽然 darwin 内核里有 pf 模块, 但 MacOS 系统里没有对应的头文件,需要自己下载:
做了个 poc 的例子, 可以直接跑起来: https://github.com/monsterxx03/pf_poc
大致流程是:
  • 初始化 struct pfioc_natlook
  • 填充 client socket 的 source ip, source port
  • 填充 proxy bind socket 的 ip, port
  • 打开 /dev/pf,执行 ioctl(fd, DIOCNATLOOK, )
  • 然后 struct 的 rdaddr, rdxport 就被填充了,这就是我们需要的原始地址。
from https://github.com/monsterxx03/blog/blob/master/content/posts/2019-06-20-snet-support-macos.md