普通视图

发现新文章,点击刷新页面。
昨天以前Mythsman

Chromium + Mitmproxy 组合使用踩坑

作者 mythsman
2023年3月21日 15:57

背景

众所周知,Chromium 目前是事实上的地表最强浏览器内核,Mitmproxy 是事实上地表最强的中间人代理工具。二者组合使用可以非常方便的进行控制与数据分离的自动化数据提取。不过在实际生产中大规模使用时,还是会或多或少的遇到了一些难以察觉的坑。。。

Mitmproxy 低版本长期运行易 OOM

现象

在容器中部署 chromium + mitmproxy 后,发现在多次访问某些类型网站时,mitmproxy 经常周期性地出现内存缓慢增长,直到超过 docker 限制而被 OOMKilled。虽然有 docker 的自动重启进程功能,但是总会不可避免的导致业务上网络连接的周期性断开。

分析

初步怀疑是流量本身过多(chromium 对 mitmproxy 是“多对一”)以及给 mitmproxy 分配的内存过低导致内存不足。于是尝试将 mitmproxy 的内存配额从 200MB 增长到 1G。

但是实际结果却是这只是延长了 OOM 的时间,并没有解决问题。于是考虑是出现了内存泄漏问题,但是业务脚本无论如何也排查不出问题,因此只能暂时用 docker 自动重启进程的功能保持服务的大致可用。

同时发现似乎在 chromium 中增加 --disable-http2 的启动参数后,内存泄漏的情况会有所缓解。

解决

经过一段时间,偶然回头一看才检索到 mitmproxy 有一个 #4786 的相关 issue。原来在较低版本中(8.0.0及以下),拦截的 HttpFlow 长连接对象的确存在连接泄漏导致内存不断膨胀直至 OOM 的问题。(这样一想强制关闭 http2 长连接的确有概率会降级到短连接,从而缓解长连接的 OOM 问题。)

这个问题终于在 8.1.0 版本得到了修复(CHANGELOG):

我们要做的就是直接使用最新稳定版的 mitmproxy 即可。不过这件事情也没有想象中的容易。

如果你的系统是 ubuntu:focal (20.04 LTS) 的版本,默认安装的 python3 版本应当是 3.8.x ,这时你会发现无论如何也装不上 mitmproxy@8.1.0 版本:

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.5 LTS
Release: 20.04
Codename: focal

$ python3 --version
Python 3.8.10

$ pip3 install mitmproxy==8.1.0
ERROR: Could not find a version that satisfies the requirement mitmproxy==8.1.0 (from versions: 0.8, 0.8.1, 0.9, 0.9.1, 0.9.2, 0.10, 0.10.1, 0.11, 0.11.1, 0.11.2, 0.11.3, 0.12.0, 0.12.1, 0.13, 0.14.0, 0.15, 0.18.1, 0.18.2, 0.18.3, 1.0.0, 1.0.1, 1.0.2, 2.0.0, 2.0.1, 2.0.2, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 4.0.0, 4.0.1, 4.0.3, 4.0.4, 5.0.0, 5.0.1, 5.1.0, 5.1.1, 5.2, 5.3.0, 6.0.0, 6.0.1, 6.0.2, 7.0.0, 7.0.1, 7.0.2, 7.0.3, 7.0.4, 8.0.0)
ERROR: No matching distribution found for mitmproxy==8.1.0

这里的关键是要记得去 pypi 上去看下 mitmproxy 对不同 python 版本的支持:8.0.0 的最低支持 python 版本是 3.8;而刚巧修复了 bug 的 8.1.0 的最低支持 python 版本就跳到了 3.9。于是这里又要继续升级 python3 到 3.9 以上。

这里又有两条路:要么需要在 20.04 的 ubuntu 里增加新的 python3.9 的源,把老的 python3.8 的相关数据清理干净,再安装新的 python3.9 ;要么直接升级到 jammy (22.04 LTS)。

经过一番尝试,发现在老的镜像里升级 python3.9 还是非常麻烦的,处理不好经常会残留一些老版本的库。于是我这里选择了直接将基础镜像换成了 ubuntu:22.04 。

全部升级完成后,正常运行的 mitmproxy 的内存占用基本都会维持在 100MB 左右了,还是非常稳定的。

Chromium 忽略证书校验会导致缓存失效

现象

原先的系统架构是先启动一个 mitmdump 服务监听 8888 端口,再使用一个基于 chromium 内核的浏览器,通过 --proxy-server=localhost:8888 将流量指向代理服务,再通过 --ignore-certificate-erros 参数忽略对 mitmdump 的自签名证书的校验,保证流量器正常访问。

同时为了减少图片、视频等带来的带宽损失,结合具体任务,在 mitmdump 的脚本里将视频、图片等相关的请求 drop 掉,保持对流量的高效利用。

本来这就是一个非常的朴素、透明、易理解的普通架构,线上也稳定运行了多年,没啥大的变动也没人想着改。不过近期业务流量逐渐大了起来,发现出口带宽有点撑不住了。于是增加了个对响应体的 Content-Type 监控,发现流量的大头竟然是 application/javascript 这一类的东西。

这显然不太合理,因为这些 javascript 资源理论上都是走的 cdn,数据都会带 Cache-Control 相关 header 方便浏览器进行本地缓存。在重复执行类似网页的时候,大概率应当会复用之前已经缓存好的 javascript 文件。

分析

仔细审查了一下正常浏览器请求和线上环境下请求的资源请求情况,果然发现了不同点。

本地环境:

线上环境:

可见本地环境的各种 javascript 资源在多次请求时都是要么命中了 memory cache ,要么命中了 disk cache,从而正常节省了流量。而 线上环境的各种 javascript 资源却只会命中 memory cache 而从未命中过 disk cache。

仔细对比了二者的环境下 chromium 的启动参数差别,多次实验后(需要注意每次实验之间一定要清空用户目录)终于发现区别只在于 本地环境没有使用 mitmproxy 抓包,而线上环境配置了mitmproxy抓包 。在本地环境下配置了 mitmproxy 抓包后终于复现了线上场景。

经过一番搜索,竟然在 MicrosoftEdge 的项目 issue #2634 里找到了对 chromium 问题的解释,具体原因可以参见 chromium 这里的解释:

Status: WontFix : The rule is actually quite simple: any error with the certificate means the page will not be cached.

没错,chromium 做了这样一个规定:证书错误的页面不会被持久化缓存,即使你配置了忽略证书校验。

解决

问题原因发现了,解决起来也就容易了。至少有两种方案可以处理:

  1. 在 mitmproxy 层基于 Http 的 Cache-Control 相关协议,自己实现一层静态资源的持久化缓存。
  2. chromium 不配置 --ignore-certificate-errors ,而是直接想办法将 mitmproxy 的证书种到 chromium 信任的 CA 里,保证对 TLS 流量的正常解析。

实测下来,二者都能很好地优化大并发任务下的网络请求。javascript 相关请求量近似跌零,整体的流量会减少 70% 以上。不过总体看下来,方案二处理起来更加便捷和稳妥。

Chromium 默认不信任 Linux 下的系统证书

现象

话接上一个问题的解决方案二,想将证书种到 chromium 中其实并不简单。一个 Ubuntu 下的通用种 mitmproxy 证书的方法是:

  1. $HOME/.mitmproxy/mitmproxy-ca-cert.pem 中拿到 mitmproxy 的默认证书;或者自己用 openssl 生成一对证书+私钥,并放在 mitmproxy 的相应位置下。
  2. 将上述的 mitmproxy-ca-cert.pem 复制到 /usr/local/share/ca-certificates 下,并重命名为 mitm.crt (一定要以 crt 为后缀)。
  3. 执行 update-ca-certificates ,会自动将 mitm.crt 按证书信息重命名并软链接到 /etc/ssl/certs 中。

这样搞完,例如 curl wget 等绝大多数应用就都能认得我们自签名的证书了。

可惜 chromium 不是这绝大多数,实测下来依然不信任我们已经种在系统 CA 里的自签名证书。

分析

其实不信任系统默认 CA 证书的事情也很常见,比如很多 App 为了安全考虑会自己做 SSL Pinning,不信任用户机器上的证书;或者像 Java 这种工具为了跨平台的考虑也不会使用系统的证书,而是使用自己存储的 keystore。这里 Chromium 可能是也是出于类似考虑,反正也是默认只信任了自己安装时带过来的证书。对于用户新增的证书,也是希望直接通过软件本身的配置进行管理。

官方配置中添加自签名证书的方法是通过 chrome://settings/certificates 自行导入。

不过显然,这中配置方式对于打镜像并不合适,我们还是要寻找通过配置文件进行配置的方案。

解决

一番搜索后,从 superuser 中的这篇文章大概了解了 chromium 对自定义证书的管理方式。官方的说明是在 chromium 的 cert_management 文档中。

简而言之,Linux 下的 Chromium 使用的是公共 nssdb 来管理证书。数据存放在 $HOME/.pki/nssdb 下。

如果这个目录不存在,那么在第一次打开 Chromium 时会自动创建。不过对于预构建的环境来说,这里还是需要自己事先初始化下的。

具体步骤如下:

$ mkdir -p ~/.pki/nssdb                                       # 准备路径和文件夹

$ certutil -d ~/.pki/nssdb -N --empty-password                # 初始化DB环境

$ ls ~/.pki/nssdb/                                            # 查看DB文件
cert9.db  key4.db  pkcs11.txt

$ certutil -d ~/.pki/nssdb -L                                 # 查看证书信息(目前为空)

Certificate Nickname                                         Trust Attributes
                                                             SSL,S/MIME,JAR/XPI

$ certutil -d ~/.pki/nssdb -A -t "C,," -n mitm -i ~/mitm.crt  # 将准备好的证书导入进 CA

$ certutil -d ~/.pki/nssdb -L                                 # 查看导入后的证书信息

Certificate Nickname                                         Trust Attributes
                                                             SSL,S/MIME,JAR/XPI

mitm                                                         C,,

正常情况下,这样处理是没有问题的,不过具体使用时,还是踩了一些坑。

注意到 chromium 文档中给出的所有 nssdb 相关指令的 -d 参数和我上述用的有所不同,多带了一个 sql: 的前缀:

$ certutil -d sql:$HOME/.pki/nssdb -L

这是因为在本地测试时,由于 bash 用习惯了,直接用 ~ 代替了 $HOME 。结果命令敲出来结果就是这样:

$ certutil -d sql:~/.pki/nssdb -L
certutil: function failed: SEC_ERROR_BAD_DATABASE: security library: bad database.

报了一个奇怪的错,想了半天没想明白问题出在哪,随手试了试将 sql: 前缀干掉,发现一切又都能 work 了,也就是我上述记录的命令。

回头仔细看了下文档:

       -d [prefix]directory
           Specify the database directory containing the certificate and key database files.

           certutil supports two types of databases: the legacy security databases (cert8.db, key3.db, and secmod.db) and new
           SQLite databases (cert9.db, key4.db, and pkcs11.txt).

           NSS recognizes the following prefixes:

           o   sql: requests the newer database

           o   dbm: requests the legacy database

           If no prefix is specified the default type is retrieved from NSS_DEFAULT_DB_TYPE. If NSS_DEFAULT_DB_TYPE is not set
           then dbm: is the default.

原来 nssdb 是有两种模式的,可以通过为 -d 参数加不同前缀指定。但是坑爹的是如果指定了前缀,似乎就无法识别 bash 下的 ~ 。。。因此这里要么不用 ~ 、改用完整路径,要么就不指定 db ,使用默认配置即可。

最后,这个 pki 的文件权限也要注意,开启 chromium 的用户一定要对这个目录有读写权限。一个稳妥的方法就是 chown -R 一下,保证用户权限没问题。

这样一番配置后,终于可以在 chrome://settings/certificates 下看到新增的自签名证书了。

记一次家庭科学网络架构调整

作者 mythsman
2023年3月16日 13:17

背景

年初入了折腾软路由的坑,买了个 R66s 玩了玩。考虑到是头一次折腾,各个地方的配置都比较保守,没有默认将所有的网关都设置成 R66s ,而是由各个客户端手动配置到软路由网关。用了一段时间下来感觉在正常使用的情况下都还是很稳的,于是打算配置的更激进一点。

刚巧,之前移动的人总是打电话过来,说他们之前给我装的光猫有故障,把某个什么设备的端口打满了,导致影响了其他用户的使用(不过我用起来都很正常),希望能上门我换一个新的光猫。

考虑到一直接电话实在不胜其烦,于是就同意让他们上门换了一波。意外的收获是,这次不仅免费帮我多续费了四个月,而且除了给光猫之外,还多给了一个 WIFI 路由器🥹。

趁着新 WIFI 路由器的余威加持,就把家庭科学网络架构调整了一下,使其更好的符合家庭网络的两大宗旨(by jack stone):

  1. 所有设备统一在一个网段下,只有一层 NAT 转换,只有一个负责 NAT 的设备。
  2. 所有网络控制功能都应该尽力收归 OpenWrt 服务器。

设备说明

  • Raspi 是用来冒充 NAS,并另外部署一些 Docker 服务的树莓派4B
  • OpenWrt 是用来科学工作的 R66s 软路由
  • Modem 是移动安装的带 WIFI 功能的光猫路由器,使用默认的 NAT 模式。
  • Wifi Router 是移动后来多送的一个 WIFI 路由器。

旧架构(旁路由)

最初的需求只是想保守地尝试使用软路由进行科学工作,因此对原生的网络结构没有做任何调整。只是在初始的网络环境下新增了一个 Openwrt 作为网关服务器。

配置要点:

  1. Modem 配置不做任何变更(其实也没权限配置),默认自动拨号、默认使用 NAT 模式、默认开启 DHCP 服务。
  2. OpenWrt 的 LAN 口接入 Modem LAN 口,手动配置 IP 为 Modem 子网下的一个固定IP。
  3. 其他设备均正常接入。默认连上 WIFI 的设备不走 OpenWrt。需要时不时讲科学的机器就配置网关和 DNS 指向 OpenWrt 的 IP。

优点:

  1. 无需打电话找运营商改光猫配置。这一点对于经常搬家的社恐租房党还是很省事的。
  2. 设备要求少。无需额外 WIFI 路由器也能让手机等无线设备实现简单的科学功能。
  3. 网络层级简单。只有光猫的一层 NAT ,一层 DHCP。
  4. 容错能力强,OpenWrt 挂了也不影响正常设备的使用。

缺点:

  1. 科学配置比较麻烦。设备需要手动修改网关和 DNS 指向 OpenWrt。
  2. OpenWrt 无法获得完整的网络控制功能。由于 DHCP 使用的是 Modem 的,因此无法通过 OpenWrt 对家庭网络进行完整的控制。
  3. OpenWrt 无法获得完整流量。对于不走加密信道的情况,即使设备将网关配置指向了 OpenWrt,也只会让上行流量经过;下行流量则会直接由 Modem 返回设备。这就导致在 OpenWrt 部署的流量监控服务其实无法检测到完整流量。

新架构(主路由)

在有了白嫖的 WIFI 路由器之后,网络架构就可以灵活很多了。我们可以抛弃掉 Modem 自带的 WIFI,改用配置更方便的的 WIFI 路由器,并自动配置网关指向 OpenWrt。这样可以使得 OpenWrt 对下游的网络拥有完整的控制能力。

配置要点:

  1. Modem 仍然无需任何配置变更。不过出于效率,可以关闭 WIFI 功能。
  2. OpenWrt 用 WAN 口连接 Modem 的 LAN 口。WAN 口使用 DHCP 即可,LAN 口配置静态地址 IP 为一个与 Modem 不同的新网段,并开启 DHCP 服务
  3. Wifi Router 用 WAN 口连接 OpenWrt 的 LAN 口。调整配置为桥接模式,使得后续接入 WIFI 或者接入 LAN 口的其他设备都进入和 WAN 口的同一个子网。
  4. OpenWrt 在管控面板中为下游需要访问的设备配置静态IP。
  5. 后续如果需要添加有线设备,则直接接入 Wifi Router 空余的LAN口,或者在 OpenWrt 到 Wifi Router 之间新增小交换机即可。不要接入 Modem 上的空余 LAN 口(因为会不受 OpenWrt 管理)。

优点:

  1. 仍然无需打电话找运营商改光猫配置。
  2. OpenWrt 拥有对下游网络的全面控制权。
  3. 科学配置方便,无线设备等只要接入 WIFI 就科学了,不用做任何配置。
  4. OpenWrt 作为主路由能获得子网下的全部流量。

缺点:

  1. 配置稍显复杂,容易配错。
  2. 看起来有了两层 NAT ,网络略微复杂。但其实这里也可以把 Modem+OpenWrt 看成一个整体,其实真正用到的设备还是只在一层 NAT 下的。当然如果硬是想改成一层 NAT 也简单,只需要找运营商把光猫改下桥接,再让 OpenWrt 拨号即可。

Ghost 解决 jsdelivr 资源加载慢的问题

作者 mythsman
2023年1月27日 22:58

背景

用了很久的自建 Ghost 博客系统不知道从哪个版本开始,页面加载速度忽然变慢了很多。看了下加载的资源,发现多了很多走 jsdelivr cdn 的资源,加载速度竟然长达半分钟。。。

本来选择自建博客系统的重要目的之一就是为了页面加载速度可控,尽量避免加载不可靠、容易被墙的第三方资源。结果没想到 Ghost 官方又在核心模块里引用了第三方的 CDN。

不过还好 Ghost 项目本身的配置化做的还是不错的,大年初六上班摸个鱼的时间解决了一下。

解决

仔细看了下,新加入的走 CDN 的资源主要是 会员系统(portal)+评论系统(comments)+页面搜索 (sodo-search),因此在某次支持这些系统的更新前都是没问题的。不过考虑到目前的主题已经集成了这些系统,所以这些功能也不能禁用掉。

参考 Ghost Forum 的这篇讨论,可以通过在 config.[env].json 中修改配置,将 url 等替换成 self-hosted 的版本。不过这里的讨论中提到的配置来源并不清晰,在源码中搜索了一番发现了端倪(这里的 /var/lib/ghost 目录是我这 docker 里的 ghost 安装目录):

root@1f7b379a87f4:/var/lib/ghost/current# grep -r 'cdn.jsdelivr.net/ghost' *
core/shared/config/defaults.json:        "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
core/shared/config/defaults.json:        "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
core/shared/config/defaults.json:        "styles": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/main.css",
core/shared/config/defaults.json:        "url": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/comments-ui.min.js",
core/shared/config/defaults.json:        "styles": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/main.css",

可见这些配置都来源于 core/shared/config/defaults.json 这个文件,涉及到 CDN 的相关配置如下:

{

    ...

    "portal": {
        "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
        "version": "2.23"
    },
    "sodoSearch": {
        "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
        "styles": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/main.css",
        "version": "1.1"
    },
    "comments": {
        "url": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/comments-ui.min.js",
        "styles": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/main.css",
        "version": "0.12"
    },
    
    ...

 }

为了自托管这些文件,我们首先将这些文件下载到静态文件夹下,然后在想办法将 config.[env].json 配置好即可。

不过问题来了,静态文件夹有哪些?如果想当然的放在 /var/lib/ghost/content/public 下,肯定是要吃瘪的。正解应该是要看下 /var/lib/ghost/current/core/frontend/web/site.js 这里启动 express 的地方:

module.exports = function setupSiteApp(routerConfig) {
    debug('Site setup start', routerConfig);

    const siteApp = express('site');

    //...
    
    // Serve sitemap.xsl file
    siteApp.use(mw.servePublicFile('static', 'sitemap.xsl', 'text/xsl', config.get('caching:sitemapXSL:maxAge')));

    // Serve stylesheets for default templates
    siteApp.use(mw.servePublicFile('static', 'public/ghost.css', 'text/css', config.get('caching:publicAssets:maxAge')));
    siteApp.use(mw.servePublicFile('static', 'public/ghost.min.css', 'text/css', config.get('caching:publicAssets:maxAge')));

    // Card assets
    siteApp.use(mw.servePublicFile('built', 'public/cards.min.css', 'text/css', config.get('caching:publicAssets:maxAge')));
    siteApp.use(mw.servePublicFile('built', 'public/cards.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge')));

    // Comment counts
    siteApp.use(mw.servePublicFile('built', 'public/comment-counts.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge')));

    // Member attribution
    siteApp.use(mw.servePublicFile('built', 'public/member-attribution.min.js', 'application/javascript', config.get('caching:publicAssets:maxAge')));

    // Serve site images using the storage adapter
    siteApp.use(STATIC_IMAGE_URL_PREFIX, mw.handleImageSizes, storage.getStorage('images').serve());
    // Serve site media using the storage adapter
    siteApp.use(STATIC_MEDIA_URL_PREFIX, storage.getStorage('media').serve());
    // Serve site files using the storage adapter
    siteApp.use(STATIC_FILES_URL_PREFIX, storage.getStorage('files').serve());
    
    //...
};

显然,这里 public 文件夹下的文件都是单独配置绑定的,不是整个文件夹的绑定。额外添加文件的话是不会映射到外部路径上的。这样一来,我们只能绑定在 images , media , files 这些路径下。再 check 下这些具体的变量,就会得知这些路径映射到的外部路径:

root@1f7b379a87f4:/var/lib/ghost/current# grep -r 'STATIC_.*_URL_PREFIX' *
...
node_modules/@tryghost/constants/index.js:    STATIC_IMAGES_URL_PREFIX: 'content/images',
node_modules/@tryghost/constants/index.js:    STATIC_MEDIA_URL_PREFIX: 'content/media',
node_modules/@tryghost/constants/index.js:    STATIC_FILES_URL_PREFIX: 'content/files'

我这里就选择放在 files 文件夹下,这里新建一个 self-host 文件夹:

root@1f7b379a87f4:/var/lib/ghost/content/files/self-host# ls
comments-ui-0.12.css  comments-ui-0.12.min.js  portal-2.23.min.js  sodo-search-1.1.css  sodo-search-1.1.min.js

由于我是通过 docker-compose 部署,相比于修改 config 文件,直接通过环境变量配置更为方便,变量名跟 config 中的 json 格式一一对应,这里不得不夸奖下 Ghost 的配置自动映射做的挺方便:

version: "3.8"
services:
  ghost:
  image: ghost:5.27
  container_name: "ghost"
  environment:
    ...
    portal__url: /content/files/self-host/portal-2.23.min.js
    sodoSearch__url: /content/files/self-host/sodo-search-1.1.min.js
    sodoSearch__styles: /content/files/self-host/sodo-search-1.1.css
    comments__url: /content/files/self-host/comments-ui-0.12.min.js
    comments__styles: /content/files/self-host/comments-ui-0.12.css
  ...

效果

首次加载速度直接从 30s 优化到了 300ms ,优化效果十分感人。。。

更新(2024.4)

考虑到 Ghost 经常会更新,每次都复制文件怪麻烦的,于是搭建了一个简单的 jsdelivr 代理 :

https://cdn.mythsman.com/jsdelivr/{path_to_jsdelivr}

这样只要在 docker-compose 做如下配置就好:

ghost:
    image: ghost:5.69.3
    container_name: "ghost"
    restart: always
    depends_on:
        - "mariadb"
    environment:
        # ...
        portal__url: https://cdn.mythsman.com/jsdelivr/ghost/portal@~2.37/umd/portal.min.js
        sodoSearch__url: https://cdn.mythsman.com/jsdelivr/ghost/sodo-search@~1.1/umd/sodo-search.min.js
        sodoSearch__styles: https://cdn.mythsman.com/jsdelivr/ghost/sodo-search@~1.1/umd/main.css
        comments__url: https://cdn.mythsman.com/jsdelivr/ghost/comments-ui@~0.16/umd/comments-ui.min.js
        # ...
    volumes:
        - ./ghost/:/var/lib/ghost/content
    expose:
        - "2368"

OpenWrt+R66s 软路由入门尝鲜

作者 mythsman
2023年1月11日 15:46

背景

随着家里电子设备越来越多,客户端维度的科学上网配置已经逐渐支持不了日常需求了:

  1. 设备数量越来越多;配置不过来,且容易触发机场的客户端连接上限。
  2. 设备类型越来越多;安卓端、IPhone端、IPAD端、Mac端、树莓派端、PC端、Kindle端,需要的客户端各不相同,配置的思维负担太大,甚至有些类型的设备没有靠谱的客户端。
  3. PAC规则越来越多;自己通常会有一个主要用的PAC规则,但是当换设备后,就需要重新配置,非常麻烦。

于是趁着工作之闲,研究了一下软路由,一站式解决了家庭上网问题。

硬件选购

市场上的可自由刷机的路由器还是很多的,不过对于入门级屌丝租客党来说,明白自己到底需要什么是很重要的:

  1. 不需要 WIFI 功能。移动光猫自带 WIFI 功能,软路由只需专注做网关服务器即可。后续如果对 WIFI 有更高要求再另买专用的 WIFI。所谓专业的东西做专业的事。
  2. 不需要 HDMI 功能。带 HDMI 功能的基本都是定位于迷你主机或者电视盒子的,其实做路由器根本用不到。
  3. 网口并不需要太多。考虑到家庭设备都是用光猫自带的WIFI连接,因此不需要很多网口。最低一个 LAN 口其实就足矣。
  4. 性能并不需要太高。毕竟租房的时候办的也就是200M带宽,网线再快也没用。同时家里也没搞什么视频工作站,内网也基本没啥带宽。况且性能高了的话、耗电、散热、占地都是问题。
  5. 芯片尽可能新一点。芯片类产品买新不买旧,就算不太在乎性能,也得考虑性价比,少买老古董不做垃圾佬。
  6. 尽量支持卡刷。线刷基本都需要去官方文档上查流程,比较麻烦。而卡刷的流程几乎都是一样的,拔卡写镜像开机即可。

基于上述原则,类似小米AX6000之类的 WIFI 路由器基本不会考虑了;类似J4125之类的性能较强、耗电较高的基于 X86 的路由器基本也不会考虑了;类似 R2s ,N1盒子之类的老东西也不考虑了。

搜寻了一番,发现电犀牛(FastRhino)的 R66s 刚好符合我的基本要求。

  • 接口和大小刚合适。两个2.5G网口、两个USB3.0口、使用SD卡、无HDMI口,因此十分小巧。
  • 产品和芯片比较新。产品是2022年5月发布,瑞芯微(Rockchip)RK3568芯片。
  • 性价比较高。2G内存+32G卡配置到手320¥不到。

相比一些从电视盒子改来的玩意、或者羞羞答答才放开刷机功能的WIFI路由器、或者是仗着原装OpenWrt割韭菜的路由器来说,这个产品还是比较大胆和良心的。

固件选择

OpenWrt官方固件自然也是可以的,不过考虑到国人的常用需求,一般都是用国内维护的固件。常见的固件有:

  1. Lean 的 LEDE 固件(官网)。
  2. 易有云的 iStoreOS 固件(官网)。
  3. Flippy 的固件(官网)。

个人感觉这些固件其实都大同小异,主要区别还是上游的 OpenWrt 版本不同、Linux 内核版本不同、默认自带的软件包不同(尤其是 iStoreOS 自带了很多私货应用)。其实 R66s 出厂自带的镜像也是够用的(其实就是 Lean 的固件),只是出于政策原因,没有自带科学上网工具。这些东西动动小手自己装下也行,不过为了方便大家开箱即用,直接写到固件里用起来更方便而已。

如果需要自己刷机,其实也不复杂。用读卡器插入PC,用 rufus 工具直接写入指定镜像即可。

这里还遇到了有一个有意思的点,原装的 32G SD 卡插进来之后,Windows系统的文件管理器只识别出了 64MB。原本以为是遇到了奸商,但是用磁盘管理工具一看的确是 32G 的盘。后来研究后才发现是因为这个是系统引导盘,因此有时只会识别系统主分区的大小,有时甚至不会被识别为外部存储,不影响使用。

正常刷完后,SD 卡分区大致如下。只使用了主分区,还有大量未分配的区域,如有需要等启动后再新增分区即可。

网络设计

相关背景知识可以先看一下油管 jack stone的家庭网络设计,讲的挺好,颇有老司机风范。

考虑到我这里光猫自带的WIFI也够用,但是光猫后台没有admin权限,很多东西不好改。并且出于社恐也不想打电话找宽带师傅把光猫的路由模式改桥接模式。因此我还是采用了 光猫做主路由+R66s做旁路由 的方案:

  1. 软路由和光猫以 LAN口-LAN口相连。
  2. 整个网络中只使用光猫的网段和光猫的DHCP服务。
  3. 光猫不做任何配置,由接入的客户端手动配置网关和 DNS 到软路由。

网络配置

对于新手小白来说,这一步最容易踩坑。尤其是当接线和配置不对时,连后台都打不开,这是最让人沮丧的。我个人也是尝试了很多姿势才了解其中的弯弯绕。

错误姿势:软路由LAN口直接接光猫LAN口

唉?前面不是说软路由和光猫就是要LAN口相连么?没错,但那是指的配置完成后的连接方式。

在设备出厂、或者刷了新固件后,说明书或文档上都会写清楚当前固件默认配置的IP。例如 R66s 刷的 Lean 固件默认 IP 就是 192.168.100.1。这个配置的意思是,当前设备的 LAN 口配置的是 192.168.100.1 的静态 IP。

而我这边光猫的网段是 192.168.1.0/24 ,软路由的静态 IP 不在这个网段内。这就导致连接光猫的PC设备和软路由是无法连接的,自然也就访问不到后台了。

错误姿势:软路由WAN口直接接光猫LAN口

既然软路由配置静态IP不行,那是不是直接用 WAN 口和光猫的 LAN 口相连就行了?毕竟 WAN 口的默认配置是 DHCP 协议,肯定可以直接加到光猫的网段中。

理论上说当然可以,但是实际上还是要看固件的配置。因为出于安全考虑,很多固件的默认防火墙配置是只允许从 LAN 口访问后台,不允许从 WAN 口访问。

如果我记得不错的话,iStore OS 默认是允许从 WAN 口访问的,而 Lean 固件默认是不允许从 WAN 口访问的。

正确姿势:软路由LAN口先和电脑直连配置好后再接光猫LAN口

为了避免上面的各种问题,最稳妥的方法还是电脑端直接和光猫LAN口相连(或者通过交换机相连),这样就避免了各种上面的幺蛾子。如果一不小心光猫的静态IP还和光猫的IP冲突了,直接断开光猫即可。

首次连接到软路由后台后,按照旁路由的配置思路,需要在 网络-> 接口 -> LAN 中进行如下配置:

  1. 配置协议为静态地址。
  2. 配置 IPV4 地址为当前光猫网段下的一个未被占用的IP地址。
  3. 配置子网掩码为光猫的掩码。
  4. 配置 IPV4 网关为光猫的 IP 地址。
  5. 配置 DNS 服务器为光猫的 IP 地址。
  6. 关闭 DHCP 开关。

配置完成后,当前页面应当就加载不出来了,这时候将软路由和光猫 LAN-LAN 相连,然后通过这里配置的静态IP就又可访问了。

OpenWrt重要路径

OpenWrt 基于 Linux,但是一些重要的配置和Linux 还是有些区别的。重点要关注下面路径下的文件:

  1. /etc/config 这里记录了各个应用的通用配置。操作这里的文件和在 luci 页面中操作是等价的。
  2. /etc/init.d 这里记录了各个应用的启动命令,类似 service 或是 systemctl 的功能。
  3. /usr/lib/lua/luci/ 这里记录了 luci 的页面信息,用于操作 /etc/config 的配置。

配置 SSR-PLUS

Lean 国际版固件自带,或者直接在软件包下搜索安装下面两个包,配置上自己准备好的机场,就能开心使用了。

luci-app-ssr-plus
luci-i18n-ssr-plus-zh-cn

由于 SSR 会深入影响 DNS 解析,路由转发等基础功能。因此一般来说,SSR可以算是家庭网络的最大不稳定因素。所以这里有必要简单了解下 SSR-PLUS 的工作原理,方便定位网络问题。

单纯的 SSR 其实就是一个基于 libshadowsocks 的加密 socks 代理。不过仅此而已肯定不够,毕竟更重要的问题是“选择哪些流量走代理,哪些流量不走代理“。因此我理解 SSR-PLUS 就是 OpenWrt 下 SSR 结合了 iptables + ipset + dnsmasq 的组合工具。

SSR-PLUS 在启动时会加载两个外部订阅的数据。这些配置可以在 /etc/ssrplus 下看到:

  1. 国内IP列表。里面记录了部署在国内不会被墙的IP网段。
  2. GFW 列表。里面记录了被墙的域名。

国内IP列表比较简单,就是在 ipset 里添加一个 china 的路由规则集,方便后续进行 iptables 设置。而GFW 列表除了设置 ipset 之外,还要进行 DNS 选路。

进行 DNS 选路

众所周知,DNS 是明文报文,无良运营商可以很方便的进行 DNS 污染,导致直接解析到错误 IP,更别提后续的连接了。

一种简单的解决方案是将所有 DNS 请求都走 SSR 的 socks 代理,但这样毕竟效率较低。因此实践中常用的办法就是:

  1. 本地启动一个走 SSR 代理的 DNS 服务。可以用 dns2tcp 或者 dns2socks 之类的工具。通常暴露的是 5335 端口。
  2. 根据预先配置的 GFW 列表,告知本地的 dnsmasq 服务,对 GFW 列表内的域名走加密通道,对GFW列表外的域名走普通通道。SSR-PLUS 对 dnsmasq 的配置可以参见 /tmp/dnsmasq.d/dnsmasq-ssrplus.d

这样就能做到安全的 DNS 了。

实际用起来发现dns总是容易挂。。。这里需要加一个定时监测脚本拉一下:

ps -ef|grep /usr/sbin/dnsmasq |grep -qv grep

dns_alive=$?

if [ $dns_alive -ne 0 ]
then
  echo 'dnsmasq down !!!'
  /etc/init.d/dnsmasq restart
  echo 'dnsmasq restarting !!!'
fi

配置 ipset 规则

我们知道 GFW 列表只记录域名,而在用 iptables 对流量路由转发时只能拿到 IP。因此 dnsmasq 在解析 GFW 后,还会将解析出来的 IP 加入到一个名为 gfwlist 的 ipset 规则。

root@OpenWrt:~# ipset list gfwlist
Name: gfwlist
Type: hash:net
Revision: 6
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 56232
References: 2
Number of entries: 1686
Members:
20.198.162.78
65.21.236.8
142.250.186.54
203.77.190.0
54.148.90.231
....

这样一来,再配合 iptables 的 NAT 的 PREROUTING 设置,就能做到针对 GFW 内的域名进行代理转发:

root@OpenWrt:~# iptables -L -t nat
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
SS_SPEC_WAN_AC  tcp  --  anywhere             anywhere             /* _SS_SPEC_RULE_ */
...

Chain SS_SPEC_WAN_AC (2 references)
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set gfwlist dst
...

Chain SS_SPEC_WAN_FW (4 references)
target     prot opt source               destination         
RETURN     all  --  anywhere             0.0.0.0/8           
RETURN     all  --  anywhere             10.0.0.0/8          
RETURN     all  --  anywhere             127.0.0.0/8         
RETURN     all  --  anywhere             169.254.0.0/16      
RETURN     all  --  anywhere             172.16.0.0/12       
RETURN     all  --  anywhere             192.168.0.0/16      
RETURN     all  --  anywhere             0.0.0.224.in-addr.arpa/4 
RETURN     all  --  anywhere             240.0.0.0/4         
REDIRECT   tcp  --  anywhere             anywhere             redir ports 1234

GFW模式和绕过中国大陆IP模式的区别

有了上面的基础,我们就明白了其实这两种模式的区别也很简单。看下两种模式下的 iptables 表就清楚了。

GFW模式

Chain SS_SPEC_WAN_AC (2 references)                 
target     prot opt source               destination                  
RETURN     all  --  anywhere             anywhere             match-set whitelist dst
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set blacklist dst
RETURN     all  --  anywhere             anywhere             match-set bplan src         
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set fplan src    
RETURN     tcp  --  anywhere             36.156.102.177       tcp dpt:!domain              
RETURN     all  --  anywhere             anywhere             match-set china dst         
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set gfwlist dst  
                                                               

简要的工作流程是:

  1. 判断目的地址是否在白名单,如果是则不转发。
  2. 判断目的地址是否在黑名单,如果是则直接转发。
  3. 判断目的地址是否是大陆IP,如果是则不转发。
  4. 判断目的地址是否在GFW列表中,如果是则直接转发。
  5. 最后默认不转发

绕过中国大陆IP模式

Chain SS_SPEC_WAN_AC (2 references)                 
target     prot opt source               destination                     
RETURN     all  --  anywhere             anywhere             match-set whitelist dst
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set blacklist dst
RETURN     all  --  anywhere             anywhere             match-set bplan src         
SS_SPEC_WAN_FW  all  --  anywhere             anywhere             match-set fplan src    
RETURN     tcp  --  anywhere             36.156.102.177       tcp dpt:!domain              
RETURN     all  --  anywhere             anywhere             match-set ss_spec_wan_ac dst
RETURN     all  --  anywhere             anywhere             match-set china dst         
SS_SPEC_WAN_FW  all  --  anywhere             anywhere 

简要的工作流程是:

  1. 判断目的地址是否在白名单,如果是则不转发。
  2. 判断目的地址是否在黑名单,如果是则直接转发。
  3. 判断目的地址是否是大陆IP,如果是则不转发。
  4. 最后默认转发

大白话总结就是:

  1. GFW 列表模式是:默认不翻墙,除非你在GFW列表中配置。
  2. 绕过大陆IP模式是:默认翻墙,除非你在国内。

那么具体家里选哪个更合适呢?我个人建议选 GFW 列表模式。虽然绕过大陆IP模式看起来方便,但是:

  1. 考虑到正常大部分流量还是不希望转发的,任何网站只有在GFW列表中、或者你配置的黑名单中才会翻墙。这样你会明确的知道哪些是翻的,哪些是不翻的。
  2. 中国大陆IP列表其实并不完善,很多运营商会偷摸摸使用一些保留IP作为局域网IP(例如类似 224.0.0.68 这类地址)。这就导致这些局域网IP会被中国大陆IP列表当成是海外IP进行转发。这显然就会导致难以排查的网络问题。(尤其是使用 N2N 之类的 P2P VPN 工具时)

配置 socat

不知道为啥 OpenWrt 自带的端口转发功能总是不能正常 work,尤其是转发到内网的其他 IP 时。这时候就需要 socat 来进行额外的端口转发功能。

安装下面的包即可:

luci-i18n-socat-zh-cn
luci-app-socat

然后就可以在 网络 -> Socat 里愉快的进行端口转发了。

配置 n2n

使用 v3 版的 n2n 需要先更新下 opkg 的源,否则拉不到适配 v3 版本的 luci-app 。虽然可以直接改 /etc/config/n2n 配置文件,不过毕竟不太优雅。

不知道为啥固件自带的源长这样,luci 的版本都跟其他的不一样。。。

src/gz openwrt_base https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/base
src/gz openwrt_luci src/gz openwrt_luci https://downloads.immortalwrt.org/releases/packages-18.06-k5.4/aarch64_generic/luci
src/gz openwrt_packages https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/packages
src/gz openwrt_routing https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/routing
src/gz openwrt_telephony https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/telephony

改成一样即可:

src/gz openwrt_base https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/base
src/gz openwrt_luci https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/luci
src/gz openwrt_packages https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/packages
src/gz openwrt_routing https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/routing
src/gz openwrt_telephony https://downloads.immortalwrt.org/releases/21.02.1/packages/aarch64_generic/telephony

配置好 n2n 客户端后,再在服务端配置好 nginx,挂上 ssl 域名 ,就能从远端访问家里的 OpenWrt 配置后台了。

当然,由于有些 luci 组件是需要额外暴露端口的,因此直挂 80 端口的 nginx 的话,实时监控、tty终端之类的功能还是没法用的。

运行一段时间后发现 n2n 的客户端 edge 经常时不时的挂掉,logread 看了下日志:

# logread |grep n2n
Thu Mar 30 19:24:48 2023 daemon.info n2n-edge[21183]: 30/Mar/2023 11:24:48 [n2n.c:38] ERROR: Unable to create socket [No file descriptors available][-1]
Thu Mar 30 19:24:48 2023 daemon.info n2n-edge[21183]: 30/Mar/2023 11:24:48 [edge_utils.c:273] ERROR: failed to bind main UDP port 0
...

看起来似乎是进程打开的最大句柄数超过了上限,确认了一下发现 ulimit -a 里看到的限制是 1024 ,实际 ls -lA /proc/{pid}/fd|wc -l  看到的正好也是 1024 (ls 的 -lA 参数忽略了 . 目录和 .. 目录,如果直接 ls -la 则是 1026)。。。

解决方式是改 ulimit 增大全局配置,或者直接修改 /etc/init.d/n2n 配置文件,增加进程的配额。而我选择后者(参考 openwrt 配置):

  procd_set_param limits core="unlimited"
  procd_set_param limits nofile="10240"
  procd_set_param limits nproc="10240"

(更新)发现上面的配置没用, cat /proc/{pid}/limits 发现 nofile 的配置并没有生效。于是自己写了一个脚本,当 fd 超过限制就自动重启下 n2n,放在 cron 里定期执行:

n2n_pid=`ps -ef|grep /usr/bin/n2n-edge |grep -v grep |awk '{print $1}'`

if [ ! $n2n_pid ]
then 
  echo 'n2n not started'
  exit 1
fi

fd_cnt=`ls /proc/${n2n_pid}/fd |wc -l`

if [ ${fd_cnt} -ge 1024 ]
then
  /etc/init.d/n2n restart
fi

配置 Dropbear

Dropbear 可以理解是 OpenWrt 下的 sshd 。为了安全起见,dropbear 默认只对 lan 口过来的请求开放 ssh 连接。因此在配置好 n2n 之后,也是无法直接访问这个服务的。这里需要先关闭下 dropbear 对 lan 口的绑定:

root@OpenWrt:/etc/config# cat /etc/config/dropbear 
config dropbear
        option PasswordAuth 'on'
        option RootPasswordAuth 'on'
        option Port         '22'
#        option Interface    'lan'
#       option BannerFile   '/etc/banner

配置好后 restart 即可生效。

root@OpenWrt:/etc/config# /etc/init.d/dropbear restart

配置各个客户端

软路由配置好了后先不要急着连接设备。建议先重启几次路由器,确保配置正常生效,且重启不丢失。确认完成后,各个需要连接的客户端再手动配置下:

  1. 关闭 DHCP,IP 地址设置为原先自动分配的IP即可。
  2. 将网关/路由器设置为软路由IP。
  3. 将子网掩码设置为和主路由一致。
  4. 将 DNS 设置为软路由IP。

正常情况下,配置好后即可感受科学的魅力,不过偶尔也可能出现一些奇怪的坑。此时可以先尝试:

  1. 关闭 IPV6。SSR 不支持 IPV6 ,如果一不小心走了 IPV6,则基本上是翻不出去的。
  2. 重启设备。像 IPhone 这类的设备似乎配置完后要重启一下,否则容易报诸如 SSL Error 之类的错。

爬虫浏览器的Cloudflare五秒盾处理

作者 mythsman
2022年12月12日 21:59

背景

近期业务有不少涉及到国外的网站,本以为经受了和国内大量卷王公司对抗的考验之后,处理国外业务应该是降维打击才对。结果本地测试的时候的确很OK,但是一上线就发现全面飘红,多个不同业务同时出了 Cloudflare 著名的五秒盾

根据 Cloudflare 的说明,只要是使用 Cloudflare 的域名解析服务,默认都会自动“享受” 他家的一系列防护服务(尤其是 Javascript 防护)。具体特征就是会在/cdn-cgi/challenge-platform/ 下嵌入风控逻辑。他们会收集浏览器的一系列指纹,再根据后台大数据对比,计算出当前环境的可疑概率以进行风控。

原则上出现这个也无妨,毕竟五秒盾也只防第一次登录。就算 hCaptcha 不好自动化处理,先人肉处理下,后续复用统一份 Cookie ,只要控制好规模和频率,基本能满足业务需要。但是吊诡的在于:无论验证多少次,永远不会跳转回正常网页。(鬼知道我点了多少次小海豚和大熊猫)

一般来说,对于网页爬虫而言,遇到强力风控时,只要不是极其重要的业务,我们一般都是考虑从业务上更换策略,尽量不正面硬刚。但是用本地浏览器在匿名模式下访问时、在和线上使用相同代理IP的前提下,虽然也出了五秒盾,但是人肉点也是能正常滑过的。这说明他的风控策略本身应当并不强,或者说这看起来不像是一种反爬策略,而更像是一种 Bug 或是某种安全策略,并不像是无法解决的问题。于是就尝试处理了一下,果然发现了一些意料之外的坑。

环境

我们的工作环境是基于 cef 定制的有头浏览器。通过 cef 框架 hook 浏览器页面的 LoadStart, LoadEnd, LoadError 等事件,注入用于页面操作的纯 JS 脚本,再通过代理抓包获取数据。同时定制一些集群管理、任务管控、链路追踪等逻辑。内核基于的是 87.1.12+g03f9336+chromium-87.0.4280.88 的 Linux 版本,默认的 Chromium 启动参数大致有如下这些:

--disable-dev-shm-usage
--no-first-run
--disable-web-security
--disable-site-isolation-trials
--lang=zh-CN
--no-sandbox
--single-process
--disable-http2
--disable-gpu
--disable-sync
--allow-running-insecure-content
--ignore-certificate-errors
--proxy-server=localhost:8080
--user-agent="some user agent"

解决

解决的思路比较清晰,既然本地环境可以正常过风控、线上的环境不行,我们就先想办法在本地搭建类似线上的环境,先想办法复现出问题,再控制变量将二者环境参数不断接近,直到找到临界的问题点。

测试的时候尤其需要注意两点:

  1. 如果配置了用户目录或者缓存目录,一定要记得每次测试前都要删一下,防止影响结论。
  2. 每一次测试都要重复多次,否则很容易由于各种偶然原因影响结论。

这里省略枯燥的探索过程,直接记录多次尝试后的结论:(实测,以下配置缺一不可)

关闭 --single-process 配置

默认 Chromium 是多进程模式的。这里的多进程模式可以有两层理解:

  1. 窗口进程、渲染进程、GPU进程、工具进程分开;
  2. 不同站点页面进程分开;

进程分开的好处就是可以更方便的进程安全控制,但是代价就是整体内存占用会略高。

因此我们线上默认选择了单进程模式,本意是为了节省进程数从而节省内存,让单个机器能同时跑更多的任务。当然,这里说的单进程并不是只有一个进程,而是只有 zygote 一种进程。(参考 #single-process-mode

例如,打开百度,如果采用单进程模式,只会有四个进程:

myths@ubuntu:~$ ps -ef|grep myproject|grep -v myproject
myths      27250    3785 10 15:27 pts/1    00:00:05 /home/myths/Projects/myproject --url=https://www.baidu.com --single-process
myths      27253   27250  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --no-zygote-sandbox --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27254   27250  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27256   27254  0 15:27 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data

而如果使用默认的多进程模式,则会有八个进程:

myths@ubuntu:~$ ps -ef|grep myproject -v myproject
myths      27351    3785  9 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --url=https://www.baidu.com
myths      27354   27351  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --no-zygote-sandbox --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27355   27351  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27357   27355  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=zygote --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data
myths      27378   27351  4 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27394   27357  0 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=renderer --user-data-dir=/home/myths/.config/cef_user_data --first-renderer-process --lang=en-US --num-raster-threads=1 --renderer-client-id=5 --time-ticks-at-unix-epoch=-1669691342091293 --launch-time-ticks=102038976468 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27395   27357 35 15:29 pts/1    00:00:02 /home/myths/Projects/myproject --type=renderer --user-data-dir=/home/myths/.config/cef_user_data --lang=en-US --num-raster-threads=1 --renderer-client-id=4 --time-ticks-at-unix-epoch=-1669691342091293 --launch-time-ticks=102038982131 --shared-files=v8_context_snapshot_data:100 --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache
myths      27420   27354  2 15:29 pts/1    00:00:00 /home/myths/Projects/myproject --type=gpu-process --lang=en-US --user-data-dir=/home/myths/.config/cef_user_data --gpu-preferences=WAAAAAAAAAAgAAAIAAAAAAAAAAAAAAAAAABgAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAABAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAIAAAAAAAAAA== --use-gl=angle --use-angle=swiftshader-webgl --shared-files --field-trial-handle=0,i,4491148827631734197,7878804601012403413,131072 --disable-features=BackForwardCache

使用单进程模式看似很美好,但是问题就是失去了多进程间的隔离,很多安全策略就失效了,浏览器的安全性就会下降很多。同时单进程模式下似乎也更容易出 bug,我在测试的时候发现单进程模式下的浏览器的确很容易出现一些 DeadLock 和 Coredump。

Cloudflare 或许通过某种方式测试出了单进程模式下安全性的问题,也或许是触发了单进程模式下的一些 Bug,导致风控页面无法跳转,具体方式还不清楚。

关闭--disable-site-isolation-trials 配置

添加这个配置的本意是方便进行跨域请求,但是据说这个需要跟 --disable-web-security--user-data-dir 一起使用才能生效(参考 StackOverflow)。但是实际上我测试下来,不加这个配置也能进行跨域访问,不太明白。

Cloudflare 似乎也能检测到这个参数引入后带来的问题,导致风控校验不过。原因也不明。

Chromium 内核版本尽量用较新的版本

众所周知,UserAgent 信息一定会被拿来进行大数据计算和风控识别,因此这个值的设置要尤其注意:

  1. UserAgent 里带的浏览器内核版本要尽量的新。长期不升级的浏览器显然是有问题的,况且内核较老的浏览器容易有未被修复的安全漏洞,可能会通不过检测。
  2. UserAgent 的值尽量和真实的内核版本一致。毕竟 UserAgent 声明的配置有时是会和其他配置互相应证的,如果不改全,很可能会被认为是恶意修改。不过,如果是 Linux 机器,还是建议不要用 Linux 的 UA,毕竟在 Linux 下刷网页的,更像是爬虫,更容易被风控。实测用 Mac 的 UA 会更好。

不要对所有页面都注入脚本

风控页面本身会加载很多的 Iframe 用于跑验证脚本、出验证码之类的,这些东西都会触发页面加载事件。通过 cdp 我们可以直接看到这些页面:

Just a moment...
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=challenge&id=04nubo7q8lgo&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=
false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=checkbox&id=04nubo7q8lgo&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=challenge&id=12ks6yz7fbwp&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethost=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com
https://cf-assets.hcaptcha.com/captcha/v1/6fdad99/static/hcaptcha.html#frame=checkbox&id=12ks6yz7fbwp&host=www.wethrift.com&sentry=true&reportapi=https%3A%2F%2Faccounts.hcaptcha.com&recaptchacompat=off&custom=false&endpoint=https%3A%2F%2Fcloudflare.hcaptcha.com&hl=zh&assethos
t=https%3A%2F%2Fcf-assets.hcaptcha.com&imghost=https%3A%2F%2Fcf-imgs.hcaptcha.com&tplinks=on&sitekey=03196e24-ce02-40fc-aa86-4d6130e1c97a&theme=light&origin=https%3A%2F%2Fwww.wethrift.com

在对目标页面加载脚本时,尽量不要对那些校验用的中间页面进行 hook。原先我们有一个对所有加载的页面都打印一下日志的兜底脚本(没有做任何其他事情),本以为没啥影响,结果测试了很久发现,这个兜底脚本的存在极大影响了页面加载的成功率。具体原因未知,但是理论上的确是有办法能够检测到。

尽量提高页面加载速度

Did you know some signs of bot malware on your computer are computer crashes, slow Internet, and a slow computer?

大数据表明,越慢的网络环境越有可能是危险的。各种 Hook 处理、多层代理、抓包解包等都会影响网络响应速度,因此一定要想办法提高。

目前我们采用的抓包代理技术比较老旧,解包太慢了,导致一加抓包代理,页面打开的成功率就骤降。尤其是清除缓存后的浏览器首次打开时,由于缓存被干掉了,资源一起加载就会非常慢。因此我们在已有的抓包代理的基础上又套了一层静态资源缓存,实现对静态资源 Cache-Control 的处理,减少因清除缓存带来的速度变慢的问题。

实测后发现,加了静态资源缓存后,页面打开的成功率也有了明显提升。

改用指纹浏览器

上面的方法虽然基本上能让滑块或者验证码出来,但是仍然解决不了一直验证的问题。测试过很多浏览器之后发现,即使是本地正常的浏览器、正常的IP,很大概率也会出验证的。

但是偶然间发现了一款 GoLogin 指纹浏览器,在不做任何额外配置时、竟然能几乎100%通过 cloudflare 的盾,非常神奇(多次重试均可通过✅);但是在我加了一堆自以为是的配置之后,却又出验证了。看来爬虫浏览器改用指纹浏览器应当是一大趋势。不过目前市面上这类的浏览器基本都是闭源收费的,且价格一般都比较昂贵,只能说多多关注了。。。

有趣的东西

基于V8引擎特性的内核版本判断

通过 JS 校验 V8 引擎在不同 Chromium 内核版本下的特性,可以用来大致判断真实内核状态。如果和 UserAgent 声明的版本不一样,则会是一个非常负面的评价。

根据 V8 的版本说明文档,V8 的版本和 Chromium 版本是一一对应的:

说的很清楚,将 Chromium 的大版本号除以10,即得到了 V8 的大版本号。从 chrome://version 中也可以应证这一点(106.0.5249.103 -> 10.6.194.17):

Google Chrome 106.0.5249.103 (Official Build) (x86_64)
Revision 182570408a1f25ab2731ef5f283b918df9b9f956-refs/branch-heads/5249_91@{#6}
OS macOS Version 12.6 (Build 21G115)
JavaScript V8 10.6.194.17
User Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36
Command Line /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --flag-switches-begin --flag-switches-end
Executable Path /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

有了这个对应关系,我们就可以通过测试 V8 语言在不同版本下的语言特性,来推断当前 Chromium 内核的版本。我们可以在 V8 的 Features 页查看到 V8 的 Javascript api 各种新特性,选择其中一个希望测试的特性,再去 V8 的 Blog 页查看其对应的版本,从而对 Chromium 内核版本进行甄别。

例如,我们发现 V8 在 9.3 版本新增了一个 Object.hasOwn 方法,那么在 Chromium 内核高于 93 的机器上,下面的 Javascript 就会正常执行:

Object.hasOwn.toString()
'function hasOwn() { [native code] }'

而在内核低于 93 的机器上,下面的 Javascript 就会报错:

Object.hasOwn.toString()
Uncaught TypeError: Cannot read property 'toString' of undefined
at <anonymous>:1:15

通过测试足够多的特征,我们就可以很轻松的精确判断当前浏览器实际的内核版本。

当然,这里直接通过 toString() 来判断函数是否存在的方法比较粗暴,很容易绕过。实际上可以多设计一些测试用例,甚至可以在服务端随机生成测试用例,再将客户端返回的结果与服务端的进行对比,这样即使通过数据抓包、JS 逆向,也挺难处理分析的。

hCaptcha基于指纹和行为打分

和 reCatpcha 类似,hCaptcha 在提供验证服务的同时,也会给后端服务返回当前校验结果的“打分”,用于评判当前的用户时真人或是机器的概率。因此并不是说点对了验过码图片就能100%保证通过风控。

一般来说,这类验证码服务几乎都会通过 JS 收集浏览器的基本信息(尤其是 window.screen 和 window.navigor 下的所有参数),以及用户的行为信息(鼠标的移动序列),传回 hCaptcha 服务端;服务端经过大数据计算后返回类似如下接口的数据给业务后端,用于进行风控判断,这样业务后端就可以根据这个 score 来控制风控强度:

{
"success": true|false,     // is the passcode valid, and does it meet security criteria you specified, e.g. sitekey?
"challenge_ts": timestamp, // timestamp of the challenge (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
"hostname": string,        // the hostname of the site where the challenge was solved
"credit": true|false,      // optional: whether the response will be credited
"error-codes": [...]       // optional: any error codes
"score": float,            // ENTERPRISE feature: a score denoting malicious activity.
"score_reason": [...]      // ENTERPRISE feature: reason(s) for score.
}

本来我也想测试下自己的定制浏览器的实际得分,奈何这个 score 只对企业级用户开放,普通开发者是拿不到的。

寻找了一圈倒是发现 reCaptcha 有类似的 demo ,不过仔细一看,他返回的 score 也只是个 demo ,不具参考意义。

PDF字体乱码问题分析

作者 mythsman
2022年10月27日 18:03

背景

最近身体有恙,于是就想起来整理下这几年体检的电子报告汇总看看。结果发现在善诊平台下载的体检报告在电脑上看直接乱码了。不过这种字符集的问题怎么能难得了本码农呢,所以就尝试起各种办法进行恢复,结果发现这果然是个深坑。。。

问题

本来好好的一个 PDF 报告,结果打开后画风是这样的:

我本地的环境是 Macbook,尝试了用 Chrome 打开、自带的 Preview 工具打开,都是同样的结果。除了 ASCII 字符能展示,其他所有的中文信息都没有,但是 PDF 中的所有图片都能正常展示。这么看大概率还是中文字体的问题。

分析

EXIF信息

不管如何,首先肯定要看一下这个 PDF 本身带的 EXIF 信息,寻找一些分析线索。这里 mac 下可以直接在 Preview 里看,不过我更喜欢用命令行工具 exiftool 看。

$ brew install exiftool
$ exiftool -a -G1:2 report.pdf
[ExifTool]      ExifTool Version Number         : 12.42
[System:Other]  File Name                       : report.pdf
[System:Other]  Directory                       : .
[System:Other]  File Size                       : 522 kB
[System:Other]  File Permissions                : -rw-r--r--
[System:Time]   File Modification Date/Time     : 2022:10:27 11:06:00+08:00
[System:Time]   File Access Date/Time           : 2022:10:27 14:14:04+08:00
[System:Time]   File Inode Change Date/Time     : 2022:10:27 15:34:02+08:00
[File:Other]    File Type                       : PDF
[File:Other]    File Type Extension             : pdf
[File:Other]    MIME Type                       : application/pdf
[PDF:Document]  PDF Version                     : 1.7
[PDF:Document]  Linearized                      : No
[PDF:Document]  PDF Version                     : 1.7
[PDF:Document]  Tagged PDF                      : Yes
[PDF:Document]  Page Count                      : 11
[PDF:Document]  Page Mode                       : UseNone
[PDF:Document]  Title                           :
[PDF:Document]  Subject                         : FastReport PDF export
[PDF:Document]  Keywords                        : 宋体, Bold, -, SimSun;宋体, -, SimSun;微软雅黑, -, Microsoft, YaHei;微软雅黑, Bold, -, Microsoft, YaHei, Negreta;宋体, Bold, -, SimSun;宋体, -, SimSun;微软雅黑, -, Microsoft, YaHei;微软雅黑, Bold, -, Microsoft, YaHei, Negreta;
[PDF:Document]  Creator                         :
[PDF:Document]  Producer                        :
[PDF:Author]    Author                          : FastReport
[PDF:Time]      Create Date                     : 2021:02:24 15:35:03
[PDF:Time]      Modify Date                     : 2021:02:24 15:35:03

划一下重点:

  1. Keywords 字段似乎展示了他使用的字体信息,看起来都是微软字体。
  2. Author 和 Subject 字段似乎展示了生成这个 PDF 的工具:FastReport

众所周知 Mac 是不带微软字体的,那么这个问题似乎就是微软字体导致的。

微软字体替换

既然是 Mac 找不到微软字体,那我就把微软字体安装到本地应该就行了吧。于是我就找了个 Windows 电脑,跑去 C:\Windows\Fonts 下把所有的 ttf , ttc 文件拷贝到 MacBook 的 ~/Library/Fonts 下,作为当前用户的字体。

当然,这些字体看起来比较多,但是重要的还是 sim (中易系列)和 msyh (微软雅黑系列) 打头的这几个。

C:\Windows\Fonts> dir|findstr sim
2019/10/15  21:46        10,578,152 simfang.ttf
2019/10/15  21:46         9,753,388 simhei.ttf
2019/10/15  21:46        11,787,328 simkai.ttf
2019/12/07  17:08        18,214,472 simsun.ttc
2019/12/07  17:08        17,064,180 simsunb.ttf

C:\Windows\Fonts> dir|findstr msyh
2019/12/07  17:08        19,647,736 msyh.ttc
2019/12/07  17:09        16,829,116 msyhbd.ttc
2019/12/07  17:09        12,139,380 msyhl.ttc

可是,安装好后发现并不起作用,Chrome 和 Preview 还是不能用,感觉这就不太合理了。于是我把 PDF 拷回 Windows 下试试,发现果然连 Windows 自己都不能打开。。。

思考了一下,既然 PDF 格式主要是 Adobe 公司主导的,是不是他家的官方软件就能搞定呢?

Adobe Acrobat 的尝试

看网上有人说 Adobe Acrobat 有个 Chrome 插件,据说能解决很多乱码问题。于是就抱着试试看的心情装了一个。

果然没什么卵用。

考虑到 Adobe Acrobat 是收费的,为了这个小问题直接买有点不合适,又懒得找破解,于是就去 PDD 花 9¥ 买了个破解版。

软件是童叟无欺的,不过还是没什么卵用,依然预览起来都是乱码。而且还报了一个乱码的错,似乎是在说找不到一些字体(这些字体的名字是乱码的)。一番搜寻,发现有人在 Google Group 里提到:

通常对于字体的识别方式是先在文档内部寻找内嵌字体文件,如果没有字体文件,那么就根据文档所使用的字体名称在用户本地查找 相应的字体,最后使用替代机制——利用本地的默认字体来显示。
很不幸的是,一般的默认字体都是不识别非 acsii 字符的,所以就会出现各种乱码和字体很丑的 情况。

再结合报错时的一大堆乱码,那么我有理由进一步怀疑:如果文件的文本字符集有问题,而字体名又是非纯 ASCII 码的中文名,那么文本字符集的问题就会导致字体名的解析问题,进而就会导致字体解析的问题。但是究竟具体要怎么做才能修正字符集呢?不知道。

忽然想起来 PDF 传到电脑前明明在手机上预览过,于是我把目光转到了手机。。。

WPS 的逆袭

把 PDF 发回我的小米手机,发现果然能成功打开。

既然这样,那我是不是可以在手机上对 PDF 进行编辑,将有问题的字体切换成好使的通用字体,再重新保存呢?

小米默认采用的是 WPS 的组件,下载 WPS 后尝试编辑发现竟然又要冲会员。不能忍,于是下载了一个不要钱的 福昕PDF编辑器。结果福昕编辑器竟然又不能打开了。

难道说只能用 WPS 打开?于是我又在 Mac 上下载了 WPS,果然能打开了。。。

绕了一圈,能否正确打开这个 PDF 这件事跟平台没有任何关系,只跟软件有关。不得不说 FastReport 这个工具生成的 PDF 是真的流氓。

可是 WPS 的 PDF 编辑功能需要开通超级会员。没办法,只能又去 PDD 花了 2.9¥ 买了一个 ⭐️WPS 一天超级会员试用版⭐️

但是问题又来了,PDF 的编辑非常麻烦,每个文本框都是独立的,不能一次全选所有文本然后统一修改字体,只能一个框一个框的选。。。

忽然想到,为啥不先把 PDF 转成 Word,利用 Word 快速修改字体,然后再保存成 PDF 呢?

于是尝试了 WPS 的 PDF 转 Word 功能,按照刚才的思路,导出 Word 之后将所有文字的字体统一改成了 Arial ,再导出回 PDF。

终于成功生成了在各个平台都能正常使用的 PDF 。

iLovePDF

问题解决了,但是为了用一个小功能就要给流氓软件冲会员,实在不能忍。思考一番忽然明白了,我需要的其实只是一个“高兼容性的PDF转Word“工具——iLovePDF

有免费工具用了,谁还愿意交智商税呢?

将你的 PDF 转换为 DOC 和 DOCX 文件,以便于编辑。
将你的 PDF 转换为可编辑的 Office DOC 和 DOCX 文件。转换准确率近乎100%。
iLovePDF - 提供在线处理PDF文件的多种工具

记一次SSH下无法umount磁盘的问题

作者 mythsman
2022年10月25日 00:35

问题

最近在用 吃灰树莓派 + 外接硬盘盒 搭建 NAS ,由于硬盘默认的文件系统是 NTFS ,出于种种原因(参考这里),Linux 下的写入速度特别慢(我这里的酷鱼1T写入只有33MB/s)。于是我打算把硬盘格式化成 ext4

但是在测完速后准备 umount 当前磁盘(/media/pi/Seagate)时,却发生了报错:

$ dd bs=1M count=256 if=/dev/zero of=/media/pi/Seagate/test conv=fdatasync
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 8.04687 s, 33.4 MB/s

$ sudo umount /media/pi/Seagate
umount: /media/pi/Seagate: target is busy.

非常奇怪,我当前的 pwd 并不在挂载盘下,按理说应该没有进程会访问这个文件夹才对,可是他依然报了设备被占用。之前出现过这个问题时尝试过重新插入磁盘、重启机器等等操作,有时候也能莫名其妙的恢复了,但是这次正好有空,我希望能找到问题的原因。

分析

既然是进程占用了文件资源,那我就先来看下到底是谁:(注意,这里一定要加 sudo  ,否则可能会假装查不到)

$ sudo lsof /media/pi/Seagate/
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
sftp-serv 1242   pi    4r   DIR    8,0     4096    5 /media/pi/Seagate
sftp-serv 1242   pi    6r   DIR    8,0     4096    5 /media/pi/Seagate

看上去是一个叫 sftp-serv 啥啥的进程,拿到进程号 1242 ,看下到底是什么:

$ ps -ef|grep 1242 | grep -v grep
pi        1242  1241  0 00:10 ?        00:00:00 /usr/lib/openssh/sftp-server

原来是 sftp-server 进程,但是我并没有手动启这个进程,systemd 下也没有这个服务,那么这个进程是谁拉起的呢?

$ pstree
systemd─┬─agetty
        ...
        ├─ssh-agent
        ├─sshd─┬─sshd───sshd───bash───pstree
        │      └─sshd───sshd───sftp-server

原来是 sshd 拉起的,毕竟我是通过 ssh 登录到机器上的,访问机器上文件的功能应当就是通过 sftp 来实现的。

那么 sftp 为啥需要加载我的这个文件么?难道他会默认拉起所有的文件?我们回头查看下:

 $ sudo lsof |grep sftp
sftp-serv  1242                              pi  cwd       DIR              179,2         4096       1407 /home/pi
sftp-serv  1242                              pi  rtd       DIR              179,2         4096          2 /
sftp-serv  1242                              pi  txt       REG              179,2       105664      13203 /usr/lib/openssh/sftp-server
sftp-serv  1242                              pi  mem       REG              179,2        51640      25196 /usr/lib/aarch64-linux-gnu/libnss_files-2.28.so
sftp-serv  1242                              pi  mem       REG              179,2      1439544      11838 /usr/lib/aarch64-linux-gnu/libc-2.28.so
sftp-serv  1242                              pi  mem       REG              179,2       136992      11834 /usr/lib/aarch64-linux-gnu/ld-2.28.so
sftp-serv  1242                              pi    0r     FIFO               0,11          0t0     300723 pipe
sftp-serv  1242                              pi    1w     FIFO               0,11          0t0     300724 pipe
sftp-serv  1242                              pi    2w     FIFO               0,11          0t0     300725 pipe
sftp-serv  1242                              pi    4r      DIR                8,0         4096          5 /media/pi/Seagate
sftp-serv  1242                              pi    5r      DIR              179,2         4096     260625 /media/pi
sftp-serv  1242                              pi    6r      DIR                8,0         4096          5 /media/pi/Seagate
sftp-serv  1242                              pi    7r      DIR              179,2         4096       1407 /home/pi

看起来 sftp 也只占用了他自己的链接库以及我当前访问的、以及曾经访问过的页面。。。

解决

既然原因找到了,解决起来就很轻松了。看似最简单的方法就是干掉占用文件的进程 sftp-server 。事实上这个方案也的确能 work ,不过 kill 进程这种事情实在不优雅,万一出锅了呢?

最简单的方法其实就是:登出 ssh,再重新登录。毕竟 sftp 进程是依附于当前会话的 sshd 进程,只要退出这个 ssh 进程,sftp 进程也就能正常终止了。

Linux主机性能测试方法

作者 mythsman
2022年10月13日 17:11

背景

最近打算用躺家吃灰的树莓派4B搭一个NAS,用来快捷方便地访问和备份一些资源。由于备选的硬件(芯片、硬盘、网线、路由器等)和软件(内网穿透技术)的技术选型比较多,这时候就需要有一个能简单评估服务性能的方法。因此简单搜寻了一下常见方案,方便在技术选型时有个统一的对比标准,并且对一些常见指标能在数量级上有一些感性的理解。

硬盘

对于硬盘的读写速度测试,首先我们需要注意根据读写的实现细节不同,测试出的结果会有很大的差别。例如对于读来说,是否走缓存读、缓存的大小如何;对于写来说,是否只写缓存、是否同步等待刷盘、刷盘的时机如何,等等。

在实际测试的时候一定要明确自己使用的是哪种IO模式,否则就会得到一些似是而非的结论。

设备查询

在测试硬盘前,我们首先得知道我们有哪些硬盘、分别对应哪些分区。用 lsblk (list block)命令可以查看当前机器下挂载的块设备:

$ lsblk
NAME        MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
sda           8:0    1 29.3G  0 disk
└─sda1        8:1    1 29.3G  0 part /media/pi/5615-BDE2
mmcblk0     179:0    0 59.5G  0 disk
├─mmcblk0p1 179:1    0  256M  0 part /boot
└─mmcblk0p2 179:2    0 59.2G  0 part /

可以看到,这里的 sda (SATA device a) 表示我外部插入的一个U盘;mmcblk0 (Multimedia card block 0) 表示树莓派自带的一张 SD 卡。这两个类型是 disk,也就是实体磁盘。

每个 disk 会被分成多个 partition,也就是这里的 sda1 和 mmcblk0p1、mmcblk0p2。每个 partition 又会 mount 到不同的文件夹下,用于在文件系统中进行访问。因此对于文件系统本身来说,我们只会关心到 partition 层面。

通过 df 命令我们也能直接观察到所有分区的挂载情况:

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        59G   11G   46G  19% /
devtmpfs        3.5G     0  3.5G   0% /dev
tmpfs           3.7G     0  3.7G   0% /dev/shm
tmpfs           3.7G   65M  3.6G   2% /run
tmpfs           5.0M  4.0K  5.0M   1% /run/lock
tmpfs           3.7G     0  3.7G   0% /sys/fs/cgroup
/dev/mmcblk0p1  253M   32M  221M  13% /boot
tmpfs           738M  4.0K  738M   1% /run/user/1000
/dev/sda1        30G   23M   30G   1% /media/pi/5615-BDE2

同时,disk 和 partition 的详细信息也可以通过 fdisk 命令查看:

$ sudo fdisk -l
Disk /dev/mmcblk0: 59.5 GiB, 63864569856 bytes, 124735488 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x140cee6b

Device         Boot  Start       End   Sectors  Size Id Type
/dev/mmcblk0p1        8192    532479    524288  256M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      532480 124735487 124203008 59.2G 83 Linux


Disk /dev/sda: 29.3 GiB, 31457280000 bytes, 61440000 sectors
Disk model: ProductCode
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xf3203eea

Device     Boot Start      End  Sectors  Size Id Type
/dev/sda1        2048 61439999 61437952 29.3G  7 HPFS/NTFS/exFAT

总之,在这里我们只需要搞清楚我们想测试的磁盘和分区分别是哪个即可。

hdparm

对于读性能测试,我们一般可以用 hdparm 工具(hard disk parameter? hardware device parameter?)。Debian下直接 apt 安装即可:

$ sudo apt install hdparm -y

hdparm目前只支持磁盘读性能测试,提供了三种方式进行测试:

  • 直接读内存: sudo hdparm -T [device]
  • 带buffer读磁盘: sudo hdparm -t [device]
  • 不带buffer读磁盘: sudo hdparm -t --direct [device]

以我的 mmcblk0 设备为例,跑出来结果分别如下(当然,每次测试建议跑多次取平均值,这里偷个懒):

$ sudo hdparm -T /dev/mmcblk0

/dev/mmcblk0:
 Timing cached reads:   1840 MB in  2.00 seconds = 921.43 MB/sec
 
$ sudo hdparm -t /dev/mmcblk0

/dev/mmcblk0:
 Timing buffered disk reads: 130 MB in  3.04 seconds =  42.74 MB/sec
 
$ sudo hdparm -t --direct /dev/mmcblk0

/dev/mmcblk0:
 Timing O_DIRECT disk reads: 124 MB in  3.01 seconds =  41.17 MB/sec
 

显然,走内存读是飞快,不过对于测试磁盘性能来说没有任何意义;不带buffer看起来比带buffer要慢一点点,差别不太大;考虑到现实场景中大多数都是带buffer的读,因此我们在比较时用带buffer读的结果来进行参考即可。

dd

dd (data definition? data duplicator?) 是进行磁盘操作、文件生成之类的常用工具。在 gnu 的 coreutils 下,类 Unix 发行版几乎都自带。

写性能

一般我们会用 dd 来进行磁盘写性能测试,一般来说也有三种方式:

  1. 直接写内存: dd bs=1M count=256 if=/dev/zero of=test
  2. 使用内存做缓存写完后一次性刷盘:dd bs=1M count=256 if=/dev/zero of=test conv=fdatasync
  3. 使用内存做缓存,每写完一部分就刷一次盘:dd bs=1M count=256 if=/dev/zero of=test oflag=dsync

还是以我的 mmcblk0 设备为例(当前目录即挂载的 mmcblk0 设备),跑出来结果分别如下(当然,每次测试建议跑多次取平均值,这里还是偷个懒):

$ dd bs=1M count=256 if=/dev/zero of=test
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 1.39574 s, 192 MB/s

$ dd bs=1M count=256 if=/dev/zero of=test conv=fdatasync
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 31.739 s, 8.5 MB/s

$ dd bs=1M count=256 if=/dev/zero of=test oflag=dsync
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 39.694 s, 6.8 MB/s

$ rm test

可见写内存不sync的确还是快,不过还是没啥参考意义。而由于 SD 卡本身写入速度就不高,因此这里 fdatasync 和 dsync 的差别不太大,如果是用更快的设备,这两个的差别会更大。考虑到实际情况下大部分程序都是采用 fdatasync 的模式来写,因此我们在比较时用这个数据即可。

虚假的测试

前几天发现有的业余测评UP主会在 1 的基础上加上 status=progress 参数来实时测写入速度,看起来还挺专业:

$ dd if=/dev/zero of=test status=progress bs=1M

为了方便文本展示,我把 '\r' 替换成 '\n' 以打印每一次刷新:

$ dd if=/dev/zero of=test status=progress bs=1M  2>&1 |tr -s '\r' '\n'

200278016 bytes (200 MB, 191 MiB) copied, 1 s, 199 MB/s
401604608 bytes (402 MB, 383 MiB) copied, 2 s, 200 MB/s
602931200 bytes (603 MB, 575 MiB) copied, 3 s, 201 MB/s
805306368 bytes (805 MB, 768 MiB) copied, 4 s, 201 MB/s
1007681536 bytes (1.0 GB, 961 MiB) copied, 5 s, 201 MB/s
1210056704 bytes (1.2 GB, 1.1 GiB) copied, 6 s, 202 MB/s
1309671424 bytes (1.3 GB, 1.2 GiB) copied, 17 s, 77.0 MB/s
1455423488 bytes (1.5 GB, 1.4 GiB) copied, 18 s, 80.9 MB/s
1563426816 bytes (1.6 GB, 1.5 GiB) copied, 20 s, 78.0 MB/s
1750073344 bytes (1.8 GB, 1.6 GiB) copied, 60 s, 29.2 MB/s
1860173824 bytes (1.9 GB, 1.7 GiB) copied, 72 s, 25.8 MB/s
1966080000 bytes (2.0 GB, 1.8 GiB) copied, 82 s, 24.0 MB/s
2051014656 bytes (2.1 GB, 1.9 GiB) copied, 98 s, 20.8 MB/s
2162163712 bytes (2.2 GB, 2.0 GiB) copied, 101 s, 21.4 MB/s
2250244096 bytes (2.3 GB, 2.1 GiB) copied, 113 s, 20.0 MB/s
2357198848 bytes (2.4 GB, 2.2 GiB) copied, 117 s, 20.1 MB/s
2459959296 bytes (2.5 GB, 2.3 GiB) copied, 129 s, 19.1 MB/s
2558525440 bytes (2.6 GB, 2.4 GiB) copied, 139 s, 18.4 MB/s
2656043008 bytes (2.7 GB, 2.5 GiB) copied, 152 s, 17.5 MB/s
2754609152 bytes (2.8 GB, 2.6 GiB) copied, 170 s, 16.2 MB/s
2850029568 bytes (2.9 GB, 2.7 GiB) copied, 182 s, 15.6 MB/s
2960130048 bytes (3.0 GB, 2.8 GiB) copied, 187 s, 15.8 MB/s
3064987648 bytes (3.1 GB, 2.9 GiB) copied, 201 s, 15.2 MB/s
3178233856 bytes (3.2 GB, 3.0 GiB) copied, 223 s, 14.3 MB/s
3300917248 bytes (3.3 GB, 3.1 GiB) copied, 235 s, 14.0 MB/s
3371171840 bytes (3.4 GB, 3.1 GiB) copied, 247 s, 13.6 MB/s
3460300800 bytes (3.5 GB, 3.2 GiB) copied, 259 s, 13.4 MB/s
3552575488 bytes (3.6 GB, 3.3 GiB) copied, 262 s, 13.6 MB/s
3668967424 bytes (3.7 GB, 3.4 GiB) copied, 276 s, 13.3 MB/s
3764387840 bytes (3.8 GB, 3.5 GiB) copied, 289 s, 13.0 MB/s
3872391168 bytes (3.9 GB, 3.6 GiB) copied, 300 s, 12.9 MB/s
3954180096 bytes (4.0 GB, 3.7 GiB) copied, 303 s, 13.0 MB/s
4066377728 bytes (4.1 GB, 3.8 GiB) copied, 329 s, 12.4 MB/s
4173332480 bytes (4.2 GB, 3.9 GiB) copied, 332 s, 12.6 MB/s
4251975680 bytes (4.3 GB, 4.0 GiB) copied, 353 s, 12.0 MB/s
4387241984 bytes (4.4 GB, 4.1 GiB) copied, 355 s, 12.4 MB/s
4513071104 bytes (4.5 GB, 4.2 GiB) copied, 371 s, 12.2 MB/s
4582277120 bytes (4.6 GB, 4.3 GiB) copied, 372 s, 12.3 MB/s
4652531712 bytes (4.7 GB, 4.3 GiB) copied, 377 s, 12.3 MB/s
4811915264 bytes (4.8 GB, 4.5 GiB) copied, 387 s, 12.4 MB/s
4869586944 bytes (4.9 GB, 4.5 GiB) copied, 388 s, 12.6 MB/s
4979687424 bytes (5.0 GB, 4.6 GiB) copied, 406 s, 12.3 MB/s
5100273664 bytes (5.1 GB, 4.8 GiB) copied, 411 s, 12.4 MB/s
5181014016 bytes (5.2 GB, 4.8 GiB) copied, 419 s, 12.4 MB/s
5307891712 bytes (5.3 GB, 4.9 GiB) copied, 420 s, 12.6 MB/s
5356126208 bytes (5.4 GB, 5.0 GiB) copied, 422 s, 12.7 MB/s
5462032384 bytes (5.5 GB, 5.1 GiB) copied, 436 s, 12.5 MB/s
5557452800 bytes (5.6 GB, 5.2 GiB) copied, 450 s, 12.3 MB/s
5667553280 bytes (5.7 GB, 5.3 GiB) copied, 463 s, 12.2 MB/s
5750390784 bytes (5.8 GB, 5.4 GiB) copied, 485 s, 11.9 MB/s
5892997120 bytes (5.9 GB, 5.5 GiB) copied, 495 s, 11.9 MB/s
5975834624 bytes (6.0 GB, 5.6 GiB) copied, 513 s, 11.6 MB/s
6086983680 bytes (6.1 GB, 5.7 GiB) copied, 515 s, 11.8 MB/s
6180306944 bytes (6.2 GB, 5.8 GiB) copied, 517 s, 12.0 MB/s
6287261696 bytes (6.3 GB, 5.9 GiB) copied, 530 s, 11.9 MB/s
6370099200 bytes (6.4 GB, 5.9 GiB) copied, 532 s, 12.0 MB/s
6490685440 bytes (6.5 GB, 6.0 GiB) copied, 542 s, 12.0 MB/s
6550454272 bytes (6.6 GB, 6.1 GiB) copied, 545 s, 12.0 MB/s
6658457600 bytes (6.7 GB, 6.2 GiB) copied, 563 s, 11.8 MB/s
6755975168 bytes (6.8 GB, 6.3 GiB) copied, 576 s, 11.7 MB/s
6856638464 bytes (6.9 GB, 6.4 GiB) copied, 592 s, 11.6 MB/s
7010779136 bytes (7.0 GB, 6.5 GiB) copied, 601 s, 11.7 MB/s
7055867904 bytes (7.1 GB, 6.6 GiB) copied, 603 s, 11.7 MB/s
7158628352 bytes (7.2 GB, 6.7 GiB) copied, 615 s, 11.6 MB/s
7265583104 bytes (7.3 GB, 6.8 GiB) copied, 631 s, 11.5 MB/s
^C

测试了整整十分钟,发现展示出来的速率硬生生从 200MB/s 降到了 11MB/s。事实上如果我愿意等,展示出来的速率还会再降。而且即使是最后展示的这个速率看起来已经低了不少,其实他也远比实际的速率要大很多,因为当我按 Ctrl+C 终止测试时,仍然有大量的数据没有真正落盘,整个程序仍然 hold 了很久才终止。最后真正落盘所消耗的时间显然是没有统计在前面计算的速率中的。

读性能

当然,有人也会利用 dd 进行读性能测试,比如:

$ sudo dd bs=1M count=256 if=/dev/mmcblk0 of=/dev/null
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 6.03649 s, 44.5 MB/s

看起来很美好,结果也和 hdparm 差不多。但是当你第二次再跑这个命令的时候,由于读缓存的存在,结果会快特别多:

$ sudo dd bs=1M count=256 if=/dev/mmcblk0 of=/dev/null
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 0.267236 s, 1.0 GB/s

显然这样的测试是没有意义的。考虑到这种测试方法甚至很难做到幂等,这里还是不建议用 dd 来测试读性能。

小结

对于磁盘读性能测试,建议使用 sudo hdparm -t [device]

对于磁盘写性能测试,建议使用 dd bs=1M count=256 if=/dev/zero of=test conv=fdatasync。当然, bs 和 count 也可以适当改大以进一步提高准确性。

网络

网速测试一般分两种,一种是测试当前设备对普通公网设备的读写速度;另一种是点对点测试两个服务器之间的速度。

speedtest

speedtest 工具在各地都有测速服务器,通过命令行(speedtest-cli)或者网页(https://speedtest.cn,https://speedtest.net)都可以进行网速上下行的测试。这里以命令行为例:

$ sudo apt install speedtest-cli -y

$ speedtest-cli
Retrieving speedtest.net configuration...
Testing from China Mobile (183.192.82.69)...
Retrieving speedtest.net server list...
Selecting best server based on ping...
Hosted by Chinamobile-5G (Shanghai) [8.49 km]: 6.054 ms
Testing download speed................................................................................
Download: 128.24 Mbit/s
Testing upload speed......................................................................................................
Upload: 22.88 Mbit/s

虽然多次测试可能会访问到不同的测试点、导致结果有区别,不过其实也大差不差了。

在使用时偶尔会遇到返403,不要慌,可以先多试几下,如果还是不行,可以加下  --secure 参数(参考Reddit) 。

iperf3

如果我们并不是想测试公网网速,而是测试两个服务器之间点对点的网速。这时用 iperf3 工具就好。

$ sudo apt install iperf3 -y # Debian 下
$ brew install iperf3        # MacOS 下

需要注意,除了 iperf3 之外,还有一个 iperf。这两个版本分别由不同组织开发,前后也不兼容。虽然似乎 iperf3 有坑,不过似乎功能多一点,尤其是支持了下行带宽测试(iperf 只支持上行带宽测试),所以这里还是用 iperf3。

iperf3 是 C/S 架构,服务端开启 server ,客户端开启 client,然后互相通信进行测速。以我在家的树莓派和一个在 HK 的 Azure 主机为例:

$ iperf3 -s -p 5555                   # azure 主机上开启 server

$ iperf3 -c 104.208.65.181 -p 5555    # pi 上开启 client 并连接 server

测试完成后,client 上会有报告:

$ iperf3 -c 104.208.65.181 -p 5555
Connecting to host 104.208.65.181, port 5555
[  5] local 192.168.1.2 port 35994 connected to 104.208.65.181 port 5555
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  8.68 MBytes  72.8 Mbits/sec    0   2.95 MBytes
[  5]   1.00-2.00   sec  1.25 MBytes  10.5 Mbits/sec  2018    252 KBytes
[  5]   2.00-3.00   sec  2.50 MBytes  21.0 Mbits/sec  1076    286 KBytes
[  5]   3.00-4.00   sec  3.75 MBytes  31.5 Mbits/sec  861    237 KBytes
[  5]   4.00-5.00   sec  2.50 MBytes  21.0 Mbits/sec   99    187 KBytes
[  5]   5.00-6.00   sec  1.25 MBytes  10.5 Mbits/sec    0    206 KBytes
[  5]   6.00-7.00   sec  2.50 MBytes  21.0 Mbits/sec    0    215 KBytes
[  5]   7.00-8.00   sec  2.50 MBytes  21.0 Mbits/sec    0    218 KBytes
[  5]   8.00-9.00   sec  2.50 MBytes  21.0 Mbits/sec    0    218 KBytes
[  5]   9.00-10.00  sec  2.50 MBytes  21.0 Mbits/sec    0    220 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  29.9 MBytes  25.1 Mbits/sec  4054             sender
[  5]   0.00-10.09  sec  26.5 MBytes  22.1 Mbits/sec                  receiver

iperf Done.

这里可以看出 client 对 server 的上行带宽大约是 22Mbit/sec。

类似的,server配置不变,client加上 -R 参数后可以测试出 server 对 client 的下行带宽:

$ iperf3 -c 104.208.65.181 -p 5555 -R
Connecting to host 104.208.65.181, port 5555
Reverse mode, remote host 104.208.65.181 is sending
[  5] local 192.168.1.2 port 36000 connected to 104.208.65.181 port 5555
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-1.00   sec  6.47 MBytes  54.3 Mbits/sec
[  5]   1.00-2.00   sec  25.7 MBytes   216 Mbits/sec
[  5]   2.00-3.00   sec  13.2 MBytes   111 Mbits/sec
[  5]   3.00-4.00   sec  16.3 MBytes   137 Mbits/sec
[  5]   4.00-5.00   sec  12.4 MBytes   104 Mbits/sec
[  5]   5.00-6.00   sec  14.4 MBytes   121 Mbits/sec
[  5]   6.00-7.00   sec  14.0 MBytes   117 Mbits/sec
[  5]   7.00-8.00   sec  13.9 MBytes   116 Mbits/sec
[  5]   8.00-9.00   sec  11.5 MBytes  96.7 Mbits/sec
[  5]   9.00-10.00  sec  11.3 MBytes  95.2 Mbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.09  sec   142 MBytes   118 Mbits/sec  2560             sender
[  5]   0.00-10.00  sec   139 MBytes   117 Mbits/sec                  receiver

iperf Done.

这里可以看出 server 对 client 的下行带宽大约是 117Mbit/sec。

CPU&内存

sysbench

对于CPU和内存的性能测试,可以使用 sysbench 工具。

$ sudo apt install sysbench -y # Debian 下
$ brew install sysbench        # MacOS 下

对于我的树莓派测试如下:

CPU单线程测试,可以看出单核每秒操作数大约1483:

$ sysbench cpu --threads=1 run
sysbench 1.0.18 (using system LuaJIT 2.1.0-beta3)

Running the test with following options:
Number of threads: 1
Initializing random number generator from current time


Prime numbers limit: 10000

Initializing worker threads...

Threads started!

CPU speed:
    events per second:  1483.17

General statistics:
    total time:                          10.0002s
    total number of events:              14839

Latency (ms):
         min:                                    0.67
         avg:                                    0.67
         max:                                    1.88
         95th percentile:                        0.68
         sum:                                 9992.91

Threads fairness:
    events (avg/stddev):           14839.0000/0.00
    execution time (avg/stddev):   9.9929/0.00

内存读写测试,可以看出读写速度约是 1814MiB 每秒:

$ sysbench memory run
sysbench 1.0.18 (using system LuaJIT 2.1.0-beta3)

Running the test with following options:
Number of threads: 1
Initializing random number generator from current time


Running memory speed test with the following options:
  block size: 1KiB
  total size: 102400MiB
  operation: write
  scope: global

Initializing worker threads...

Threads started!

Total operations: 18590983 (1858174.07 per second)

18155.26 MiB transferred (1814.62 MiB/sec)


General statistics:
    total time:                          10.0001s
    total number of events:              18590983

Latency (ms):
         min:                                    0.00
         avg:                                    0.00
         max:                                    0.20
         95th percentile:                        0.00
         sum:                                 4463.57

Threads fairness:
    events (avg/stddev):           18590983.0000/0.00
    execution time (avg/stddev):   4.4636/0.00

需要注意的是,对 MacOS 的 CPU benchmark 似乎有坑,测试出来的结果异常的大,不具有参考价值。

md5sum

虽然 sysbench 的基准测试看起来比较靠谱,但是实际环境下,真正的执行效率还跟执行的指令啥的都有挺大关系。比如,我们以执行 md5 的速度来对比 CPU 的执行效率:

$ dd if=/dev/zero bs=1M count=1024 | md5sum
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 5.81996 s, 184 MB/s
cd573cfaace07e7949bc0c46028904ff  -

通过 dd 命令向 md5sum 持续发送 1GB 的数据进行计算,计算的速度也可以看成是 CPU 单核性能的一种指标。(考虑到管道操作是单线程,这个指令其实也只能用到一个核)

然后,如果我们多找几个 CPU 进行以下对比,我们就会发现一些神奇的现象:

Cortex-A72(ARM)

  • sysbench 单线程:1479.99 event/s
  • dd + md5sum:184 MB/s

Neoverse-N1(ARM)

  • sysbench 单线程:3497.70 event/s
  • dd + md5sum:424 MB/s

Intel(R) Xeon(R) Platinum 8171M CPU @ 2.60GHz(x86_64)

  • sysbench 单线程:820.19 event/s
  • dd + md5sum:409 MB/s

Intel(R) Xeon(R) CPU E5-26xx v4(x86_64)

  • sysbench 单线程:927.74 event/s
  • dd + md5sum:459 MB/s

Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz(x86_64)

  • sysbench 单线程:1054.30 event/s
  • dd + md5sum:515 MB/s

可以发现 ARM 架构的机器在 sysbench 上表现几乎都比 X86_64 的机器好很多,但是实际跑 md5sum 却相差不大甚至差不少。

可见不同架构间二者的指标并不完全正相关;不过相同架构间二者的指标还是基本正相关的。

参考资料

dd-benchmark

man-hdparm

iperf

利用 Redsocks 解决透明代理的远程抓包问题

作者 mythsman
2022年5月9日 22:10

背景

最近在做一些远程设备的抓包能力建设。具体来说是设备(基于 Docker 的 Redroid 云手机)在服务器上,抓包工具在本地( Mitmproxy , Charles, Fiddler 等类似工具),希望通过某种方法将远程设备上的流量打到本地的抓包工具上,并且流量通过本地的 IP 出到公网。

这样做的目的主要有两个:

  1. 可以做到抓包工具和待抓包设备的分离。既能利用上强大的第三方抓包工具,又无需本地部署待抓包的设备。毕竟如果在远程服务器上部署抓包工具,操作起来就不那么方便,甚至可能还需要自行开发管控界面;如果在本地部署待抓包设备,很可能会遇到例如芯片架构、操作系统、环境依赖、系统资源消耗等问题。
  2. 可以方便进行 IP 出口的调整。在调试一些不可言说的功能时,服务器上部署的设备很可能是走一些代理 IP 池,有时候这些 IP 池本身可能有点问题,导致远程设备被封。这时候如果我们能够将设备的流量导到本地的正常 IP 出公网,可能会更方便我们验证是 IP 问题还是其他的设备问题。

计划

为了打通远程设备本地抓包的这套链路,我们需要考虑如下技术点:

  1. 远程设备需要能够安装本地抓包工具的证书。
  2. 远程设备的流量需要通过某种内网穿透能力打到本地的代理工具上。
  3. 远程设备的流量需要保证不遗漏地进行转发。

由于我们的远程设备是有 root 权限的云手机,因此证书安装并不难。只要将本地的证书通过 openssl 命令转换成指定格式的证书文件,传到服务器上,在云手机启动时 bind 到 /system/etc/security/cacerts/  目录下即可。

同理,由于远程设备是云手机,通过暴露 adb 的 tcpip 端口,我们可以用本地的 adb 客户端进行连接,再通过 adb reverse 就可以构建一个云手机访问本机代理端口的信道。

而要保证云手机的流量(这里特指 HTTP/HTTPS 流量)不遗漏的进行转发,我们就不能采用配置全局正向代理的方法( adb shell settings put global http_proxy xxxx  ),因为个别 app 可以配置强制 NO_PROXY 不走系统代理。一个简单的方法是通过云手机自带的 iptables 工具进行转发,将云手机中所有目的端口为 80/443 的流量转发到 adb reverse 命令转发过来的、映射到本地抓包工具的代理端口即可。

理想的架构图如下:

问题

架构图谁都会画,但是真正实操起来才发现有一堆坑。这套流程对 HTTP 请求的确是有用的,透明代理的工具无论是使用 Charles 还是 Mitmproxy 等中间人代理工具都能正常抓到包。但是对与 HTTPS 的流量则都出现了问题:

  • Charles 会报 invalid first line in request 的错。
  • Mitmproxy 会报 Could not resolve original destination 的错。

当然,个别代理工具可能不支持解 HTTPS ,出现问题可以理解。但是各种代理工具都不能抓,那显然应当是流程上出了问题。经过实验我们发现,使用正向代理或非 HTTPS 的透明代理再加上端口转发都是能通的,唯一不能通的是 HTTPS 的透明代理模式。那么我们就需要先辨析一下这些模式的区别。

分析

正向代理

正向代理是由客户端主动发起,主动将流量打给一个代理服务器,由代理服务器代替请求的过程。下图主要展示正向代理过程中 IP 报文的变化:

  • Alice 代表发起请求的客户端
  • Flank 代表代理服务器(Forward Proxy)
  • Bob 代表客户端需要请求到的服务器

透明代理

透明代理是客户端本身无感知的,由路由转发工具强行进行流量转发(Linux 下可以用 iptables ,Windows 下可以用 netsh ,Mac 下可以用 pfctl)。下图主要展示透明代理过程中 IP 报文的变化:

  • Alice 代表发起请求的客户端
  • Ivan 代表转发流量的路由工具(Iptables之类的工具)
  • Tom 代表透明代理服务器(Transparent Proxy)
  • Bob 代表客户端需要请求到的服务器

在透明代理模式下,路由工具会非常暴力地将客户端发来的包的目的地址直接改为透明代理服务器,这会导致当数据包到了透明代理服务器中时,代理服务器是无法直接获取客户端真正想要到达的服务器地址。而正向代理服务器则不同,客户端会明确告知代理服务器他想访问谁。

那么透明代理服务器要如何在报文中获得真实的目的地址呢?这时候就需要分情况讨论了。

HTTP

我们知道 HTTP 报文是纯明文,就像一个没有封口的信封。只要打开来看就会发现,HTTP 请求报文会在 Header 中带上一个 Host 头表明当前的信期望到达的地方。透明代理服务器可以非常方便地解析到这个信息,从而知道报文需要被发送到的目的地址(Bob)。

HTTPS

HTTPS 这里就比较尴尬了。我们知道 HTTPS 在第四层有一个 TLS 加密层,如果想和 HTTP 一样从 Header 中获取 Host 头的话,则需要先进行 TLS 解密;但是,如果想进行 TLS 解密,则必须和实际的服务器进行 TLS 握手;可是你都不知道实际的服务器在哪,如何握手呢?这竟然变成了一个鸡生蛋还是蛋生鸡的问题。

那么问题最终会怎么解决呢?目光还得回到路由工具。

既然报文是你路由工具传给透明代理的,那显然路由工具这边是记录了报文原先实际需要访问的目的地址的,我直接请求你不就好了么?在 Linux 下,我们有一个用户工具 conntrack 可以展示当前网络连接的链路追踪信息:

$ sudo conntrack -L
tcp      6 60 TIME_WAIT src=192.168.32.1 dst=192.168.32.3 sport=40298 dport=8080 src=192.168.32.3 dst=192.168.32.1 sport=8080 dport=40298 [ASSURED] mark=0 use=1
tcp      6 56 TIME_WAIT src=192.168.32.1 dst=192.168.32.7 sport=54398 dport=2368 src=192.168.32.7 dst=192.168.32.1 sport=2368 dport=54398 [ASSURED] mark=0 use=1
tcp      6 60 TIME_WAIT src=192.168.32.5 dst=172.17.0.1 sport=52992 dport=5001 src=172.17.0.1 dst=192.168.32.5 sport=5001 dport=52992 [ASSURED] mark=0 use=1
tcp      6 79 TIME_WAIT src=10.0.0.4 dst=168.63.129.16 sport=39414 dport=80 src=168.63.129.16 dst=10.0.0.4 sport=80 dport=39414 [ASSURED] mark=0 use=1

而透明代理程序则可以通过 getsockopt 等方法直接向内核查询 socket 的链路信息:

static int getdestaddr(int fd, struct sockaddr_storage *destaddr)
{
    socklen_t socklen = sizeof(*destaddr);
    int error         = 0;

    error = getsockopt(fd, SOL_IPV6, IP6T_SO_ORIGINAL_DST, destaddr, &socklen);
    if (error) { // Didn't find a proper way to detect IP version.
        error = getsockopt(fd, SOL_IP, SO_ORIGINAL_DST, destaddr, &socklen);
        if (error) {
            return -1;
        }
    }
    return 0;
}

这样一来,透明代理服务就能在 TLS 握手前就能拿到真实的目的 IP 了。

然而有了 IP 就够了么?并不是。路由工具中我们只能拿到目的 IP ,但是并没有域名!现代服务端的网关层基本都需要通过域名来进行转发。如何解决这个问题呢?这就引入了 SNI (Server Name Indication)头。在 TLS 握手阶段,客户端会在握手报文里额外增加 SNI 信息(这个已经是TLS标准了,但是有些客户端可能没有加),这样服务端或者透明代理服务器就能获取到实际的域名了。

最后我们再来看下 Mitmproxy 文档中提供的透明代理的流程图:

解决

了解了代理服务器的工作细节,我们再来尝试回答下开头的问题。为什么把远端的 HTTPS 流量通过路由工具+端口映射转发到本地的透明代理服务器中会报错呢?

答案已经很明显了,那就是本地的透明代理工具无法查询到远端服务器中的路由链路信息,导致无法获取真实的目的地址。(也就是为什么 mitmproxy 会报 Could not resolve original destination的原因。

解决这个问题的思路也很清晰,既然希望走透明代理的流量无法跨主机,那我们就将透明代理服务部署在本机,然后转换成正向代理出去即可

具体操作中,由于 mitmproxy 默认不支持在透明代理模式下再配置一个上游正向代理,因此我们可以选择简单魔改一下代码,在透明代理模式下的出口处请求另一个正向代理。

如果懒得改代码,我找到了一个 Redsocks 工具。这个工具可以直接作为透明代理服务器,并将流量转发给一个上游的正向代理。Ubuntu 下可以直接用 apt 安装,配置一下 /etc/redsocks.conf 就能直接使用。

如此,架构图修改成下图即可:

稳定性

方案上线后,原先预计 redsocks 可能会对系统稳定性造成影响,后来发现这一块还好,反倒是由于 mitmproxy 从透明代理模式切到正向代理/上行代理模式,导致 mitmproxy 经常 crash,参见 issue ,略坑。

参考资料

Mitmproxy Docs - Transparent HTTPS

如何使用透明代理抓 HTTPS

Istio的流量劫持和Linux下透明代理实现

利用 FFmpeg 将视频转换为 GIF 操作指北

作者 mythsman
2022年5月2日 12:05

背景

在写技术文档的时候,为了演示方便,经常需要插入一些短小的视频资源。比如一些操作的录频、一些经典视频片段、一些科普动画等。由于展示的地方通常是网页,而如果是插入视频之类的资源,通常需要占用额外的存储,而且也需要可用的视频播放器的支持,非常不便。对于命令行操作的回放,我们可能会采用 Asciinema,但是这个方案对非终端的操作无法模拟。一个常见的方案是把需要的资源转换成网页支持的 GIF、WEBP 格式等动图。考虑到各大浏览器对 GIF 格式的支持最稳定,因此大部分情况下我们都想将待展示的短小视频转换成 GIF 格式,方便直接插入文档中。

将视频转为 GIF 并对 GIF 做调整的过程中会有很多的坑,比如经常容易出现色差严重、分辨率不合适、GIF 占用的存储过大等问题。有时我们也希望在转换前做一些剪切等。处理图片的工具其实有很多,比如 ImageMagick 的 convert 工具,或者 gifsicle 工具,甚至是一些在线的 GIF 格式转换工具(极不推荐,很难用)。但是在处理类视频的信息时,这些工具其实并不很适合。毕竟原始数据本身是视频,相比先把视频转换成 GIF 再做操作,直接在视频上做完操作最后再一步转为 GIF 造成的信息丢失会更少,效果也会更好。本文主要基于 FFmpeg 总结一下转换过程中经常用到的命令,并介绍个人在处理这些问题的一些原则。

视频预处理

在典型的场景下,我们在将视频转为图片前一般会有如下步骤:

  • 视频采集
  • 视频剪裁
  • 视频分辨率调整
  • 视频帧率调整
  • 视频速率调整
  • 视频压缩

下面将基于此流程,结合样例做简要介绍。本地的 ffmpeg 版本为 4.4.1 。

视频采集

如果我们能获取到视频文件,则最好。不过很多情况下还是需要我们使用一些录屏工具进行采集,不过作为系统洁癖患者,个人不建议下载一些乱七八糟来源的录频工具或者很贵但是普通人根本用不到多少功能的视频处理软件。

  • 在 Windows 下,可以通过 Win+G 快捷键呼出 xbox 自带的录频工具。
  • 在 Mac 下,可以通过 Command+Shift+5 快捷键呼出。

以上工具足够用了。

视频裁剪

无论是自有视频,还是录屏来的视频,我们经常都希望剪裁掉头尾的一些无用片段。我们当然可以使用常见的编辑工具,例如 Mac 上的 imovie 等。不过显然我们不想杀鸡用牛刀,一两条命令就可以解决。

截取从 00:00:10 开始的 10 秒的时间段的视频:

$ ffmpeg -i sample.mov -ss 00:00:10 -t 00:00:10 output1.mov

(其中 -ss 表示开始时间,-t 表示截取的时长)

截取从 00:00:10 到 00:00:20 时间段的视频:

$ ffmpeg -i sample.mov -ss 00:00:10 -to 00:00:20 output2.mov

(其中 -ss 表示开始时间,-to 表示结束时间)

确认二者相同并确认视频时长:

$ diff output1.mov output2.mov
$ ffprobe -v error -show_entries format=duration sample.mov
[FORMAT]
duration=10.000000
[/FORMAT]

(其中 -v error 是为了屏蔽一开始打印的版本信息)

视频分辨率调整

由于我们需要展示的视频本身是嵌入文档或网页里的,因此本身对图片分辨率的要求并不大。这时候适当的缩小分辨率无论是对文件占用的大小、还是对展示的便捷都是有好处的。采用的工具是 ffmpeg 的 scale filter graph。

确认原视频的分辨率:

$ ffprobe -v error -show_entries stream=width,height sample.mov
[STREAM]
width=1560
height=1148
[/STREAM]

强制比例缩放,设置长度,宽度:

$ ffmpeg -i sample.mov -vf scale=720:530 output1.mov

(其中 720 为宽,530为高,且,宽高均不可为奇数)

固定比例缩放,设置宽度,高度自适应:

$ ffmpeg -i sample.mov -vf scale=720:-1 output2.mov

固定比例缩放,设置高度,宽度自适应:

$ ffmpeg -i sample.mov -vf scale=-1:530 output3.mov

(将需要自适应的部分设置为-1即可,如果自适应部分再按倍缩放,则可以设置为 -2 ,-3 等)

选择自定义scale算法,可选算法可见ScalerOptions

$ ffmpeg -i sample.mov -vf scale=-1:530:flags=lanczos  output4.mov

(缩放算法有很多种,如果效果不好可以换几个试试。默认是 bicubic 算法)

视频帧率调整

视频的帧率一般会比较高,而我们对 GIF 的要求一般没那么高。为了减少图片的体积,我们可以手动调节下帧率,以达到图片大小和用户体验的最佳平衡点。通常视频的帧率一般是 60 fps 。对普通图片来说,20 的fps早已够用,节约点的话,10 fps ,5 fps 也凑合能看,具体就自己体验喽。

确认原视频帧率:

$ ffprobe -v error -show_entries stream=r_frame_rate sample.mov
[STREAM]
r_frame_rate=60/1
[/STREAM]

(原视频的帧率就是 60 fps)

调整帧率为20:

$ ffmpeg -i sample.mov -r 20 output1.mov

视频速率调整

对于录屏而言,可能我们的动作比较慢,希望在展示的时候稍微加快点速度以提高展示效率并减少视频体积。或者视频本身很快,我们希望做一下慢放。这些时候我们最好提前就对视频速度做一些调整。

调整视频速度变快为5倍(时长*0.2):

$ ffmpeg -i sample.mov -filter:v "setpts=0.2*PTS" output1.mov

调整视频速度变慢2倍(时长x2):

$ ffmpeg -i sample.mov -filter:v "setpts=2*PTS" output2.mov

(原理通过调整视频帧中的 PTS 展示时间戳来调整速度)

需要注意的是,调整速率后,帧率仍然保持不变。因此将视频加速再减速成原视频的速度后,与原视频相比会丢失信息。

视频压缩

其实H264视频本身的压缩率已经很高了,如果想要进一步压缩,基本只能通过一些有损的形式。我们可以通过调整 x264, x265, 以及 libvpx 中的 Constant Rate Factor 参数来进行一些有损压缩处理。该参数取值在 0 到 51 之间,值越大则有损的比例越大,压缩率越好。通常我们会取 23这个值,稍微激进一点可以调整为30+。

调整 crf 取值到30 :

$ ffmpeg -i sample.mov -crf 30 output1.mov

比较二者大小:

$ ls -lah sample.mov output1.mov
-rw-r--r--  1 myths  staff   1.5M May  1 17:31 sample.mov
-rw-r--r--@ 1 myths  staff   1.0M May  1 17:51 output1.mov

可见视频的确压缩了一些,不过会发现 GIF 的质量会有一些下降。是否需要调整这个参数,需要根据实际情况进行取舍。

GIF 格式转换

ffmpeg默认支持根据输出文件的后缀名自动进行格式转换,非常方便。但是如果你以为能无脑用,那就大错特错了。

帧率问题

一个典型的错误转换方法是:

$ ffmpeg -i sample.mov output.gif

有什么问题呢?我们检查一下视频长度就知道了:

$ ffprobe -v error -show_entries format=duration sample.mov
[FORMAT]
duration=62.000000
[/FORMAT]

$ ffprobe -v error -show_entries format=duration output.gif
[FORMAT]
duration=173.680000
[/FORMAT]

我们发现转换后的 GIF 的视频长度竟然和原视频不一样。打开检测后发现的确 GIF 相比原视频要慢了许多。

再检查一下帧率就发现问题了:

$ ffprobe -v error -show_entries stream=r_frame_rate,avg_frame_rate sample.mov
[STREAM]
r_frame_rate=60/1
avg_frame_rate=60/1
[/STREAM]

$ ffprobe -v error -show_entries stream=r_frame_rate,avg_frame_rate output.gif
[STREAM]
r_frame_rate=50/1
avg_frame_rate=257/12
[/STREAM]

GIF 的实际帧率竟然变小了,难怪视频变长了。计算下比例也能对的上:

173.680000/62.000000≈(60/1)/(257/12)

既然如此,我们强制设置下帧率就好了:

$ ffmpeg -i sample.mov -r 20 output2.gif

$ ffprobe -v error -show_entries stream=r_frame_rate,avg_frame_rate,duration output2.gif
[STREAM]
r_frame_rate=20/1
avg_frame_rate=20/1
duration=62.050000
[/STREAM]

这样时长就和原视频对上了,帧率也是我们设置的帧率。具体原因未知,不过结论就是在对视频转 GIF 时,一定要重新指定一下帧率

调色板优化

你可能知道,和视频不同,PNG的调色盘只有256种颜色。默认情况下,这256种颜色会尽量平均分布在整个颜色空间中。这就导致对于一些色彩区分度比较小的图片,会出现颜色模糊的情况。

首先我们生成一张下未优化下的图片:

$ ffmpeg -i sample.mov -r 20 output-raw.gif

结果如下:

这时候需要用对图片进行一下全局调色板优化

$ ffmpeg -i sample.mov -r 20 -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" output-palette.gif

结果如下:

放大后仔细观察一下二者,你会很明显的发现原图的背景颜色变成了很多小颗粒,而使用调色版优化后,背景才真正显示了纯色

当然,如果对图片质量要求高,也可以对每一帧单独记录调色板(代价就是图片会变大很多):

$ ffmpeg -i sample.mov -r 20 -vf "split[s0][s1];[s0]palettegen=stats_mode=single [p];[s1][p]paletteuse=new=1" output-palette-single.gif

如果图片的运动程度比较大,也可以修改一些防抖参数( dither = none / bayer / heckbert / floyd_steinberg / sierra2 / sierra2_4a),如果不指定,默认是 sierra2_4a 。例如:

$ ffmpeg -i sample.mov -r 20 -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=none" output-palette-none.gif

$ ffmpeg -i sample.mov -r 20 -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=bayer" output-palette-bayer.gif

$ ffmpeg -i sample.mov -r 20 -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=heckbert" output-palette-heckbert.gif

最后看一下添加了调色板优化后的图片大小:

$ ls -lah
-rw-r--r--   1 myths  staff   2.2M May  2 11:36 output-palette-bayer.gif
-rw-r--r--   1 myths  staff   2.5M May  2 11:36 output-palette-heckbert.gif
-rw-r--r--   1 myths  staff   1.9M May  2 11:40 output-palette-none.gif
-rw-r--r--   1 myths  staff    32M May  2 11:29 output-palette-single.gif
-rw-r--r--@  1 myths  staff   2.7M May  2 11:15 output-palette.gif
-rw-r--r--   1 myths  staff   1.0M May  2 11:30 output-raw.gif
-rw-r--r--@  1 myths  staff   198K May  1 22:15 sample.mov

显然,每帧记录调色板( output-palette-single.gif ) 最大;不进行调色板优化图片最小,但是质量最差( output-raw.gif );而不使用防抖策略( output-palette-none.gif ) 会使图片更小一点,也不太影响图片观感。

GIF 循环次数设置

通过 ffmpeg 还可以设置图片的循环次数。图片在播放完成后,默认会重头开始播放,如果想修改这个特性,可以通过 -loop 参数指定循环方式,也可以通过 -final_delay 参数配置间隔时间:

设置 GIF 播放完后不重头开始:

$ ffmpeg -i sample.mov -r 20 -loop -1 output.gif

gif muxer 支持的 -loop 和 -final_delay 参数的说明可以通过命令查看:

$ ffmpeg -v error -h muxer=gif
Muxer gif [CompuServe Graphics Interchange Format (GIF)]:
    Common extensions: gif.
    Mime type: image/gif.
    Default video codec: gif.
GIF muxer AVOptions:
  -loop              <int>        E.......... Number of times to loop the output: -1 - no loop, 0 - infinite loop (from -1 to 65535) (default 0)
  -final_delay       <int>        E.......... Force delay (in centiseconds) after the last frame (from -1 to 65535) (default -1)

有点令人费解的是,除了无限重复(0)和不重复(-1)的值,如果你想重复 N 次,那么这个 -loop 参数就要设置为 N+1 。。。

图片大小分析

最后记录一下我的一个测试资源在顺序进行以上各种处理后的大小变化情况:

  1. 录频后的原视频:9.3M (mov格式)
  2. 不加任何参数转码后视频:1.5M(mov格式)
  3. 按需裁减后:1.2M (mov格式)
  4. 分辨率由 1560x1148 调整为 720x539 后:541K(mov格式)
  5. 帧率从 60 调整为 20 后:339K (mov格式)
  6. 速率 x2 后:235K (mov格式)
  7. 视频压缩 CRF 取值 30 后:198K (mov格式)
  8. 转换为 GIF ,使用全局调色板并取消防抖设置后:1.9M (gif格式)

GIF 格式的压缩效果和普通视频格式相比还是差很多的,不过在尽量保证图片质量的前提下,把图片大小控制在 2M 以内一般也还能够接受。

参考资料

压缩 gif 图片的方法(命令行)

Speeding up/slowing down video

CRF Guide

How do I convert a video to GIF using ffmpeg, with reasonable quality?

High quality GIF with FFmpeg

ffmpegでとにかく綺麗なGIFを作りたい

纯Docker部署Https服务——以Nextcloud为例

作者 mythsman
2022年3月9日 15:00

背景

近期阿里云的廉价学生机小水管到期了,打算把一些服务打包迁移到微软员工不要钱的azure云上。

原先各种服务的部署方式都是直接部署的,换主机需要重新搭建各种环境非常麻烦,也容易遗漏。于是就想跟随潮流把这些服务都上docker,能够做到部署一把梭。

难点

一般的开源组件都会有官方docker镜像,部署起来其实都很方便。大部分对着官方文档改改配置再用docker-compose组合一下参数就能跑起来。比较麻烦的点就是网站要部署成 https 的话需要 SSL 证书。对于个人使用的小网站来说,云厂商的 SSL 证书又实在是比较贵(阿里云上通配符域名大概2000¥/年,单域名也要400¥/年)。

比较经济实惠的做法是使用 Let’s Encrypt 的免费证书,不过代价就是他需要定时check你对这个域名的所有权。显然,我们肯定不能手动更新,那样简直要疯。一般我们会用 certbot 来定时进行站点所有权的确认。经常对接站长后台工具的人应该都知道,认证的方式一般有两种:一种是将给定的验证字符串写进 DNS 的 TXT 记录,从而确认你对这个域名的所有权;第二种是将给定的验证字符串写在网站的给定目录下,从而确认你对这个网站的所有权。

理论上最方便的做法是通过一个定时任务,定时调用 DNS 服务商的接口来改 TXT 记录。这样可以做到将 SSL 证书的校验和网站本身的部署分离开,使得校验所有权的逻辑不干扰正常的网站配置。不过尴尬的是 certbot 提供的 DNS插件 基本都不包含国内的运营商。而考虑到域名在国内解析的性能,我还是不太想把域名切到国外的服务商去。

因此事实上我只能采用 webroot 的方式来认证。这个方式比较万金油,不过缺点就是不支持通配符域名,如果需要同时验证多个子域名,则需要手动添加。同时,传统的 certbot 使用方式中一定还需要在系统中配置一个 crontab 任务来做更新,而我现在则希望将这个 crontab 任务也集成在 docker 中,尽量不要对除 docker 外的逻辑做任何感知。

步骤

逻辑上讲,步骤大致应当如下:

  1. 去 DNS 服务商提供的配置后台,将给定域名配置好A记录指向目的主机。
  2. 安装 docker  docker-compose ,并设置好配置文件。
  3. 配置并启动 nginx 只开放 http 端口 ,准备 certbot 的认证环境。
  4. 启动 certbot 初始化,配合nginx,生成首个私钥和证书链。
  5. 利用 certbot 提供的私钥和证书,配置 nginx 的 https 端口。
  6. 配置 certbot 的自动 renew ,进行自动验证。

样例

下面以在 https://pan.mythsman.com 下配置 nextcloud 为例。 DNS 配置、docker 和 docker-compose 安装等步骤略过。

初始化docker配置

./docker-compose.yml

version: "3.8"
services:
    nginx:
        container_name: nginx
        image: nginx:latest
        restart: always
        volumes:
            - ./nginx/logs:/var/log/nginx
            - ./nginx/conf.d:/etc/nginx/conf.d
            - ./certbot/conf:/etc/nginx/ssl
            - ./certbot/data:/var/www/certbot
        ports:
            - "80:80"
            - "443:443"
        command: ["/bin/sh", "-c", "while :;do sleep 24h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\""]

    certbot:
        container_name: certbot
        image: certbot/certbot:latest
        command: certonly --webroot --webroot-path=/var/www/certbot --agree-tos --email mythsman@foxmail.com -d  pan.mythsman.com
        # entrypoint: ["/bin/sh", "-c", "trap exit TERM;while :; do certbot renew --webroot -w /var/www/certbot; sleep 24h & wait $${!}; done;"]
        volumes:
            - ./certbot/conf:/etc/letsencrypt
            - ./certbot/logs:/var/log/letsencrypt
            - ./certbot/data:/var/www/certbot

(需要注意,certbot 的 latest 镜像不支持 arm,如果当前机器是 arm 架构则需要去 dockerhub 中自行选购🥲)

说明:

  1. ./certbot/conf 目录存放的即是 certbot 生成的证书等一大堆文件,因此需要挂载给 nginx 。
  2. ./certbot/logs 目录下存放的是证书创建相关的日志。
  3. ./certbot/data 目录是 certbot 用来存放校验字符串的,需要通过nginx对外网暴露,因此也需要挂在给 nginx。
  4. ./nginx/logs 目录存放的即是 nginx 的相关日志。
  5. ./nginx/conf.d 目录存放 nginx 的配置,下面会介绍。
  6. nginx 的 command 指令写了一个小脚本,用来让 nginx 定时自动 reload,方便 certbot 刷新证书后 nginx 能及时更新。
  7. certbot 的 command 指令中需要配置接受邮箱,用来接受一些通知消息,以及需要配置的域名 pan.mythsman.com
  8. certbot 的 entrypoint 指令是用来后续 renew 的,暂时注释掉。

初始化nginx配置

./nginx/conf.d/default.conf

server {
     listen [::]:80 default_server;
     listen 80 default_server;

     server_name _;

     location ~ /.well-known/acme-challenge {
         allow all;
         root /var/www/certbot;
     }

     location / {
         if ( $host = "pan.mythsman.com" ){
             return 301 https://pan.mythsman.com$request_uri;
         }
         return 444;
     }

}

server {
       listen 443 default_server;
       listen [::]:443 default_server;
       server_name _;
       ssl_reject_handshake on;
       ssl_session_tickets off;
}

说明:

  1. 这里最关键的是 .well-known 行,用来关联 certbot 的校验文件。
  2. return 444 是用来拒绝掉一些未知域名的http访问。
  3. ssl_reject_handshake 是用来拒绝掉一些未知域名的https的访问。

生成证书

执行 docker-compose up -d ,证书生成成功后会在 ./certbot/logs/letsencrypt.log 打印相关日志:

2022-03-09 03:41:39,879:DEBUG:certbot._internal.storage:Creating directory /etc/letsencrypt/archive.
2022-03-09 03:41:39,879:DEBUG:certbot._internal.storage:Creating directory /etc/letsencrypt/live.
2022-03-09 03:41:39,879:DEBUG:certbot._internal.storage:Writing README to /etc/letsencrypt/live/README.
2022-03-09 03:41:39,879:DEBUG:certbot._internal.storage:Creating directory /etc/letsencrypt/archive/pan.mythsman.com.
2022-03-09 03:41:39,879:DEBUG:certbot._internal.storage:Creating directory /etc/letsencrypt/live/pan.mythsman.com.
2022-03-09 03:41:39,880:DEBUG:certbot._internal.storage:Writing certificate to /etc/letsencrypt/live/pan.mythsman.com/cert.pem.
2022-03-09 03:41:39,880:DEBUG:certbot._internal.storage:Writing private key to /etc/letsencrypt/live/pan.mythsman.com/privkey.pem.
2022-03-09 03:41:39,880:DEBUG:certbot._internal.storage:Writing chain to /etc/letsencrypt/live/pan.mythsman.com/chain.pem.
2022-03-09 03:41:39,880:DEBUG:certbot._internal.storage:Writing full chain to /etc/letsencrypt/live/pan.mythsman.com/fullchain.pem.
2022-03-09 03:41:39,880:DEBUG:certbot._internal.storage:Writing README to /etc/letsencrypt/live/pan.mythsman.com/README.
2022-03-09 03:41:39,909:DEBUG:certbot._internal.plugins.selection:Requested authenticator webroot and installer <certbot._internal.cli.cli_utils._Default object at 0x7f94027a1370>
2022-03-09 03:41:39,909:DEBUG:certbot._internal.cli:Var authenticator=webroot (set by user).
2022-03-09 03:41:39,909:DEBUG:certbot._internal.cli:Var webroot_path=/var/www/certbot (set by user).
2022-03-09 03:41:39,909:DEBUG:certbot._internal.cli:Var webroot_path=/var/www/certbot (set by user).
2022-03-09 03:41:39,909:DEBUG:certbot._internal.cli:Var webroot_map={'webroot_path'} (set by user).
2022-03-09 03:41:39,909:DEBUG:certbot._internal.storage:Writing new config /etc/letsencrypt/renewal/pan.mythsman.com.conf.
2022-03-09 03:41:39,911:DEBUG:certbot._internal.display.obj:Notifying user:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/pan.mythsman.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/pan.mythsman.com/privkey.pem
This certificate expires on 2022-06-07.
These files will be updated when the certificate renews.
2022-03-09 03:41:39,911:DEBUG:certbot._internal.display.obj:Notifying user: NEXT STEPS:
2022-03-09 03:41:39,912:DEBUG:certbot._internal.display.obj:Notifying user: - The certificate will need to be renewed before it expires. Certbot can automatically renew the certificate in the background, but you may need to take steps to enable that functionality. See https://certbot.org/renewal-setup for instructions.
2022-03-09 03:41:39,913:DEBUG:certbot._internal.display.obj:Notifying user: If you like Certbot, please consider supporting our work by:
 * Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
 * Donating to EFF:                    https://eff.org/donate-le

也可以通过 nginx 的访问日志 /nginx/logs/access.log  查看他在校验的过程中访问了哪些页面:

3.120.130.29 - - [09/Mar/2022:03:41:36 +0000] "GET /.well-known/acme-challenge/fNVMV-CtDCdq_eAdiaV0rF_J7I-ZW38M7Bo3UqEtOY4 HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)" "-"
3.19.56.43 - - [09/Mar/2022:03:41:36 +0000] "GET /.well-known/acme-challenge/fNVMV-CtDCdq_eAdiaV0rF_J7I-ZW38M7Bo3UqEtOY4 HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)" "-"
34.221.255.206 - - [09/Mar/2022:03:41:36 +0000] "GET /.well-known/acme-challenge/fNVMV-CtDCdq_eAdiaV0rF_J7I-ZW38M7Bo3UqEtOY4 HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)" "-"
64.78.149.164 - - [09/Mar/2022:03:41:36 +0000] "GET /.well-known/acme-challenge/fNVMV-CtDCdq_eAdiaV0rF_J7I-ZW38M7Bo3UqEtOY4 HTTP/1.1" 200 87 "-" "Mozilla/5.0 (compatible; Let's Encrypt validation server; +https://www.letsencrypt.org)" "-"

配置站点容器和证书更新

./docker-compose.yml

version: "3.8"
services:
    nginx:
        container_name: nginx
        image: nginx:latest
        restart: always
        volumes:
            - ./nginx/logs:/var/log/nginx
            - ./nginx/conf.d:/etc/nginx/conf.d
            - ./certbot/conf:/etc/nginx/ssl
            - ./certbot/data:/var/www/certbot
        ports:
            - "80:80"
            - "443:443"
        command: ["/bin/sh", "-c", "while :;do sleep 24h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\""]

    certbot:
        container_name: certbot
        image: certbot/certbot:latest
        # command: certonly --webroot --webroot-path=/var/www/certbot --agree-tos --email mythsman@foxmail.com -d  pan.mythsman.com
        entrypoint: ["/bin/sh", "-c", "trap exit TERM;while :; do certbot renew --webroot -w /var/www/certbot; sleep 24h & wait $${!}; done;"]
        volumes:
            - ./certbot/conf:/etc/letsencrypt
            - ./certbot/logs:/var/log/letsencrypt
            - ./certbot/data:/var/www/certbot
    nextcloud:
        container_name: nextcloud
        image: nextcloud:latest
        volumes:
            - ./nextcloud:/var/www/html
        ports:
            - "8080:80"

说明:

  1. 这里将 cerbot 的 command 注释掉,更换了 entrypoint 的启动脚本,用来每天自动更新 cerbot 证书。
  2. 新增了 nextcloud 的镜像。

配置站点的Nginx

./nginx/conf.d/pan.mythsman.com-ssl.conf

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name pan.mythsman.com;

    ssl_certificate /etc/nginx/ssl/live/pan.mythsman.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/live/pan.mythsman.com/privkey.pem;

    ssl_stapling on;
    ssl_stapling_verify on;

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://172.17.0.1:8080;
    }

    client_max_body_size 10G;

}

说明:

  1. proxy_pass 中的 172.17.0.1 为 host 主机在 docker 中访问的 ip,8080端口暴露的即为前文设置的 nextcloud 的服务。
  2. ssl_certificate 和 ssl_certificate_key 配置为前文 certbot 生成的文件。
  3. 如果申请证书的时候也填了一级域名,那么这里的证书文件中间的域名就是一级域名。
  4. client_max_body_size 10G; 配置是增大上传文件的大小限制。

重新部署

执行 docker-compose down 和  docker-compose up -d ,重新部署各个服务。部署完成后,可以查看 certbot 的日志:

2022-03-09 06:27:54,847:DEBUG:certbot._internal.main:certbot version: 1.24.0
2022-03-09 06:27:54,848:DEBUG:certbot._internal.main:Location of certbot entry point: /usr/local/bin/certbot
2022-03-09 06:27:54,848:DEBUG:certbot._internal.main:Arguments: ['--webroot', '-w', '/var/www/certbot']
2022-03-09 06:27:54,848:DEBUG:certbot._internal.main:Discovered plugins: PluginsRegistry(PluginEntryPoint#manual,PluginEntryPoint#null,PluginEntryPoint#standalone,PluginEntryPoint#webroot)
2022-03-09 06:27:54,863:DEBUG:certbot._internal.log:Root logging level set at 30
2022-03-09 06:27:54,865:DEBUG:certbot._internal.display.obj:Notifying user: Processing /etc/letsencrypt/renewal/pan.mythsman.com.conf
2022-03-09 06:27:54,883:DEBUG:certbot._internal.plugins.selection:Requested authenticator webroot and installer <certbot._internal.cli.cli_utils._Default object at 0x7f64a6694f10>
2022-03-09 06:27:54,883:DEBUG:certbot._internal.cli:Var authenticator=webroot (set by user).
2022-03-09 06:27:54,883:DEBUG:certbot._internal.cli:Var webroot_path=/var/www/certbot (set by user).
2022-03-09 06:27:54,883:DEBUG:certbot._internal.cli:Var webroot_map={'webroot_path'} (set by user).
2022-03-09 06:27:54,883:DEBUG:certbot._internal.cli:Var webroot_path=/var/www/certbot (set by user).
2022-03-09 06:27:54,907:DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): r3.o.lencr.org:80
2022-03-09 06:27:55,136:DEBUG:urllib3.connectionpool:http://r3.o.lencr.org:80 "POST / HTTP/1.1" 200 503
2022-03-09 06:27:55,137:DEBUG:certbot.ocsp:OCSP response for certificate /etc/letsencrypt/archive/pan.mythsman.com/cert1.pem is signed by the certificate's issuer.
2022-03-09 06:27:55,138:DEBUG:certbot.ocsp:OCSP certificate status for /etc/letsencrypt/archive/pan.mythsman.com/cert1.pem is: OCSPCertStatus.GOOD
2022-03-09 06:27:55,141:DEBUG:certbot._internal.display.obj:Notifying user: Certificate not yet due for renewal
2022-03-09 06:27:55,142:DEBUG:certbot._internal.plugins.selection:Requested authenticator webroot and installer None
2022-03-09 06:27:55,142:DEBUG:certbot._internal.display.obj:Notifying user:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2022-03-09 06:27:55,142:DEBUG:certbot._internal.display.obj:Notifying user: The following certificates are not due for renewal yet:
2022-03-09 06:27:55,142:DEBUG:certbot._internal.display.obj:Notifying user:   /etc/letsencrypt/live/pan.mythsman.com/fullchain.pem expires on 2022-06-07 (skipped)
2022-03-09 06:27:55,142:DEBUG:certbot._internal.display.obj:Notifying user: No renewals were attempted.
2022-03-09 06:27:55,143:DEBUG:certbot._internal.display.obj:Notifying user: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2022-03-09 06:27:55,143:DEBUG:certbot._internal.renewal:no renewal failures

没有报错说明流程就是通的,不过由于这个证书是刚申请的,离过期还很远,因此在renew的时候显示不需要更新。如果是想强制更新的话,可以在 certbot 的参数中加 --renew-by-default 选项。如果想确认证书的过期时间,可以通过 openssl 命令查看:

$ openssl x509 -in cert.pem -noout -dates
notBefore=Jan 30 03:00:32 2022 GMT
notAfter=Apr 30 03:00:31 2022 GMT

这时候访问 pan.mythsman.com 即可发现 https 证书已经OK了。

新增子域名

有时候,我们可能想在 nginx 里新增个子域名,这时候就需要把整个 certbot/ 文件夹删除,然后全部重新来一遍

这里有一个坑,就是如果直接删除 certbot/ 文件夹,重新跑初始化一定会有报错。这是因为 nginx/conf.d/ 下的 *-ssl.conf 文件引用了原先在 certbot/ 下生成的证书文件。

简单的做法是在重新跑 certbot 的初始化指令前先把 nginx/conf.d/*-ssl.conf 文件挪走,等初始化完后再挪回来即可。

Nextcloud的其他配置

配置好 https 后,nextcloud有时还不认得自己的scheme已经是https了,这里最好需要修改一下配置。

./nextcloud/config/config.php 新增一行:

'overwriteprotocol' => 'https',

参考资料

Nginx & Certbot (Letsencrypt) via Docker

Certbot User Guide

Can’t use webroot authenticator needed for wildcard domain

快手抓包问题分析

作者 mythsman
2022年2月21日 14:10

背景

不知从什么版本后,对快手进行简单抓包似乎“不可行”了。表现就是使用常规的 HTTP 正向代理抓包工具(charlesmitmproxyfiddler 之类)并且把自签名证书种到系统证书里后,数据依然能刷出来,也能抓到一些零星的报文,但是关键出数据的那些接口报文都没有。

一般来说,常规方法无法抓安卓应用的 https 包,通常有以下几种可能:

  1. 证书信任问题。在 Android 7 以上,应用会默认不信任用户证书,只信任系统证书,如果配置不得当则是抓不到包的。
  2. 应用配置了 SSL pinning,强制只信任自己的证书。这样一来由于客户端不信任我们种的证书,因此也无法抓包。
  3. 应用使用了纯 TCP 传输私有协议(通常也会套上一层 TLS)。由于甚至都不是 HTTP 协议,当然就抓不到包了。
  4. 应用使用 WebSocket 长链接,将不同的接口封装在这个长链接里。在 WebSocket 里承载的协议一般是用某种自定义方式来模拟 http 请求,因此也难以抓包。
  5. 应用使用了基于 UDP 的 http3.0协议等。大部分代理工具目前还不支持 quic 等协议,所以这样一般是抓不到包的。
  6. 应用配置了 Proxy.NO_PROXY ,强制不走系统代理。这样http流量就绕过我们配置的代理,自然抓不到包。

当前的现象是数据能刷出来,那就说明并不是证书信任相关的问题。接下来就需要验证它究竟是使用了什么样的传输方式,对症下药。

最稳妥的验证方式当然是白盒测试看源码,不过快手反编译的代码看起来也费劲,于是考虑直接当成黑盒来测试看看。以下验证方式均以 快手 8.2.31.17191 版本为例。

分析

环境部署

准备自签名CA证书

需要在 Linux 主机上使用 openssl 工具生成一波证书。当然,这一步可以忽略,直接使用mitmproxy生成的证书。只是手动配置一下能够加深一下对 openssl 的理解。

# 生成RSA私钥 cert.key 
openssl genrsa -out cert.key 2048

# 生成有效期为15年的X509证书 cert.crt(DN信息随便填填似乎都行,但是不能全空)
openssl req -new -x509 -key cert.key -out -days 5480 cert.crt

# 私钥和证书放一起,构成PEM格式的 cert.pem
cat cert.key cert.crt > cert.pem

# 将之前生成的pem证书作为mitmproxy使用的根证书
cp cert.pem ~/.mitmproxy/mitmproxy-ca.pem

# 以普通正向代理模式启动在8000端口,并配合curl简单验证证书可用性
mitmproxy -p 8000 

# 能正常访问没有SSL相关报错,就说明之前生成的自签名证书是OK的
curl -x localhost:8000 --cacert ~/.mitmproxy/mitmproxy-ca.pem https://www.baidu.com

# 从证书文件中计算出用于放置在安卓中的文件hash名,假设结果为 a5176621 
openssl x509 -subject_hash_old -in cert.crt -noout

# 将cert.crt复制出一个上述hash结果的新文件
cp cert.crt a5176621.0

值得注意的是,不要尝试使用 mitmproxy --certs 来配置证书,这种方式只能配置 leaf 证书,而不能配置根 CA 证书。因此还是老老实实的把根证书放在默认路径下。

准备设备

为了方便测试,我在 arm 服务器上使用 redroid 准备了一台安卓虚拟机。

docker run -itd --rm --memory-swappiness=0 \
--privileged --name redroid \
--mount type=bind,source=/home/myths/exp/a5176621.0,target=/system/etc/security/cacerts/a5176621.0 \
-p 5555:5555 \
redroid/redroid:11.0.0-arm64 \
redroid.width=720 \
redroid.height=1280 \
redroid.fps=15 \
redroid.gpu.mode=guest

其中 /home/myths/exp/a5176621.0 替换成实际文件所在路径。

然后在arm主机上用 adb 连接安卓的 tcpip 端口,下载并安装快手 8.2.31.17191 版本。

# 在arm服务器上用adb连接安卓虚拟机
adb connect localhost:5555

# 安装快手
adb install kuaishou.apk

为了方便远程操作,需要在本地机器上连接 arm 服务器上的安卓虚拟机,并用scrcpy操作。

# 在本地主机上连接远程arm服务器上的安卓虚拟机
adb connect <ip for arm server>:5555

# 启动scrcpy
scrcpy

到这一步骤时,可以检测安卓中的网络应该都已经是通的了。

复现抓包问题

先尝试使用传统正向代理的方式进行抓包。

# 在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 对安卓设置正向代理,其中 172.17.0.1 为安卓下访问arm主机的ip
adb shell settings put global http_proxy 172.17.0.1:8000

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,返回也正常,但是mitmproxy只能抓到一些静态资源,无法抓到接口。

Ban掉不走代理的所有流量

有数据但是抓不到包,怀疑应当是有些流量漏掉了,于是尝试把这些流量Ban掉看看效果。

# 首先需要打开内核的 ip_forward 功能
echo 1 > /proc/sys/net/ipv4/ip_forward

# 依然在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 继续在手机上配置http代理,其中172.17.0.1为安卓下访问arm主机的ip
adb shell settings put global http_proxy 172.17.0.1:8000

# 在 arm 服务器上配置iptables,将来源于安卓虚拟机但目的地不是arm服务器的流量重定向到一个无用端口。
# 其中 172.17.0.12 位安卓虚拟机的ip,1234为一个无用端口。
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12 ! -d 172.17.0.1 -j REDIRECT --to-ports 1234
# 如果上述操作报了 Couldn't load match `owner':No such file or directory 的错,那么需要在 arm 服务器上启动下相关模块。
sudo modprobe ipt_owner

# 记得测试完成后,将上面这个记录干掉
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,显示“无网络连接“。

说明这里的确是有流量漏了,没有走正向代理,但是依然出了外网。

Ban掉不走代理的443/80流量

那么这些流量到底是私有的四层TCP流量、还是没走正向代理的80/443流量呢?因此尝试把非80/443的流量放开试试。

# 依然在arm服务器上用正向代理启动mitmproxy
mitmproxy -p 8000

# 继续在手机上配置http代理
adb shell settings put global http_proxy 172.17.0.1:8000

# 在 arm 服务器上配置iptables,将来源于安卓虚拟机但目的端口不是80/443的流量重定向到一个无用端口。
# 其中 172.17.0.12 位安卓虚拟机的ip,1234为一个无用端口。
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12  --dport 80 -j REDIRECT --to-ports 1234
sudo iptables -t nat -I PREROUTING -p tcp -s 172.17.0.12  --dport 443 -j REDIRECT --to-ports 1234

# 记得测试完成后,将上面这两条记录干掉
iptables -t nat -D PREROUTING 1
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,依然显示“无网络连接“。

这就说明,控制快手推荐页的流量并不是所谓私有流量,而就是走的80/443端口,只是没有走正向代理。

改用透明代理模式

既然七层的代理配置会被忽略,那就尝试使用四层的透明代理,将流量强制转到透明代理服务器上即可。

# 在arm服务器上以透明代理模式启动mitmproxy
mitmproxy -p 8000 -m transparent

# 将手机上的http代理移除
adb shell settings put global http_proxy :0

# 在arm服务器上配置将来源于安卓虚拟机的的80/443流量直接路由到mitmproxy
# 其中 172.17.0.12 位安卓虚拟机的ip
sudo iptables -t nat -I PREROUTING -p tcp  -s 172.17.0.12 --dport 80 -j REDIRECT --to-ports 8000
sudo iptables -t nat -I PREROUTING -p tcp  -s 172.17.0.12 --dport 443 -j REDIRECT --to-ports 8000

# 记得测试完成后,将上面这两个记录干掉
iptables -t nat -D PREROUTING 1
iptables -t nat -D PREROUTING 1

设置完成后,观察手机的网络状况,现象如下:

  1. 使用浏览器访问普通网站,返回均正常,mitmproxy也能抓到包。
  2. 刷快手推荐页,能成功刷出,并且mitmproxy也抓到了包。

总结

目前看来,快手并非像很多网上传的那样,大多数接口都走了 kquic 协议导致无法抓包。其实很多接口只是做了一个禁止走系统代理的设置,简单使用透明代理模式进行抓包即可轻松绕过。当然,不排除有些关键接口也做了SSL pinning、走私有协议之类的。。。

参考

https://docs.mitmproxy.org/stable/concepts-certificates/

https://stackoverflow.com/questions/56830858

https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/proxy/

一次采耳引发的屎案

作者 mythsman
2022年2月18日 15:20

采耳

上周去团建泡汤,由于300块的自由预算吃吃喝喝实在是用不掉,想来不如消费一波服务,小奢侈一把。于是就点了 299/50min 的采耳服务,想见识见识掏个耳朵又能掏出个什么花样。

结果采耳的小姐姐一看我的耳朵,立马就抱怨说我右耳这有个大耳屎堵在外耳道,硬的不行,尝试夹了半天也夹不出来,稍微扽一扽还容易疼。拿个挖耳内窥镜给我瞅了瞅,的确有一大球黄黑相间的东西堵在那里,甚至都看不到鼓膜了。折腾半天到钟了,只能草草收场,建议我早点去医院弄。

好嘛,300块钱整下去,整的不明不白,不仅没爽到,耳朵被挖疼了也啥都没挖出来,甚至还被要求去医院有病治病。。。

开药

没辙,只能抽时间去附近医院的耳鼻喉科挂个号(-18¥)。医生倒是驾轻就熟,瞅了瞅是耵聍栓塞,也不废话,直接开了两小瓶氧氟沙星滴耳液(-8¥*2),让我回家做三天耳浴,每天三次,每次五分钟以上,然后再过来冲洗。

不得不说耳鼻喉科的医生是真的清闲,系统预约的时候限定半小时一个号,可其实只看了不到2分钟。。。

耳浴

不知道从小听谁说的,耳朵不要进水,进水容易发炎,鼓膜会坏掉。因此我一直都保护耳朵不进水,不会去游泳,即使是洗澡的时候也不会让水流进耳朵。现在看来这也不全对,只要进去的水是干净的其实也没啥问题。

医生开的这个滴耳液其实也并不是什么神药,我认为其实也只是附带了一定消炎功能的水。本质起作用的还是水对耳屎的浸润和软化,只是附带消炎功能防止一些意外感染,听说有时也可以用碳酸氢钠溶液来代替。

正常没有耵聍的情况下,耳浴后随着水倒掉,听力应当立刻恢复正常。但是耵聍栓塞的时候的确很不舒服,尤其是把药水倒掉之后,耳屎会有一部分粘在鼓膜上,导致一直隆隆隆的,需要等一段时间才会恢复正常。

清水进,黄水出。

不过多尝试几次也就习惯了,药水也渐渐的从黄色变成了偶尔会带一些颗粒出来了,耳朵里的异物感也由于耳屎变软变散了而越来越明显。

到了三天之期,打算再来医院一趟,结果医生因事停诊,只得再拖延一天。不得不再说,清闲科室的医生真是爽,想翘班就翘班。。。

冲洗

隔了一天约到了号(-18¥),这次直接进了一个操作的诊室(门上写的是耳鼻喉敷贴诊室啥的)。这次换了个女医生,让我坐在一个大的内窥镜边上,拿发光的内窥管照了照,确认了一下右耳耳屎状况(-20¥),顺带也确认了下左耳(-20¥)。

看了下没问题后,拿了一个冲耳器,加了点水,往右耳使劲一推,里面的脏东西就一波带出来了。医生看了看已经干净了,就说我可以走了。。。

冲耳器

不过我耳朵边上有点水,于是就问医生有没有纸给我擦下,医生顺手递过来一个小纱布(-18¥)。

总结

完事后看了下账单:

  1. 第一次挂号费18元,诊断费3元。
  2. 两瓶药水16元。
  3. 第二次挂号费18元。
  4. 内窥镜看左耳20元。
  5. 内窥镜看右耳20元
  6. 小换药(想了半天应该就是我要的那片纱布)18元。

不得不说,医院的收入构成真的很有趣,大头竟然是租器材和卖耗材。内窥镜按次收费,纱布按片收费,用的时候不明说,等你做完了再给你统统算上。虽然我从理性上还是能接受的,但是从感性上讲的确不太舒服。而且看上去医生的劳动产生的价值并没有体现在费用中,相反,医生的定位似乎只是高价仪器的操作工,有价值的是这些耗材,而不是医生的职业水准和服务质量。也难怪这种科室的医生大多十分高冷。。。有时候想想科技的进步到底是提高了人们接受到的医疗水平、还是提高人们的就医成本从而降低的普遍的医疗水平呢?不太懂,不过起码“手术室里都是钱“的说法还是很有道理的。

罢了,还是继续努力健身,医院没事还是尽量少来吧。

树莓派与1024x600分辨率屏幕的适配问题

作者 mythsman
2021年12月27日 21:41

问题

为了方便操作,买了一个 7英寸的 LCD 触摸屏,结果发现屏幕是 1024x600 的分辨率,系统总是会认为是 1024x768 的分辨率,导致了两个后果:

  1. 分辨率不准。由于实际展示的高度比渲染的要低,导致屏幕下方的东西是看不到的。
  2. 触摸板不准。虽然图像渲染会超出屏幕,但是触摸板似乎却做了缩放,触摸的地方越往下鼠标跟手的位置就越不准。

问了下亚博智能的技术客服,按照他的建议修改了 /boot/config.txt 中的 hdmi_cvt 等相关参数,重启后依然不行。接着他们建议直接刷入他们配置好的 armhf 镜像。由于我需要使用 arm64 ,因此个我肯定是不能干的。于是就自己琢磨了下,大半天才终于搞定。

踩坑

弯路稍微提一嘴吧,不然对不起大半天踩的坑。

为了解决分辨率问题,研究了树莓派官网HDMI配置的一大堆参数,经过一下午的重启后,结果还是不行。

为了解决触摸板不准的问题,找到了 xinput_calibrator 这个屏幕校准的工具,结果由于屏幕太矮,下面的两个检查点根本点不到,太坑。

解决

这个问题可以理解是驱动问题,应当还是要直接找到生产厂商,整到驱动相关的文档。

经过多方查找,发现我的这个板子是 微雪的7inch HDMI LCD ,找到官方文档后,发现了驱动的 github 地址:https://github.com/waveshare/LCD-show.git

代码clone下来后,似乎是要执行其中的 ./LCD7-1024x600-show 脚本。不过慢着,先看下脚本做了啥:

i=1
for lines in `cat ./boot/mark`
do
  case ${i} in
  1) var1=${lines};;
  esac
  i=`expr ${i} + 1`
done
#echo "$var1"

j=$(uname -n)
if test "$var1" = "0" -a "$1" != "lite" -a "$2" != "lite" ;then

if test "$j" != "retropie" ;then
sudo apt-get install xserver-xorg-input-evdev
sudo cp -rf /usr/share/X11/xorg.conf.d/10-evdev.conf /usr/share/X11/xorg.conf.d/45-evdev.conf
fi

if test "$j" = "retropie" ;then
sudo cp -rf ./nes /home/pi/RetroPie/roms/
fi

echo "1" > ./boot/mark
sudo dpkg -i -B ./xinput-calibrator_0.7.5-1_armhf.deb

sudo apt-get install cmake -y
cd ./rpi-fbcp/build/
sudo cmake ..
sudo make
sudo install fbcp /usr/local/bin/fbcp
cd ../../
sudo mkdir -p /usr/share/X11/xorg.conf.d

elif test "$var1" = "0" -a "$1" = "lite" ;then
echo "1" > ./boot/mark
echo "No touch driver installled"
echo "No fbcp driver installled"
sudo mkdir -p /usr/share/X11/xorg.conf.d

fi

sudo cp -rf ./etc/rc.local /etc/rc.local

if test "$1" = "0" -o "$#" = "0" -o "$2" = "0" ; then
sudo cp -rf ./etc/X11/xorg.conf.d/99-calibration.conf-7-1024x600  /usr/share/X11/xorg.conf.d/99-calibration.conf
sudo cp ./boot/config-7-1024x600.txt /boot/config.txt
echo "LCD configure 0"
elif test "$1" = "lite" -a  "$#" = "1"; then
sudo cp -rf ./etc/X11/xorg.conf.d/99-calibration.conf-7-1024x600  /usr/share/X11/xorg.conf.d/99-calibration.conf
sudo cp ./boot/config-7-1024x600.txt /boot/config.txt
echo "LCD configure 0"
elif test "$1" = "90" -o "$2" = "90" ; then
sudo cp -rf ./etc/X11/xorg.conf.d/99-calibration.conf-7-1024x600-90  /usr/share/X11/xorg.conf.d/99-calibration.conf
sudo cp ./boot/config-7-1024x600.txt-90 /boot/config.txt
echo "LCD configure 90"
elif test "$1" = "180" -o "$2" = "180" ;then
sudo cp -rf ./etc/X11/xorg.conf.d/99-calibration.conf-7-1024x600-180  /usr/share/X11/xorg.conf.d/99-calibration.conf
sudo cp ./boot/config-7-1024x600.txt-180 /boot/config.txt
echo "LCD configure 180"
elif test "$1" = "270" -o "$2" = "270" ;then
sudo cp -rf ./etc/X11/xorg.conf.d/99-calibration.conf-7-1024x600-270  /usr/share/X11/xorg.conf.d/99-calibration.conf
sudo cp ./boot/config-7-1024x600.txt-270 /boot/config.txt
echo "LCD configure 270"
fi



sudo cp -rf ./usr/share/X11/xorg.conf.d/99-fbturbo.conf-HDMI /usr/share/X11/xorg.conf.d/99-fbturbo.conf 
if test "$j" = "kali" ;then
sudo cp ./usr/share/X11/xorg.conf.d/99-fbturbo.conf-kali-HDMI /usr/share/X11/xorg.conf.d/99-fbturbo.conf
fi

if [ -b /dev/mmcblk0p7 ]; then
sudo cp ./cmdline.txt-noobs /boot/cmdline.txt
else
sudo cp ./cmdline.txt /boot/
fi

sudo cp ./inittab /etc/

if test "$#" = "0" -o "$1" = "0" -o "$1" = "90"  -o "$1" = "180" -o "$1" = "270" -o "$2" = "0" -o "$2" = "90"  -o "$2" = "180" -o "$2" = "270" -o "$1" = "lite" -o "$2" = "lite"; then
sudo reboot
echo "reboot now"
else
echo "Invalid parameter,Usage:LCD7-1024x600-show [0] [90] [180] [270] [lite]"
fi

需要注意的坑是这里有个 sudo dpkg -i -B ./xinput-calibrator_0.7.5-1_armhf.deb 操作。不懂为啥他要把 xinput-calibrator 的 armhf 包写死在项目里。如果系统镜像是 arm64 的话,这个包是用不了的,还会报错。解决方法是,把这行直接改成 sudo apt install xinput-calibrator -y 即可。

其他操作看起来都没有坑,比如用 apt 安装依赖、往 /usr/share/X11/xorg.conf.d/ 下扔配置文件、往 /etc 下扔 inittab 文件、修改 /boot/config.txt 配置文件、修改 /boot/cmdline.txt 启动参数等等,似乎都是些业务逻辑。

但其实这里的配置修改的地方简直太暴力了,都是直接用项目里的配置完整替换掉原始文件,也不做任何备份。万一我自己加了一些配置,这里就完全被覆盖掉了,找都找不回来。。。所以建议在刷完干净的镜像后就执行这个命令,不要急着配置其他的东西。并且记得把这两个文件手动备份下,万一出了问题也方便改回去。。。

树莓派4B使用arm64系统踩坑

作者 mythsman
2021年12月27日 21:39

背景

由于工作原因,需要一台 arm64 的服务器测试一些功能。但是目前这个点没法快速采购到腾讯云或者百度云的arm服务器(这俩公司的arm服务器好像都只是在内测阶段,据说得2022年初才能 Release)。想了一圈发现树莓派似乎正好有 arm64 的cpu,于是去官网确认了下 Specification

Broadcom BCM2711, Quad core Cortex-A72 (ARM v8) 64-bit SoC @ 1.5GHz

看起来挺好的,于是在亚博智能店里买了个 4B ,但是买到后跑起来发现竟然不是 arm64 的,大呼上当。不过仔细一研究发现原来是原装的 Raspberry os 镜像使用的是 armhf 内核(即支持hard float 的32位 arm )。而树莓派官方其实在 2020 年已经发布了支持 arm64 内核的镜像了,参见这个 Reddit 讨论。同时也发现 arm64 的系统相比 armhf 其实也更能发挥64位cpu的能力。

既然如此,那就搞起来吧。

方案

网上冲浪了一把,发现基本有俩套解决方案:

  1. 修改 /boot/config.txt 的 arm_64bit 配置(参考官方配置文档)。
  2. 重新刷入 arm64 位的镜像。

方案1看起来更方便,方案2看起来更稳妥,于是我挨个试了下。为了验证系统架构的正确性,我也准备了几个测试方法:

  1. uname -a
  2. cat /proc/cpuinfo
  3. getconf LONG_BIT
  4. file /bin/bash
  5. cat /proc/version
  6. dpkg --print-architecture

验证

原始环境配置

首先看一下原版基于 raspios_armhf-2020-05-28 的镜像下的配置情况。

pi@raspberrypi:~ $ uname -a
Linux raspberrypi 5.10.63-v7l+ #1496 SMP Wed Dec 1 15:58:56 GMT 2021 armv7l GNU/Linux
pi@raspberrypi:~ $ cat /proc/cpuinfo
processor : 0
model name : ARMv7 Processor rev 3 (v7l)
BogoMIPS : 108.00
Features : half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm crc32
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 1
model name : ARMv7 Processor rev 3 (v7l)
BogoMIPS : 108.00
Features : half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm crc32
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 2
model name : ARMv7 Processor rev 3 (v7l)
BogoMIPS : 108.00
Features : half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm crc32
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 3
model name : ARMv7 Processor rev 3 (v7l)
BogoMIPS : 108.00
Features : half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt vfpd32 lpae evtstrm crc32
CPU implementer : 0x41
CPU architecture: 7
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
Hardware : BCM2711
Revision : d03114
Serial  : 10000000b6d307b2
Model  : Raspberry Pi 4 Model B Rev 1.4
pi@raspberrypi:~ $ getconf LONG_BIT
32
pi@raspberrypi:~ $ file /bin/bash
/bin/bash: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=3e5e2847bbc51da2ab313bc53d4bdcff0faf2462, stripped
pi@raspberrypi:~ $ cat /proc/version
Linux version 5.10.63-v7l+ (dom@buildbot) (arm-linux-gnueabihf-gcc-8 (Ubuntu/Linaro 8.4.0-3ubuntu1) 8.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1496 SMP Wed Dec 1 15:58:56 GMT 2021
pi@raspberrypi:~ $ dpkg --print-architecture
armhf

基本符合预期,唯一让我一开始比较意外的就是 /proc/cpuinfo 里的 Model Name 是 ARMv7 Processor rev 3 (v7l),和官方声称的 ARM v8 并不一致。后来才明白原来这个值也是跟系统内核有关的,有时并不能真正准确的表达真是硬件的属性。这个镜像下的系统的确是32位arm无疑了。

修改环境配置

参照官网配置文档,将 /boot/config.txt 中的 arm64_bit 置位1,重启后重新进行下检测。

pi@raspberrypi:~ $ uname -a
Linux raspberrypi 5.10.63-v8+ #1496 SMP PREEMPT Wed Dec 1 15:59:46 GMT 2021 aarch64 GNU/Linux
pi@raspberrypi:~ $ cat /proc/cpuinfo
processor : 0
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 1
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 2
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 3
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
Hardware : BCM2835
Revision : d03114
Serial  : 10000000b6d307b2
Model  : Raspberry Pi 4 Model B Rev 1.4
pi@raspberrypi:~ $ getconf LONG_BIT
32
pi@raspberrypi:~ $ file /bin/bash
/bin/bash: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=3e5e2847bbc51da2ab313bc53d4bdcff0faf2462, stripped
pi@raspberrypi:~ $ cat /proc/version
Linux version 5.10.63-v8+ (dom@buildbot) (aarch64-linux-gnu-gcc-8 (Ubuntu/Linaro 8.4.0-3ubuntu1) 8.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1496 SMP PREEMPT Wed Dec 1 15:59:46 GMT 2021
pi@raspberrypi:~ $ dpkg --print-architecture
armhf

结果有点出乎意料。

通过内核版本查看的cpu架构基本都能正确展示出 aarch64 的关键字,但是其实系统中除了内核之外的其他软件仍然是 32 位的版本(毕竟只是更新了内核)。而且 dpkg 默认使用的软件架构仍然是 32 位的。这意味着当前系统其实有点四不像,既有64位的东西,又有32位的东西,不用想就知道以后100%会踩坑。

同时注意到,刷了新内核之后,cpuinfo 展示硬件信息为 Hardware : BCM2835 ,而原来的硬件信息却是Hardware : BCM2711 。在 gadgetversus 里对比了一下两种芯片:

虽然 2835>2711,但其实是变成了个老版本,真的是闹不明白。。。后来在官方论坛上瞅了瞅,才发现树莓派的软件总监说这好像是因为实际上存在多个芯片类型,但是为了一些上下游兼容的逻辑,就展示了一个错的。。。

顺带吃一个瓜:

mmuaa同学在博客里分享了他的方法。pingwei同学照着弄死机了,也写了个博客,然后还去mmuaa同学的博客下面喷粪,笑死了。

重新刷入镜像

四不像的系统肯定是不要用的,于是就去官网下了Imager,又去清华镜像站里下了 最新的基于 buster (ubuntu 18.04 , debian 10) 的arm64位镜像,烧进TF卡里。装机成功后验证了下数据。

pi@raspberrypi:~ $ uname -a
Linux raspberrypi 5.10.17-v8+ #1414 SMP PREEMPT Fri Apr 30 13:23:25 BST 2021 aarch64 GNU/Linux
pi@raspberrypi:~ $ cat /proc/cpuinfo
processor : 0
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 1
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 2
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
processor : 3
BogoMIPS : 108.00
Features : fp asimd evtstrm crc32 cpuid
CPU implementer : 0x41
CPU architecture: 8
CPU variant : 0x0
CPU part : 0xd08
CPU revision : 3
Hardware : BCM2835
Revision : d03114
Serial  : 10000000b6d307b2
Model  : Raspberry Pi 4 Model B Rev 1.4
pi@raspberrypi:~ $ getconf LONG_BIT
64
pi@raspberrypi:~ $  file /bin/bash
/bin/bash: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, BuildID[sha1]=b11533bde88bb45ef2891fbf3ad86c1869ed3a41, stripped
pi@raspberrypi:~ $ cat /proc/version
Linux version 5.10.17-v8+ (dom@buildbot) (aarch64-linux-gnu-gcc-8 (Ubuntu/Linaro 8.4.0-3ubuntu1) 8.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1414 SMP PREEMPT Fri Apr 30 13:23:25 BST 2021
pi@raspberrypi:~ $ dpkg --print-architecture
arm64

这下终于统一都是 arm64 的了,虽然 cpuinfo 还是显示的是老的芯片信息(BCM2835),不过毕竟没啥太大影响。

剩下需要注意的就是,在配置新的apt源的时候,/etc/apt/sources.list 直接配置 debian 10 的镜像即可,不要用 raspberrypi 的镜像了;/etc/apt/sources.list.d/raspi.list 里再配置下 raspberrypi 的特定源即可。

❌
❌