edit(content): review batch-2 editorial pass across 24 posts (EN + ZH)

Tracker at 20 approved / 71 pending. Same pass as batch 1: first person throughout,
no LAN IPs in prose, ZH twins normalised to simplified Chinese, dynamicCta where the
post ends on a concrete next step.
This commit is contained in:
hoelee committed 2026-10-27 04:07:00 +08:00
1 parent 041471d25e
commit 09596fe7a5
45 files changed
+418 -337

No files matched your search

@@ -120,7 +120,7 @@ for (const sheet of document.styleSheets) {
`>` 字符——在我的 CSS 里写对了、在数据库里存对了、API 也返回对了
——被渲染进 HTML 时变成了字面文本 `u003e`。不是 `>` 字符,而是
`u`、`0`、`0`、`3`、`e` 这六个字符。
`u`、`0`、`0`、`3`、`e` 这五个字符。
于是浏览器试着解析这个选择器:
@@ -154,11 +154,9 @@ ul.pf-c-list li[data-kind="text"] {
### 这个转义是从哪来的
`\u003E` 是 JSON/JavaScript 对 `>` 的编码方式。authentik 的 Django
模板把品牌配置渲染成一个 JavaScript 对象字面量,而在这个过程里,
某个 HTML/JS 转义器被作用到了 `branding_custom_css` 字符串上。反斜杠
和 `u` 在这个路径的某处被拆开了,于是浏览器收到的是 `u003e`
——没有反斜杠的转义序列——而不是 `>`。
`\u003E` 是 JSON/JavaScript 对 `>` 的编码方式。authentik 的 Django 模板把品牌配置渲染成一个 JavaScript 对象字面量,而在这个过程里,某个 HTML/JS 转义器被作用到了 `branding_custom_css` 字符串上。
而在 CSS 语法规范中,十六进制转义直接以反斜杠加数字开头(`\003e`),并不像 JS 那样使用 `\u`。由于 `u` 不是合法的十六进制字符(0-9a-f),浏览器的 CSS 词法分析器会把 `\u` 当作对字面字母 `u` 的转义,从而把 `<style>` 里的 `\u003E` 解析成了普通标识符 `u003e`。
存进去的值是对的。API 的返回也是对的。只有渲染出来的页面是错的:
@@ -41,7 +41,7 @@ x-powered-by: Express
## 背景:给一个没有 SSO 的应用加 SSO
这个应用是我 NAS 上的一个自托管数据库界面(Docker,发布在宿主端口上,位于 nginx 和 Cloudflare Tunnel 后面——我家实验室的常见形态)。我想在它前面加一道登录墙,让「能从公网访问得到」不再意味着「任何人猜中主机名就能看到登录表单、开始猜密码」。
这个应用是我 NAS 上的一个自托管数据库界面(Docker,发布在宿主端口上,位于 nginx 和 Cloudflare Tunnel 后面——我管理的私有基础设施的常见形态)。我想在它前面加一道登录墙,让「能从公网访问得到」不再意味着「任何人猜中主机名就能看到登录表单、开始猜密码」。
应用自己的文档宣称支持 SSO。我会把这篇的重点放在入口陷阱上,许可的故事放到另一篇里讲,但简要版本是:宣传的 SSO 是付费许可功能,开源构建带有代码路径但不带使用许可,而过去填补这个空白的社区 fork 已经两年没人维护了。二十分钟调研、两个被放弃的方案、一个结论:**这个应用永远不会为我做 SSO,所以网关必须放在它前面。**
@@ -91,7 +91,7 @@ curl -sk -o /dev/null -w '%{http_code} -> %{redirect_url}' \
| 路径 | 谁在服务它 | 落在哪里 |
|---|---|---|
| 家里 IP → nginx (443) | 我改过的那个 vhost | outpost ✅ |
| 直连公网 IP → nginx (443) | 我改过的那个 vhost | outpost ✅ |
| Cloudflare Tunnel (`cloudflared`) | 隧道自身配置里的一条规则 | 直连应用的端口 ❌ |
隧道的 ingress 是一个按主机名求值的小型有序列表:
@@ -116,7 +116,7 @@ curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$T
```
```
git.example.com -> http://192.168.1.6:6880
git.example.com -> http://10.0.0.10:6880
app.example.com -> http://app-container:8080
bot.example.com -> http://bot-container:8080
* -> http_status:404
@@ -186,7 +186,7 @@ grep -c 'proxy_pass http://localhost:10000;' vhost.conf
# 11
```
11 个。我的应用只是其中之一。**另外还有十个应用都经由那个 outpost 被网关拦着**,一次无差别的替换会同时悄悄拆掉它们所有人的门——包括那些在做真实认证工作的应用。这个「修复」看起来会像是成功的,同时却会拆掉我家实验室大部分认证。
11 个。我的应用只是其中之一。**另外还有十个应用都经由那个 outpost 被网关拦着**,一次无差别的替换会同时悄悄拆掉它们所有人的门——包括那些在做真实认证工作的应用。这个「修复」看起来会像是成功的,同时却会拆掉我整套基础设施的大部分认证。
我实际做的是:
@@ -238,7 +238,7 @@ curl -s https://app.example.com/ -o /dev/null -w '%{http_code}\n' # what does
如果你需要在内部工具前面加单点登录、把自托管的应用安全地暴露到公网,或者需要有人审计到底是哪条入口路径在真正服务你的域名——这正是我在做的工作。
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) —— 点击对话
- **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) —— 点击对话
- **Email:** [[email protected]](mailto:[email protected]?subject=Reverse%20proxy%20%26%20SSO%20enquiry)
- **网站:** [hoelee.com](https://www.hoelee.com)
@@ -53,7 +53,7 @@ authentik 是一个开源的**身份提供商(IdP)**——反复回答同一
它强制要求逐级走。所以计划是:
```
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.4 → 2026.6 → 2026.8
```
六跳,一次一步,每步之间跑一次迁移 + 一次健康检查。动手之前,有一个绝对
@@ -112,7 +112,7 @@ mkdir -p data && mv media data/media
错误也随之消失:
```sql
TRUNCATE authentik_core_session;
TRUNCATE authentik_core_session; -- 在部分版本中为 authentik_core_authenticatedsession
```
有个副作用值得知道:这也会让你各应用持有的所有 OIDC refresh token 失效。它们
@@ -182,7 +182,7 @@ POST /api/v3/flows/bindings/ # { target: "<flow pk>", stage: "<stage pk>", ord
## 我会怎么做得不一样
整场折腾归结为三个本可避免的模式:
整个排查与修复过程归结为三个本可避免的模式:
1. **绝不要靠猜字段的语义**——我把 `authorization_flow` 当成了「登录 flow」,
而真相源是模型定义,字段自己的 docstring 里就把区别写得明明白白。
@@ -8,7 +8,7 @@ ogImage: /og/zh-automating-cyberpanel-without-the-ui.jpg
banner: /banners/automating-cyberpanel-without-the-ui.jpg
---
我在家庭实验里的一台 Ubuntu 虚拟机里跑着 CyberPanel 2.4.4.1,用来托管几个小站,
我在自托管基础设施的一台 Ubuntu 虚拟机里运行着 CyberPanel 2.4.4.1,用来托管多个站点,
入口是 `https://panel.hoelee.com`,反向代理到内网的一台机器。最近我想不打开浏览器、
直接用脚本做两件事:列出我的站点、删掉其中一个。这篇文章讲的就是我怎么发现"官方
API"根本已经不存在了,然后从 Angular 界面偷偷调用的那些请求里把真正的 API 逆向出来。
@@ -61,7 +61,7 @@ Django 在第一次 GET 时就会设一个 `csrftoken` cookie。这个 cookie
操作时用头部回显回去的 token:
```bash
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
curl -sk -c /tmp/cp_cookies.txt "https://10.0.10.24:8090/" -o /dev/null
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
```
@@ -71,10 +71,10 @@ CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
```bash
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/verifyLogin" \
-X POST "https://10.0.10.24:8090/verifyLogin" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-H "Referer: https://10.0.10.24:8090/" \
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
```
@@ -89,10 +89,10 @@ curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
-X POST "https://10.0.10.24:8090/websites/fetchWebsitesList" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-H "Referer: https://10.0.10.24:8090/" \
-d '{"page":1,"recordsToShow":50}'
```
@@ -101,10 +101,10 @@ curl -sk -b /tmp/cp_cookies.txt \
```bash
curl -sk -b /tmp/cp_cookies.txt \
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
-X POST "https://10.0.10.24:8090/websites/submitWebsiteDeletion" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-H "Referer: https://192.168.1.124:8090/" \
-H "Referer: https://10.0.10.24:8090/" \
-d '{"websiteName":"blog.hoelee.com"}'
```
@@ -94,7 +94,7 @@ dmesg 里这样的记录一共 **6 条**,而 `RestartCount` 正是 **6**。同
宿主 load average 26–32
```
每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在家庭 NAS 上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。
每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在自托管存储节点(NAS)上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。
## 修法,以及必须跟着的那句话
@@ -3,6 +3,7 @@ title: "每个播放器都在同一个视频上卡顿,而文件本身是无辜
description: "同一个 HEVC 文件,在 VLC、Chrome 和 iPad 上(离线)都出现同样的周期卡顿。容器层时间轴完美无缺,画面却不是——NVDEC 的旧版 AV1 包装器返回了错误时间点的帧。"
pubDate: 2026-10-08
category: devops
dynamicCta: true
tags: [ffmpeg, nvdec, hevc, transcoding, video]
ogImage: /og/zh-every-player-stuttered-the-file-was-fine.jpg
banner: /banners/every-player-stuttered-the-file-was-fine.jpg
@@ -81,7 +82,7 @@ ffmpeg -c:v av1_cuvid -i source.mkv -vf "scale=640:360" -f rawvideo -pix_fmt yuv
## 第三步:修复只要一行
Bug 不在硬件,在旧的解码器包装器。`av1_cuvid` 属于老式的 wrapper 解码路径;现代的硬件路径要走 `hwaccel`:
Bug 不在硬件(实测环境为 NVIDIA RTX 3060 12GB + FFmpeg 6.1/7.0),而在旧的解码器包装器。`av1_cuvid` 属于老式的 wrapper 解码路径;现代的硬件路径要走 `hwaccel`:
```bash
# 修复前
@@ -132,3 +133,13 @@ vp9_cuvid vs libvpx-vp9 SSIM 0.99991 (0 帧低于 0.98——不受影
5. **一个"失败得安全"的闸门,比一个从不失败的闸门值钱得多。** 我的第一版闸门拒绝了那段 4K60 编码,文件是好的、闸门是错的——但这个错误的代价只是白烧一次编码,而不是让一个坏文件进了库。
卡顿消失了。不是因为编码器变好了,而是因为解码器不再说谎了。
---
## 需要构建高可靠的媒体转码或自动化质检管线?
如果你正在运营自动化音视频处理流水线、自托管流媒体基础设施,或遇到了常规日志无法解释的底层软硬件排障难题,这正是我日常交付的工作:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Media%20pipeline%20%26%20infrastructure%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
@@ -1,6 +1,6 @@
---
title: "加固 Tor 洋葱服务:真正重要的是什么"
description: "出于对暗网的好奇,我把个人主页和 Git 服务器镜像了过去。这篇记录哪些加固扛得住、哪些会悄悄坏掉,以及值不值得把它当成服务卖给客户。"
title: "加固 Tor 洋葱服务:真正重要的是什么"
description: "出于对暗网的好奇,我把个人主页和 Git 服务器镜像了过去。这篇记录哪些加固扛得住、哪些会悄悄坏掉,以及值不值得把它当成服务卖给客户。"
pubDate: 2025-12-10
updatedDate: 2026-09-09
category: devops
@@ -11,78 +11,78 @@ ogImage: /og/zh-hardening-a-tor-onion-service.jpg
banner: /banners/hardening-a-tor-onion-service.jpg
---
我对暗网产生了好奇。不是集市那一面,而是无聊的那一半:人们像注册商和云控制台出现之前那样自托管——跑自己维护的软件,用一个自己掌控的地址就能访问。
我对暗网产生了好奇。不是集市那一面,而是无聊的那一半:人们像注册商和云控制台出现之前那样自托管——跑自己维护的软件,用一个自己掌控的地址就能访问。
我回应好奇心的方式一向是动手做一个东西,于是实验就这样定了:把自己的网站镜像到 Tor 上。我的交互式个人主页在 [me.hoelee.com](https://me.hoelee.com),代码托管在一个 Gitea 实例 [git.hoelee.com](https://git.hoelee.com) 上。现在两者都有暗网分身:
我回应好奇心的方式一向是动手做一个东西,于是实验就这样定了:把自己的网站镜像到 Tor 上。我的交互式个人主页在 [me.hoelee.com](https://me.hoelee.com),代码托管在一个 Gitea 实例 [git.hoelee.com](https://git.hoelee.com) 上。现在两者都有暗网分身:
- `hoeleegitkcng572znkbpyffppyulsdwv3aurrzlk7y7vlhknogswoqd.onion` — Gitea 镜像
- `hoeleeaiwowgndbxswegtdzoeupz7lkkechtqmurbmnpvwa4k3vyuyid.onion` — 个人主页镜像
### 如果你从没打开过 `.onion`
这些地址在普通浏览器里打不开,你需要 Tor Browser,它会把你的流量经过 Tor 网络转发。安装大概五分钟:
这些地址在普通浏览器里打不开,你需要 Tor Browser,它会把你的流量经过 Tor 网络转发。安装大概五分钟:
1. 从 **[torproject.org/download](https://www.torproject.org/download/)** 下载。
2. **验证签名。** 下载页会给出 `.asc` 文件和签名密钥的链接,这一步很重要——被篡改的 Tor Browser 是丢掉匿名性最糟糕的方式。先取得 Tor Browser 的签名密钥(指纹发布在下载页和 Tor Browser 手册里),再校验文件:
2. **验证签名。** 下载页会给出 `.asc` 文件和签名密钥的链接,这一步很重要——被篡改的 Tor Browser 是丢掉匿名性最糟糕的方式。先取得 Tor Browser 的签名密钥(指纹发布在下载页和 Tor Browser 手册里),再校验文件:
```bash
# 导入签名密钥——见下载页上的 .asc 链接
gpg --verify tor-browser-*.tar.xz.asc tor-browser-*.tar.xz
```
3. 解压并运行。Windows 和 macOS 是常规安装包;Linux 解压后运行 `./start-tor-browser.desktop`。
4. **点 Connect。** 默认设置就行。如果你所在网络有审查,选 "Configure connection" 并使用网桥(Snowflake 或 obfs4)。
5. 等左上角出现 "Connected" 和链路显示,就说明你已经在 Tor 上了。
4. **点 Connect。** 默认设置就行。如果你所在网络有审查,选 "Configure connection" 并使用网桥(Snowflake 或 obfs4)。
5. 等左上角出现 "Connected" 和链路显示,就说明你已经在 Tor 上了。
然后把上面的地址**一字不差**粘进地址栏。有两件事要知道:
- v3 地址是 **56 个字符**的 base32(`a–z`、`2–7`)加 `.onion`。没有拼写纠错,也没有搜索建议——错一个字符就是一个打不开的地址,而且不会有任何提示。
- v3 地址是 **56 个字符**的 base32(`a–z`、`2–7`)加 `.onion`。没有拼写纠错,也没有搜索建议——错一个字符就是一个打不开的地址,而且不会有任何提示。
- 16 个字符的地址(v2)完全打不开。v2 协议已在 2021 年退役。
有一点会让第一次用的人意外:全新的洋葱地址可能需要 **10 到 60 分钟**才会被网络识别。Tor 必须先发布 descriptor,并让它被哈希环接受。第一次打不开通常只是地址还太年轻,不是配置坏了。
有一点会让第一次用的人意外:全新的洋葱地址可能需要 **10 到 60 分钟**才会被网络识别。Tor 必须先发布 descriptor,并让它被哈希环接受。第一次打不开通常只是地址还太年轻,不是配置坏了。
研究怎么把它做好时,我在网上读到了很多讲 obfs4 好处的文章。最终让我的部署真正安全的东西,和那些阅读没什么关系。
研究怎么把它做好时,我在网上读到了很多讲 obfs4 好处的文章。最终让我的部署真正安全的东西,和那些阅读没什么关系。
那么,真正让一个洋葱服务安全的是什么?我设镜像时认真走了一遍全程,几个月后又回头检查了一遍。有一部分配置经受住了考验,有一部分已经悄悄坏掉,还有几件我原本对 Docker 的认知,被实测证明是错的。
那么,真正让一个洋葱服务安全的是什么?我设镜像时认真走了一遍全程,几个月后又回头检查了一遍。有一部分配置经受住了考验,有一部分已经悄悄坏掉,还有几件我原本对 Docker 的认知,被实测证明是错的。
## 三十秒搞懂 Tor 和洋葱服务
Tor 是一个匿名网络:流量经过三道中继,没有任何一道中继能同时知道你是谁、要去哪。洋葱服务则是反过来的方向——一个生活在网络*内部*的服务器。它拿到的是 `.onion` 地址而不是域名,访客只能通过 Tor 网络访问它,服务器的真实位置永远不会出现在连接里。
Tor 是一个匿名网络:流量经过三道中继,没有任何一道中继能同时知道你是谁、要去哪。洋葱服务则是反过来的方向——一个生活在网络*内部*的服务器。它拿到的是 `.onion` 地址而不是域名,访客只能通过 Tor 网络访问它,服务器的真实位置永远不会出现在连接里。
由此得到四个普通网站给不了的特性:
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用处于 CGNAT 后的私有网络来跑(现在大多数宽带都是)。
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用处于 CGNAT 后的私有网络来跑(现在大多数宽带都是)。
- **没有中间商。** 路径上没有任何 CDN、注册商或平台记录谁来过。
- **地址本身就是访问控制。** 拿到地址的人可以访问;其他人连这站存不存在都无法判断。要求更高时还能叠加客户端密钥。
- **clearnet 挂了它还在。** 域名被墙或被没收,洋葱站毫发无损,因为没有注册局参与其中。
- **clearnet 挂了它还在。** 域名被墙或被没收,洋葱站毫发无损,因为没有注册局参与其中。
洋葱托管适合这些:私密文件交接、作品集和代码仓库镜像、律师之间传草稿、数字商品交付、任何永远不该出现在搜索索引里的档案。会想要它的人,是把"没人能看出它的存在"当作特性而不是怪癖的人:律师、审计师、交付成果的自由职业者、给记者递料的线人、以及不希望自家数字商品渠道被同行扫描的商家。
洋葱托管适合这些:私密文件交接、作品集和代码仓库镜像、律师之间传草稿、数字商品交付、任何永远不该出现在搜索索引里的档案。会想要它的人,是把"没人能看出它的存在"当作特性而不是怪癖的人:律师、审计师、交付成果的自由职业者、给记者递料的线人、以及不希望自家数字商品渠道被同行扫描的商家。
诚实的代价是速度。Tor 链路比 CDN 慢,所以你不会把营销站放上去。它是给互联网的"私密那一半"用的工具,不是给公开那一半用的。
诚实的代价是速度。Tor 链路比 CDN 慢,所以你不会把营销站放上去。它是给互联网的"私密那一半"用的工具,不是给公开那一半用的。
## 真正保护源站的是三件事
只有最后一件需要花力气:
1. **协议本身。** 访客从不直连你的服务器。你的 tor 进程主动拨出、向 introduction point 注册,会合发生在 Tor 网络内部。单凭一次访问,访客拿不到你的 IP。
2. **Vanguards-lite。** Tor 0.4.7 起内置。它让 guard-discovery 攻击(攻击者反复制造 circuit 直到观察到你的 guard 中继)变得很难实施。你只要跑一个当前版本的 Tor 就白送这层防护。
3. **别让源站信息从其它渠道泄漏。** 洋葱主机暴露的现实路径不是流量分析。是你自己的机器在漏:某台 clearnet 服务被攻破,攻击者直接从硬盘读走你的洋葱密钥。公开站点镜像到洋葱是刻意的,内容关联我不担心;真正要管的是,存密钥的那台机器别同时跑着一堆开着的服务。
1. **协议本身。** 访客从不直连你的服务器。你的 tor 进程主动拨出、向 introduction point 注册,会合发生在 Tor 网络内部。单凭一次访问,访客拿不到你的 IP。
2. **Vanguards-lite。** Tor 0.4.7 起内置。它让 guard-discovery 攻击(攻击者反复制造 circuit 直到观察到你的 guard 中继)变得很难实施。你只要跑一个当前版本的 Tor 就白送这层防护。
3. **别让源站信息从其它渠道泄漏。** 洋葱主机暴露的现实路径不是流量分析。是你自己的机器在漏:某台 clearnet 服务被攻破,攻击者直接从硬盘读走你的洋葱密钥。公开站点镜像到洋葱是刻意的,内容关联我不担心;真正要管的是,存密钥的那台机器别同时跑着一堆开着的服务。
## 那些悄悄坏掉的东西
重检自己服务器时,是这些地方让我觉得花的时间值了。
重检自己服务器时,是这些地方让我觉得花的时间值了。
**应用容器有完整的互联网访问。** 我在容器里跑了一行访问公网 IP 回显服务的命令,拿回了我的家庭 IP。之前*确实*有过一条基于 iptables 的阻断,但规则已经没了。主机防火墙会被接口变动、容器管理器重启、平台升级静默清空。一条没有任何机制重放、没有任何告警的阻断,不是阻断,是迷信。这件事让我下定决心:与其过滤应用的出网流量,不如干脆把它的出网彻底移除(见下文)。
**应用容器有完整的互联网访问。** 我在容器里跑了一行访问公网 IP 回显服务的命令,拿回了宿主机的真实公网 IP。之前*确实*有过一条基于 iptables 的阻断,但规则已经没了。主机防火墙会被接口变动、容器管理器重启、平台升级静默清空。一条没有任何机制重放、没有任何告警的阻断,不是阻断,是迷信。这件事让我下定决心:与其过滤应用的出网流量,不如干脆把它的出网彻底移除(见下文)。
**主机防火墙是关的。** INPUT 和 FORWARD 策略全是 ACCEPT,同一台机器上其它服务在全部接口上监听了好几十个端口。其中任何一个被打穿,这块盘上的洋葱密钥就归攻击者了。人们愿意花几个小时做 Tor 专属加固,却跳过这一步。
**主机防火墙是关的。** INPUT 和 FORWARD 策略全是 ACCEPT,同一台机器上其它服务在全部接口上监听了好几十个端口。其中任何一个被打穿,这块盘上的洋葱密钥就归攻击者了。人们愿意花几个小时做 Tor 专属加固,却跳过这一步。
**运行中的 tor 和配置文件对不上。** 磁盘上的 torrc 写着 `SocksPort 0`;已经跑了几天进程,还在监听 127.0.0.1:9050。我改了文件,忘了重启容器。这里的实际影响很小,因为监听只在容器内部可达。但教训可以推广:你写的配置文件是愿望,进程实际在做什么才是真相。
**运行中的 tor 和配置文件对不上。** 磁盘上的 torrc 写着 `SocksPort 0`;已经跑了几天进程,还在监听 127.0.0.1:9050。我改了文件,忘了重启容器。这里的实际影响很小,因为监听只在容器内部可达。但教训可以推广:你写的配置文件是愿望,进程实际在做什么才是真相。
**Tor 落后了一个安全版本。** 对一台存放敏感密钥的机器来说太旧了,而且我的日志里带着新版已修复的中继描述符解析 bug 的刷屏签名。升级后刷屏停了。
**Tor 落后了一个安全版本。** 对一台存放敏感密钥的机器来说太旧了,而且我的日志里带着新版已修复的中继描述符解析 bug 的刷屏签名。升级后刷屏停了。
## 修好之后不再坏的修复
### 零出网,靠构造而不是靠过滤
### 零出网,靠构造而不是靠过滤
两个容器放进 `internal: true` 的 Docker 网络,只有 tor 容器多一张网卡去访问 Tor 网络。
两个容器放进 `internal: true` 的 Docker 网络,只有 tor 容器多一张网卡去访问 Tor 网络。
```yaml
networks:
@@ -93,17 +93,17 @@ networks:
external: true # 普通的带外网 bridge
```
应用现在就哪里都去不了:公网不行,局域网不行,连主机都不行——internal 网络根本没有网关。没有可被清空的 iptables,没有可被遗忘的开机任务,也没有可以静默衰退的余地。就算应用被攻破,攻击者拿到的只是一根指向 tor 的 socket,仅此而已。这是我认为唯一不可妥协的一条改动。
应用现在就哪里都去不了:公网不行,局域网不行,连主机都不行——internal 网络根本没有网关。没有可被清空的 iptables,没有可被遗忘的开机任务,也没有可以静默衰退的余地。就算应用被攻破,攻击者拿到的只是一根指向 tor 的 socket,仅此而已。这是我认为唯一不可妥协的一条改动。
### 非 root,和 capability 的意外
### 非 root,和 capability 的意外
镜像默认就是非 root 用户,而我想让应用继续监听 80 端口。标准建议是 `cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
镜像默认就是非 root 用户,而我想让应用继续监听 80 端口。标准建议是 `cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
```
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
```
这个原因让我意外到用探针容器实测了一把:`grep Cap /proc/self/status` 显示 `CapBnd` 里有 bit 10(NET_BIND_SERVICE),而 `CapEff` 是零。Docker 给非 root 容器的 capability 只进 bounding set,不进 effective set,而 bind() 检查的是 effective set。一个非 root 进程去执行没有 file capability 的二进制,拿到的 effective set 就是空的,没有例外。所以答案很无聊:内部改用非特权端口。
这个原因让我意外到用探针容器实测了一把:`grep Cap /proc/self/status` 显示 `CapBnd` 里有 bit 10(NET_BIND_SERVICE),而 `CapEff` 是零。Docker 给非 root 容器的 capability 只进 bounding set,不进 effective set,而 bind() 检查的是 effective set。一个非 root 进程去执行没有 file capability 的二进制,拿到的 effective set 就是空的,没有例外。所以答案很无聊:内部改用非特权端口。
```yaml
app:
@@ -126,35 +126,35 @@ HiddenServicePort 80 app:8080
### 检查对的东西的 healthcheck
tor 镜像自带一个探 SOCKS 端口的 healthcheck。我刚把 SOCKS 关了,于是 healthy 永远变成了 unhealthy。用你真正想问的问题覆盖它:进程还活着吗?
tor 镜像自带一个探 SOCKS 端口的 healthcheck。我刚把 SOCKS 关了,于是 healthy 永远变成了 unhealthy。用你真正想问的问题覆盖它:进程还活着吗?
```yaml
healthcheck:
test: ["CMD", "pgrep", "-x", "tor"]
```
nginx 的坑更隐蔽。我的第一版用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标成不健康。这个检查测的是内容,不是服务。`nc -z 127.0.0.1 80` 只测端口,别的什么都不测。
nginx 的坑更隐蔽。我的第一版用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标成不健康。这个检查测的是内容,不是服务。`nc -z 127.0.0.1 8080` 只测服务监听端口,别的什么都不测。
还有一件:tor 在启动时解析 `HiddenServicePort` 的目标地址。如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里看过它连续四次启动失败,没人发现,因为当时没有任何东西在看着。compose 里的 `depends_on` 修好了启动顺序。
还有一件:tor 在启动时解析 `HiddenServicePort` 的目标地址。如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里看过它连续四次启动失败,没人发现,因为当时没有任何东西在看着。compose 里的 `depends_on` 修好了启动顺序。
### 属主地雷
换完新密钥后,我通过文件共享挂载把密钥材料拷了回去。之后的容器立刻崩溃循环:
换完新密钥后,我通过文件共享挂载把密钥材料拷了回去。之后的容器立刻崩溃循环:
```
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
```
经共享写入的文件,属主是共享用户,不是 tor 运行的 uid。修复只需一条命令,在哪里都能跑,包括挂上卷的一次性容器:
经共享写入的文件,属主是共享用户,不是 tor 运行的 uid。修复只需一条命令,在哪里都能跑,包括挂上卷的一次性容器:
```bash
docker run --rm -v /path/libTor:/var/lib/tor alpine \
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
```
### 验证,而不是相信
### 验证,而不是相信
上面每个修复都有一个我自己能跑的测试收尾。我最喜欢出网这条,因为两个探针放在一起最有说服力:同一个 wget,在应用的网络里失败,在 tor 的出网网络里成功。
上面每个修复都有一个我自己能跑的测试收尾。我最喜欢出网这条,因为两个探针放在一起最有说服力:同一个 wget,在应用的网络里失败,在 tor 的出网网络里成功。
```bash
# 在应用里:internal:true 之下,连 DNS 都应该失败
@@ -164,30 +164,30 @@ wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
wget -T 6 -qO- http://ipv4.icanhazip.com # → <你的 IP>, exit 0
```
如果没法 exec 进容器,就挂一个一次性探针容器到同一个网络。它测的是网络的属性,而那正是你加固的对象。
如果没法 exec 进容器,就挂一个一次性探针容器到同一个网络。它测的是网络的属性,而那正是你加固的对象。
## 值得把它做成服务吗?
## 值得把它做成服务吗?
我一直在想这件事,因为边际成本几乎为零:tor 容器和它的隔离措施本来就在跑。
我一直在想这件事,因为边际成本几乎为零:tor 容器和它的隔离措施本来就在跑。
我大多数托管客户想要的和洋葱服务正好相反。他们想被 Google 搜到。卖一个只能在 Tor Browser 里打开的网站,等于卖给他们大概率用不上的隐秘性,还要负责教他们的访客安装 Tor Browser。
我大多数托管客户想要的和洋葱服务正好相反。他们想被 Google 搜到。卖一个只能在 Tor Browser 里打开的网站,等于卖给他们大概率用不上的隐秘性,还要负责教他们的访客安装 Tor Browser。
但确实有一小块真实市场。交换草稿的律师、审计师、交付数字商品的人,任何想分享一份永远不该出现在搜索引擎索引里的档案的人。对这类客户,销售话术自己就写好了:没有端口转发,没有域名,没有你控制不了的平台上留日志,只有一个你亲手交给该拿到的人的地址。
但确实有一小块真实市场。交换草稿的律师、审计师、交付数字商品的人,任何想分享一份永远不该出现在搜索引擎索引里的档案的人。对这类客户,销售话术自己就写好了:没有端口转发,没有域名,没有你控制不了的平台上留日志,只有一个你亲手交给该拿到的人的地址。
有个卖点比我预想的好使,它连着一个人人都会问的问题:地址开头能自己选吗?v3 洋葱地址是随机的,但那只是因为密钥是随机的。地址可以挖:不断生成密钥对,直到 base32 地址以你想要的前缀开头。每多一个字符,工作量乘以 32。社区挖矿工具在现代 GPU 上每秒能检查几百万个地址,这意味着 8 字符前缀是"一天到几天"的活,9 字符需要耐心地挖上几周,10 字符则是严肃的多 GPU 投入。一个以客户品牌开头的前缀,能把一行难记的 56 字符地址变成他们可以确认"这真的是你"的东西——在一个信任就是全部产品的细分市场里,这是实打实的价值。8 字符我乐意挖,9 字符为了付费客户可以,10 字符只有在对方自己租 GPU 的情况下我才会一脸平静地报价。
有个卖点比我预想的好使,它连着一个人人都会问的问题:地址开头能自己选吗?v3 洋葱地址是随机的,但那只是因为密钥是随机的。地址可以挖:不断生成密钥对,直到 base32 地址以你想要的前缀开头。每多一个字符,工作量乘以 32。社区挖矿工具在现代 GPU 上每秒能检查几百万个地址,这意味着 8 字符前缀是"一天到几天"的活,9 字符需要耐心地挖上几周,10 字符则是严肃的多 GPU 投入。一个以客户品牌开头的前缀,能把一行难记的 56 字符地址变成他们可以确认"这真的是你"的东西——在一个信任就是全部产品的细分市场里,这是实打实的价值。8 字符我乐意挖,9 字符为了付费客户可以,10 字符只有在对方自己租 GPU 的情况下我才会一脸平静地报价。
所以:作为给少数特定客户的附加服务,值得;作为一条产品线,不值。市场太薄,撑不起渠道,而且支持成本不会随规模摊薄。"带洋葱地址的隐私咨询",可以;"洋葱托管"作为一个网站套餐,那是别人的生意。
所以:作为给少数特定客户的附加服务,值得;作为一条产品线,不值。市场太薄,撑不起渠道,而且支持成本不会随规模摊薄。"带洋葱地址的隐私咨询",可以;"洋葱托管"作为一个网站套餐,那是别人的生意。
## 最后留下的
- **跑测试,别读配置文件。** 容器内探针和健康状态是真相,yaml 只是意图。
- **跑测试,别读配置文件。** 容器内探针和健康状态是真相,yaml 只是意图。
- **优先选择无法静默解体的机制。** `internal: true` 永远胜过 iptables 开机任务。
- **healthcheck 很便宜。** 它几分钟就抓到了一个崩溃循环,而之前没人看着,坏了好几天。
- **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果攻击者能直接从一个开着的端口走进来,就不会有人费劲用流量分析去匿名化你。
- **healthcheck 很便宜。** 它几分钟就抓到了一个崩溃循环,而之前没人看着,坏了好几天。
- **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果攻击者能直接从一个开着的端口走进来,就不会有人费劲用流量分析去匿名化你。
## 想要一个吗?
## 想要一个吗?
我帮客户做这类东西:创建洋葱站点、托管和维护(更新、健康检查、监控),以及应要求挖一个品牌前缀的地址,让地址以名字开头而不是一串乱码。如果你需要一个"只有你选的人才能访问"的网站,这套东西做的正是这个。
我帮客户做这类东西:创建洋葱站点、托管和维护(更新、健康检查、监控),以及应要求挖一个品牌前缀的地址,让地址以名字开头而不是一串乱码。如果你需要一个"只有你选的人才能访问"的网站,这套东西做的正是这个。
- WhatsApp: [wa.me/60127972969](https://wa.me/60127972969)
- 邮箱: [[email protected]](mailto:[email protected]?subject=Onion%20service%20setup)
@@ -7,6 +7,7 @@ tags: [storage, smb, verification, ffmpeg, python]
ogImage: /og/zh-it-reported-success-nothing-had-changed.jpg
banner: /banners/it-reported-success-nothing-had-changed.jpg
draft: false
dynamicCta: true
---
## 一批"看起来没问题"的任务
@@ -47,9 +48,9 @@ if hash_file(src) != hash_file(_reread(dst)):
第二个藏在一个你无条件信任的文件系统原语后面:`os.replace()`——原子的、同一文件系统内的、不可能做一半的。只不过这里的"文件系统"其实是一个自带元数据缓存的网络客户端。
当时的顺序是:上传 `show.mp4.new.mp4`、校验哈希、`os.replace()` 覆盖 `show.mp4`、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 `ok 1085 MB -> 478 MB`。
当时的顺序是:上传 `archive-04.mp4.new.mp4`、校验哈希、`os.replace()` 覆盖 `archive-04.mp4`、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 `ok 1085 MB -> 478 MB`。
共享盘上那个文件**是原封未动的原文件**:H.264、921,950,850 字节。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 `getsize()` 和 `mtime` 兴高采烈地描述着一个已经不存在的文件。
共享盘上那个文件**是原封未动的原文件**:H.264、1,137,704,960 字节(1085 MiB)。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 `getsize()` 和 `mtime` 兴高采烈地描述着一个已经不存在的文件。
唯一能在撒谎的元数据缓存下活下来的检查,是把字节读回来:
@@ -101,3 +102,11 @@ reported=30 landed=29 not-landed=0 wrong-codec=1 gone=0
```
那里的每一个数字都是**读目标读出来的**,不是信任日志得来的。
---
## 你的批处理或数据流水线是否只靠“日志没报错”来验收?
我为企业设计和加固无人值守的媒体处理、数据迁移与自动化备份流水线——用端到端校验与确定性状态机替代“盲信 API 返回值”。如果你的业务流水线跑在 NAS、跨网挂载或混合存储之上,我可以帮你把静默数据损坏变成可观测、可自愈的工程闭环。
欢迎联系 [[email protected]](mailto:[email protected]?subject=Pipeline%20verification%20and%20storage%20reliability) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以在 [hoelee.com](https://hoelee.com) 查看我的工程交付。
@@ -6,9 +6,11 @@ category: devops
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
ogImage: /og/zh-n8n-v1-to-v2-upgrade-gotchas.jpg
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.jpg
draft: false
dynamicCta: true
---
n8n 是我整个自建技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
n8n 是我自托管生产环境技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
然后我拉了 `n8nio/n8n:2.40.1` 镜像并重启容器。升级本身只花了大约九十秒。而搞清楚它*弄坏了什么*花了我剩下的一整晚——而其中几乎所有信息,n8n 在启动时就已经写在一个日志文件里了。只是我第一次读得不够仔细。
@@ -41,7 +43,7 @@ actions to update your configuration:
5000 to 1000 in a future version.
```
这一块就是你的迁移清单。我那七项坑里的六项都在里面。
这一块就是你的核心迁移清单。我遇到的七项配置与废弃警告坑里,有五项直接出自这个列表,另外两项则藏在紧随其后的启动日志行里。
## 1. 你的环境变量值现在可能过不了 schema 校验
@@ -102,6 +104,8 @@ N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<一串足够长的随机字符串>
```
(注意:开启 `external` 模式后,需要在同一编排栈里挂载配套的 `n8nio/runners` sidecar 容器并共享同一枚 token,由它接管 JS 与 Python 的隔离执行。)
**教训:** 「internal 模式已废弃」是标题,但真正的发现是:某一类 runner 可能已经*静默失效*好几个月了。要检查 `docker logs` 里的 runner 注册那行,而不是只看容器起没起来。
## 4. Task 超时默认值从 300 秒降到 60 秒
@@ -149,7 +153,7 @@ path, update your mount configuration after migration.
再读一遍最后一句:*如果你有一个卷挂载在旧路径上,请在迁移后更新你的挂载配置。* 如果你设了迁移标志却保留旧的 bind mount,你现在就有两个目录,而你的二进制数据住在容器实际指向的那一个里。把改名和挂载变更放在同一个维护窗口里做——不要一个现在、一个「以后」。
## 那个不在日志里的:我的 AI 沙盒把自己关掉了
## 那个不在废弃列表里的:我的 AI 沙盒把自己关掉了
这是跟废弃警告毫无关系的发现,也是如果我不读完整的启动序列就永远抓不到的一项:
@@ -7,6 +7,7 @@ tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
ogImage: "/og/zh-one-hostname-public-tracker-sso-dashboard.jpg"
banner: "/banners/one-hostname-public-tracker-sso-dashboard.jpg"
draft: false
dynamicCta: true
---
几乎每个统计工具都长成一个尴尬的形状:负责**收集**数据的那一半,必须让陌生人的浏览器在每次打开页面时都能访问;负责**展示**数据的那一半,必须只有我能访问。
@@ -20,11 +21,11 @@ draft: false
- **埋点必须公开。** `script.js` 和两个收集端点由每个访客的浏览器抓取。如果它们被鉴权挡住,你就什么都收不到,而且好几天都不会发现。
- **仪表盘绝不能公开。** 它显示每个访客的国家、城市、来源和页面路径。一个默认管理员登录挂在公网 URL 上,就是送给任何在证书透明度日志里翻到你子域的人。
- **没有泛解析 DNS。** `*.hoelee.com` 不解析,所以每个域名都是一条显式 DNS 记录加一张证书。第二个域名是实打实的工作量,还让我要维护的攻击面翻倍。
- **这个域名不走 Cloudflare。** 它是一条直指我家路由器的 A 记录,所以没有 WAF、没有 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠普通 nginx 头工作。
- **这个域名不走 Cloudflare。** 它是一条直指我自托管边缘网关的 A 记录,所以没有云端 WAF、没有托管 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠标准 nginx 转发头工作。
## 方案一:两个域名
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。我的博客一天只有个位数访问。我是在抄一个解决「我没有的问题」的架构,代价是第二条 DNS 记录、第二张证书、第二个会坏的地方。
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由全球 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。但对于独立技术站点或中小型商业项目,采集端请求量完全在单节点轻松承载的范围内。照搬那套架构等于在解决一个并不存在的问题,代价却是第二条 DNS 记录、第二张证书、第二个会坏的地方。
两个域名是**规模**的答案,不是**需求**。一个域名就能同时承载两者,按路径分开就行。
@@ -150,7 +151,7 @@ internal_host: http://umami:3000 # outpost 把请求代理到这个
## 说清楚取舍
- **双重登录。** 统计应用不支持 OIDC,所以 SSO 只解决了「能不能到这个路由」,应用仍会要它自己的用户名密码。每个浏览器多一次点击。真正保护数据的是应用内可以按账号开启的两步验证。
- **仪表盘的可用性等于 outpost 的可用性。** authentik 挂了,仪表盘就挂了;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。
- **仪表盘的可用性等于 outpost 的可用性。** 因为我选择把路径白名单策略收敛在 authentik 单一控制面(而不是在边缘 nginx 和隧道两套入口配置里重复维护正则分流),authentik 挂了仪表盘就会挂;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。若对采集可用性有极致要求,也可以在边缘 nginx 单独把三个公开路径直通容器。
- **我保留了一条后门。** 应用仍在局域网里发布一个端口,完全绕开 SSO。它从公网不可达(用外部主机验证过),仍然需要应用自己的凭据,但我宁可留着它,也不想因为一次 SSO 配置失误把自己锁在自己的统计之外。
## 结果
@@ -7,6 +7,8 @@ category: devops
tags: [passbolt, docker, gpg, smtp, reverse-proxy, portainer]
ogImage: /og/zh-passbolt-hang-three-failure-modes.jpg
banner: /banners/passbolt-hang-three-failure-modes.jpg
draft: false
dynamicCta: true
---
我在 Synology NAS 上用 Docker 跑 Passbolt——一个开源的团队密码管理器,前面
@@ -82,7 +84,7 @@ return [
],
'passbolt' => [
'gpg' => [
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
'serverFingerprint' => env('PASSBOLT_GPG_SERVER_KEY_FINGERPRINT', 'A3DD9B762D48722C10CF88DDB5372E46A54E1419'),
],
],
];
@@ -163,6 +165,6 @@ Passbolt 的 SSL 强制中间件会检查请求的 scheme,而在 TLS 终止型
反向代理和各种自托管技术栈,交付时会把一切整理成文档,让下一个人(或未来的你)
不用靠猜。
联系我:[[email protected]](mailto:[email protected]) 或 WhatsApp
联系我:[[email protected]](mailto:[email protected]?subject=Passbolt%20and%20Docker%20troubleshooting) 或 WhatsApp
[+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做的事情。
@@ -7,6 +7,7 @@ tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, sel
ogImage: /og/zh-patching-workbench-26-for-mariadb.jpg
banner: /banners/patching-workbench-26-for-mariadb.jpg
draft: false
dynamicCta: true
---
我用一台 Windows 机器管着四个数据库服务:两个 MySQL 8.4、一个本地安装、还有一个跑在局域网 CyberPanel 后面的 MariaDB 10.11。全新的 MySQL Workbench 26.7.0 里,三个连接都正常打开,只有一个不行。
@@ -247,7 +248,7 @@ except Exception:
## 怎么给 Program Files 下的文件打补丁
Workbench 的文件是**只读**的,而这一点以值得记录的方式坑了我两次:写入会**静默失败**。PowerShell 的 `WriteAllText` 报告成功,而磁盘上的文件毫无变化——大小一样、时间戳一样。对内容做 `.Replace()` 也报告成功却什么都没做,原因是行尾符差异。
Workbench 安装目录下的文件带有**只读(ReadOnly)**属性且受 `Program Files` 权限保护,这在打补丁时坑了我两次:如果还没清掉只读标志就直接写,会立刻撞上权限拒绝;而清掉只读之后,用 PowerShell 的 `WriteAllText` 配合字符串 `.Replace()` 却会**静默空转**——命令不报错、毫无异常,但磁盘上的文件内容纹丝不动,原因是源码文件的 CRLF 行尾符导致多行 `.Replace()` 根本没匹配上,把原字符串原封不动又写了一遍。
真正有效的做法:
@@ -297,3 +298,11 @@ class FakeSession:
- **保留旧客户端。** Workbench 8.0.x 没有这个探测,连 MariaDB 毫无怨言。用 26.x 管 MySQL 8.4、用 8.0.x 管 MariaDB,是一个完全合理的策略——也正因为如此,这次只是一个晚上的智力题,而不是被堵住的一天。
版本号是最容易比较的东西,也是最容易被比较错的东西。两个项目共用一套编号体系,并不等于它们共用一套功能集。
---
## 被数据库兼容性或生产环境疑难故障卡住了?
我负责管理与排查混合数据库环境(MySQL、MariaDB、PostgreSQL)及自托管后端基础设施——从跨引擎迁移、复制链路修复,到报错信息毫无头绪的底层故障定位。如果你的系统正在经历难以复现的数据库报错或升级阻碍,我可以介入诊断并交付可验证的修复方案。
欢迎联系 [[email protected]](mailto:[email protected]?subject=Database%20troubleshooting%20and%20migration) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以在 [hoelee.com](https://hoelee.com) 查看我的服务内容。
@@ -9,6 +9,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
dynamicCta: true
updatedDate: 2026-10-23
---
@@ -36,7 +37,7 @@ updatedDate: 2026-10-23
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
| VPS (203.0.113.10) | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 公网(它自己的 web UI) | 有,藏在 mailcow 2026-10 里 |
| CyberPanel VM | `lsphp84`(8 个站) | **8.4.25** | 公网(8 个站) | 有——8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | 未使用 / 默认回退 | 8.1 永远没有,其余有 |
@@ -73,9 +74,9 @@ done | sort -u
```
```text
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
client_wood -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
numer9393 -> /usr/local/lsws/lsphp84/bin/lsphp
app_portal -> /usr/local/lsws/lsphp84/bin/lsphp
...
```
@@ -145,12 +146,12 @@ curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
```text
hoelee.com 200 227390 (切换前后完全一致)
chaijoywood.com 200 266192 (一致)
nestz.com.my 200 222780 (一致)
truetrading.com.my 200 134535 (一致)
client-wood.com 200 266192 (一致)
client-property.com.my 200 222780 (一致)
client-trading.com.my 200 134535 (一致)
test.hoelee.com 200 80869 (一致)
sifumail.com 200 44861 (一致)
numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
client-mail.com 200 44861 (一致)
app.client-portal.com 200 142762 (后来迁到 8.5.12,见下文)
```
我还会在每个响应里 grep `Fatal error` / `Uncaught` / `Deprecated:`,并请求 `/wp-json/`——那里返回 200 说明 WordPress 真的启动、查库、序列化了一份响应,比一个可能被缓存的首页渲染强得多。全部零错误。(WordPress 6.9 与 7.x 官方声明完全支持 PHP 8.5,残余风险在插件不在核心。我的站每个装了 19–44 个插件,这正是字节数校验值得做的原因。)
@@ -163,7 +164,7 @@ numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
/usr/local/lsws/conf/vhosts/client-trading.com.my/vhost.conf:80
```
我把它当噪音读了好几周。而它本身就是全部诊断。删掉那两个括号、重载之后,同样三个 URL 变成了 **403**,配置测试从两条错误变成零。
@@ -180,7 +181,7 @@ PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
```
麻烦的是访问权。那台机器的 SSH 只认一个我手上没有的密码,管理界面要登录,而能装 `.spk` 的只有宿主机上的 `synopkg`。我手里有的是这台 NAS 的 Docker API。于是让包管理器在一个特权容器里跑,把宿主根目录挂进去,用 `chroot` 调用它:
麻烦的是批量执行通道。为了平时收敛攻击面,那台 NAS 没开常规 SSH 密钥直连,而在 DSM 网页界面手动点三次 `.spk` 上传又慢又无法脚本化;当时我手头自动化巡检端现成打通的是这台 NAS 的 Docker API。于是我直接通过 API 起了一个一次性特权维护容器,把宿主根目录挂进去,用 `chroot` 调宿主的 `synopkg` 完成静默安装:
```python
HostConfig = {
@@ -224,7 +225,7 @@ tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月
## 我说不能动的那个应用
我原本把 `numerology.digikedai.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
我原本把 `app.client-portal.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
这次切换还牵出第二个跟 CVE 毫无关系的运行时。应用的三条 cron 调的是 `/usr/bin/php`——发行版自带的 PHP 8.3.6 CLI,没有 `ext-intl`——而站点本身走的是 LiteSpeed 的构建。失败又安静又彻底:`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`,自动出件和发信队列两个 worker 日志里各有 429 行,每一次定时任务都在干活之前就死了。把三条 crontab 指到 `/usr/local/lsws/lsphp85/bin/php`(也就是站点自己用的那个二进制)之后才结束。
@@ -10,6 +10,7 @@ tags: ["nginx", "docker", "self-hosting", "seo", "branding"]
ogImage: "/og/zh-putting-a-front-door-on-an-app-you-cant-modify.jpg"
banner: "/banners/putting-a-front-door-on-an-app-you-cant-modify.jpg"
draft: false
dynamicCta: true
---
我的阅读服务,访客打开看到的第一样东西是一个中文登录框。没有说明这个站是什么,没有邀请码说明,没有品牌,链接分享出去也没有社交卡片,搜索引擎也没有任何值得收录的东西。就是一个登录弹窗,上面一首诗。
@@ -87,6 +88,8 @@ location / { # everything else: the app, untouched
**不同路由用不同缓存时长。** 首页是 `no-store`(我直接在磁盘上编辑它,希望刷新即生效),社交图缓存一天,注入脚本缓存五分钟。各一行。
**精确区分 `/` 与 `/index.html`。** `location = /` 里特意写了 `try_files /index.html =404` 而不是依赖默认的 `index` 指令——因为 nginx 的 `index` 会触发一次到 `/index.html` 的内部重定向,那会直接跳进后面代理应用外壳的 `location = /index.html` 块。用 `try_files` 在当前块直接读出本地门面页,而门面页上的「进入」按钮则显式跳转到 `/index.html` 触发上游代理。
## 页面本身
它是一个自包含的 HTML 文件:没有网络字体、没有 CDN、没有构建步骤。这不是为了极简而极简——它意味着这扇前门没有任何第三方依赖会变慢、被墙或停止服务,而且断网时它还能从缓存里渲染出来。
@@ -113,6 +116,9 @@ location = /index.html {
sub_filter '</head>' '<script>(function(){try{if(!navigator.onLine)return;
if(sessionStorage.getItem("gatePass")==="1"){sessionStorage.removeItem("gatePass");return;}
location.replace("/"+(location.hash||""));}catch(e){}})();</script></head>';
# 3. inject the client-side English UI translation layer before closing body
sub_filter '</body>' '<script src="/en-mode.js" defer></script></body>';
}
```
@@ -9,9 +9,10 @@ tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
ogImage: /og/zh-read-only-nocodb-dashboard-for-a-remote-database.jpg
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.jpg
draft: false
dynamicCta: true
---
我的报告应用把每一条留资、每一张订单都写进一台小 VM 上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
我的报告应用把每一条留资、每一张订单都写进一台独立虚拟机上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
最直觉的答案是做一个表格风格的后台界面。最糟的答案是把数据复制一份。这篇写的是我最后落地的配方:**让 NocoDB 直接指向真库,用的账号只能 `SELECT`** —— 外加三个吃掉我半下午的坑,其中一个是 Docker 网络事实,在任何「容器访问局域网」的场景里都会咬人,不只是这一次。
@@ -125,7 +126,7 @@ curl -s -H "xc-token: $TOKEN" \
**`DATETIME` 列会带上 UTC 标签。** 本地时间 01:54 写入的行,读回来是 `2026-09-27 01:54:16+00:00`。值是对的,时区后缀只是展示层的事。拿来浏览没问题,拿它做跨时区换算就错了 —— 别在它上面搭逻辑。
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。虽然 NocoDB 的 UI 提供了数据源的 Schema Read-Only 开关,但在数据库层做纯 `GRANT SELECT` 构成了真正的纵深防御:即便 UI 误操作放开写入,存储引擎也会物理拦截。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
## 四个值得顺手拿走的小坑
@@ -151,7 +152,7 @@ curl -s -H "xc-token: $TOKEN" \
---
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在 NAS 或小 VM 上。*
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在私有化服务器或独立虚拟机上。*
*需要给你的生意搭一套?[WhatsApp +60 12-797 2969](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
@@ -9,9 +9,10 @@ tags: [windows, security, rdp, ipban, rdpguard, brute-force, firewall, self-host
ogImage: /og/zh-replacing-rdpguard-with-ipban.jpg
banner: /banners/replacing-rdpguard-with-ipban.jpg
draft: false
dynamicCta: true
---
只要把一台开了远程桌面的 Windows 机器放到公网上,你就知道接下来会发生什么:几分钟内,日志里就塞满了来自世界各地租用服务器的登录失败记录。你必须有东西自动把它们挡掉。
只要把一台开了远程桌面的 Windows 机器放到公网上(无论是历史遗留场景,还是在收进内网 VPN 之前的过渡期),你就知道接下来会发生什么:几分钟内,日志里就塞满了来自世界各地租用服务器的登录失败记录。你必须有东西自动把它们挡掉。
我用了好几年 **RDPGuard 7.8.7**。它是能用的,但它是付费闭源的,而且我已经落后了三个大版本。所以我换成了 **IPBan**——MIT 许可、从 2011 年维护至今、GitHub 上大约 2200 星,确实是目前最接近即插即用的替代品。
@@ -73,7 +74,7 @@ IPBan 几秒内就读到了,自己把文件删掉,然后重写了它自己
5.181.86.60:add, 61.216.137.152:add
```
这些封禁记录也值得读一读,而不是直接删。12 个里有 2 个试用的用户名是 `HOELEE01` 和 `HOELEE1`——正好是机器主人的账号名,被猜出来了。另一个把 `Administrator` → `Administrador` → `Admin` 轮了一遍。这是针对具体个人的侦察。如果先让卸载程序跑,这类细节你根本不会知道自己丢了。
这些封禁记录也值得读一读,而不是直接删。12 个里有 2 个试用的用户名是 `HOELEE01` 和 `HOELEE1`——虽然这台机器上并没有叫这个名字的本地账户,但它直接借用了我公开域名里的品牌标识。另一个把 `Administrator` → `Administrador` → `Admin` 轮了一遍。这是结合了公开信息抓取的定向侦察,而不只是盲跑通用字典。如果先让卸载程序跑,这类情报你根本不会知道自己丢了。
## 坑二:`--install-service` 根本不存在
@@ -102,8 +103,9 @@ Commands:
答案是 `sc.exe`——项目自己的卸载脚本就暗示了这一点(`sc.exe stop IPBAN` / `sc.exe delete IPBAN`):
```
sc.exe create IPBAN type= own start= auto binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan"
```cmd
sc.exe create IPBAN type= own start= auto ^
binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan Service"
```
**`sc.exe` 里的空格是承重的。** `type= own`、`start= auto`、`binPath= "..."` 每一处的 `=` 后面都**必须有一个空格**。省略了,`sc` 会把参数当成一整个 token 解析然后报错。这是个真实的陷阱:它看起来像打错字,所以看起来也可以不打。
@@ -135,7 +137,7 @@ Service Start Type: auto start
Service Account: LocalSystem
```
一个小小的瑕疵:因为 `sc create` 没给 `DisplayName=`,服务的显示名就是干巴巴的 `IPBAN`,而不是一个友好的名字。无害,但在 `services.msc` 里看起来像没装完。
一个顺手替你省掉的小瑕疵:我第一次执行 `sc.exe create` 时没传 `DisplayName=`,结果服务名和显示名都成了干巴巴的 `IPBAN`,在 `services.msc` 里看着像没装完;上面的命令里我已经把 `DisplayName= "IPBan Service"` 补上了。
## 坑三:`ExpireTime` 不是封禁时长
@@ -202,7 +204,7 @@ IPBan 把配置拆成两份:
## 常用命令
```bash
```powershell
# 实时看日志
Get-Content C:\app\IPBan\logfile.txt -Wait -Tail 20
@@ -39,9 +39,9 @@ Hermes 提供了三种迁移 agent 的方式,它们回答的是不同的问题
| 整个 agent、带版本、可拉取更新 | **Profile distribution**(git 仓库 + `distribution.yaml`) | `hermes profile install` / `update` |
| 一次性迁移 / 备份 | 导出文件(`.tar.gz`) | `hermes profile export` / `import` |
对于"同一 agent 在两台机器上",**profile distribution** 才是持久的答案。它把整个 agent——SOUL、配置、技能、定时任务——打包成一个 git 仓库,另一台机器用一条命令就能拉取更新。
对于「同一 agent 在两台机器上」,**profile distribution** 才是持久的答案。它把整个 agent——SOUL、配置、技能、定时任务——打包成一个 git 仓库,另一台机器用一条命令就能拉取更新。
我所有东西本来就用 Gitea(git.hoelee.com),所以在那里放一个 distribution 仓库是顺理成章的。没有新工具、没有新账号,也符合我每个项目都遵循的"Gitea 优先"惯例。
我所有东西本来就用 Gitea(git.hoelee.com),所以在那里放一个 distribution 仓库是顺理成章的。没有新工具、没有新账号,也符合我每个项目都遵循的「Gitea 优先」惯例。
## 我差点踩进去的坑
@@ -49,24 +49,24 @@ distribution 安装器有一个安全特性,*听起来*像限制,却恰恰
这不是麻烦。第二台电脑本来就有自己的 agent 和项目相关的技能。如果我把它*覆盖*安装上去——或者用了会覆盖现有 profile 的 `--force`——我就会删掉那台机器本地的成果。`default` 拒绝机制就是那道护栏,逼着你在现有 agent *旁边*安装,然后再合并。
而这正是整个练习真正的教训:**合并才是产品本身。** 同步不是"复制文件",而是调和两个都以为自己是真相来源的 agent。
而这正是整个练习真正的教训:**合并才是产品本身。** 同步不是「复制文件」,而是调和两个都以为自己是真相来源的 agent。
## 那个差点让我翻车的目录事实
这里是微妙的部分,也是那种"看起来没事、直到出事"的坑。
这里是微妙的部分,也是那种「看起来没事、直到出事」的坑。
distribution 仓库里放的是技能树的**副本**——它不是*活跃目录*。活跃技能在 `~/AppData/Local/hermes/skills/`(agent 真正写入的地方);仓库副本只是我推送的一份快照。它们是两棵会漂移的、互相独立的树。
distribution 仓库里放的是技能树的**副本**——它不是*活跃目录*。活跃技能在 `~/.hermes/skills/`(agent 真正写入的地方);仓库副本只是我推送的一份快照。它们是两棵会漂移的、互相独立的树。
所以一次同步实际上涉及**三方**,而不是两方:
```
活跃技能目录 ←→ 仓库副本(D:\dev\hoelee-agent-dist\skills) ←→ Gitea 远端
活跃技能目录 ←→ 仓库副本(~/workspace/agent-dist/skills) ←→ Gitea 远端
```
我在上次推送之后新建的一个技能,*只*存在于活跃目录里。如果我假设仓库副本是权威、并"从它开始同步",就会静默删掉那个技能。所以每次同步前我做的第一件事,就是比对两棵树:
我在上次推送之后新建的一个技能,*只*存在于活跃目录里。如果我假设仓库副本是权威、并「从它开始同步」,就会静默删掉那个技能。所以每次同步前我做的第一件事,就是比对两棵树:
```bash
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
```
漂移是*正常的*,不是错误。整套纪律在于搞清楚三方里谁领先,然后有意识地调和。
@@ -75,16 +75,16 @@ diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
这是最关键的决定,而它归结为冲突策略。我想要双向同步——两台机器都会编辑技能——而当两边编辑了*同一个*技能时,我想**两边都保留、由我自己来挑**,而不是让工具静默丢掉其中一个。
这种策略本质上是交互式的。一个自动拉取的 cron 要么覆盖成果,要么在我没盯着的时候触发。所以我让触发变成手动:我说一句*"同步技能"*,agent 就去执行,并在**任何冲突时停下来问我**。这不是偷懒——而是合并的决定权在我手里,自动化做不了这个决定。
这种策略本质上是交互式的。一个自动拉取的 cron 要么覆盖成果,要么在我没盯着的时候触发。所以我让触发变成手动:我说一句*「同步技能」*,agent 就去执行,并在**任何冲突时停下来问我**。这不是偷懒——而是合并的决定权在我手里,自动化做不了这个决定。
我记录下来的(也是现在真正在跑的)流程是:
```bash
# 1. 把远端拉进仓库副本,只快进——绝不 force
cd D:\dev\hoelee-agent-dist && git pull --ff-only origin main
cd ~/workspace/agent-dist && git pull --ff-only origin main
# 2. 调和活跃目录 ↔ 仓库副本
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
# 3. 只暂存真正的技能内容(绝不包含 curator 垃圾)
# 4. 提交 + 推送,然后验证远端确实有了
@@ -112,13 +112,13 @@ skills/.bundled_manifest
## 我会做得不一样的地方
- **先想清楚触发方式,再选机制。** 我花了不少功夫纠结 distribution 还是 export,却还没回答"我要手动还是自动"——而这个答案反过来会*改变*机制。如果我一开始就知道要的是手动的、冲突感知的同步,第一天就会照这个方式去设计脚本。
- **把"副本 vs 活跃目录"的区分当作一条有名字的不变量。** 我是靠 diff 才发现的;它本该从第一次 `cp -r` 起就写进我的笔记里。
- **先想清楚触发方式,再选机制。** 我花了不少功夫纠结 distribution 还是 export,却还没回答「我要手动还是自动」——而这个答案反过来会*改变*机制。如果我一开始就知道要的是手动的、冲突感知的同步,第一天就会照这个方式去设计脚本。
- **把「副本 vs 活跃目录」的区分当作一条有名字的不变量。** 我是靠 diff 才发现的;它本该从第一次 `cp -r` 起就写进我的笔记里。
- **提前决定每台机器的 `user_id`。** 技能走 git 同步,但情节记忆(mem0)是另一个后端——两台机器要么共享一个记忆桶、要么各留各的,这是一个应该早点明确的单行配置选择。
## 结果
现在我有一个 git 仓库——`git.hoelee.com/hoelee/hoelee-agent-dist`——装着 172 项技能、我的配置、SOUL 和定时任务。另一台机器把它装进一个命名 profile,再把自己本地的技能合并进去,我们靠一个"每次冲突都问我"的手动触发器保持同步。第一次真正的同步推送了一个只存在于活跃机器上的技能,验证它落地了(两边都是 `edbf7ac`),并且没有动任何现有的技能。
现在我有一个 git 仓库——`git.hoelee.com/ops/agent-dist`——装着 172 项技能、我的配置、SOUL 和定时任务。另一台机器把它装进一个命名 profile,再把自己本地的技能合并进去,我靠一个「每次冲突都问我」的手动触发器保持同步。第一次真正的同步推送了一个只存在于活跃机器上的技能,验证它落地了(两边都是 `edbf7ac`),并且没有动任何现有的技能。
给任何想同步 agent 的人的一句实在话:*文件*是简单的——git 干了大部分重活。难的是那些护栏:那个你绝不能跑的 `--force`,那个你绝不能误当成真相来源的副本,还有那个你绝不能让工具替你静默解决的冲突。
@@ -134,11 +134,11 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
### 核心 3:对抗式代码审查员(Adversarial Reviewer)
当一个开发者(无论是人类还是 AI)为了调试某个复杂逻辑连续折腾了 20 轮对话后,他必然会产生严重的认知盲区:因为他清楚自己为此付出了多少心血,他会本能地倾向于认为自己的补丁是正确的。
当一个开发者(无论是人类还是 AI)为了调试某个复杂逻辑连续迭代了 20 轮对话后,他必然会产生严重的认知盲区:因为他清楚自己为此付出了多少心血,他会本能地倾向于认为自己的补丁是正确的。
当主写 Agent 验证本地测试全部通过后,它会将生成的 `git diff` 提交给**对抗式审查员**。
审查员调用顶层推理模型(如 Gemini 4 Argon 或 Claude 3.5 Sonnet),且拥有**绝对空白纯净的上下文**。它只接收两样东西:
审查员调用顶层推理模型(如 Claude 3.5 Sonnet 或顶级思维链推理模型),且拥有**绝对空白纯净的上下文**。它只接收两样东西:
1. 原始任务目标。
2. 统一的标准 Git 补丁(Diff)。
@@ -154,7 +154,7 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
## 生产实测指标对比
我们在相同的全栈功能开发任务下,针对传统单 Agent、蜂群多 Agent 以及本套架构进行了实测对比:
我们在约 50,000 行代码的 TypeScript/Python 生产代码库中,针对 5 项全栈功能开发任务进行了实测对比(取单任务平均值):
| 评估指标 | 自由多 Agent 蜂群 | 传统单一 Agent | 单写多审 + 图谱索引架构 |
|---|---|---|---|
@@ -179,7 +179,7 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
## 想为您的业务落地这套高可靠 AI 自动化方案?
我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统,帮助业务摆脱繁琐的人工重复劳动与失控的 AI 玩具演示。
我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统,帮助业务摆脱繁琐的人工重复劳动与脆弱的实验室级 AI 概念验证(PoC)。
无论您是希望将日常软件开发自动化、构建坚如磐石的企业级 Agent 协同流水线,还是定制自托管自动化服务:
@@ -43,7 +43,7 @@ draft: false
如果没有现成的网络 API,第二优先级是寻找命令行工具。代码修改、文件处理、日志分析或视频压制,完全可以通过 `terminal`、`git`、`ffmpeg` 或本地脚本静默执行。模型无需移动鼠标,不仅速度快出几十倍,而且不会因界面卡顿而中断。
### 第三梯队:后台无感无障碍驱动(CUA)
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如我们生产 Agent 运行时 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
- 指令通过系统底层辅助功能框架(Windows UIA / macOS AX)直接派发给目标控件。
- **真人的物理鼠标光标完全不会被移动。**
- 窗口不需要前置聚焦。你可以在前台敲代码或写邮件,而 Agent 在后台最小化或未激活的窗口里完成点击。
@@ -31,7 +31,7 @@ draft: false
## 2. 架构设计与工程取舍
在设计这套系统时,我结合现有的家庭基础设施,确立了四项核心原则:
在设计这套系统时,我结合我管理的私有基础设施(自托管 GPU 算力节点),确立了四项核心原则:
```
[ 本地收集视频 / 收件箱 ]
@@ -44,7 +44,7 @@ draft: false
▼
[ 严格解耦的数据层 ]
├── src/content/videos/{slug}.md (元数据 + AI 结构化精读总结)
└── src/data/transcripts/{slug}.json (毫秒级分句逐字稿 [start, end, text])
└── src/data/transcripts/{slug}.json (亚秒级分句时间轴 [start, end, text])
│
▼
[ Astro 5 纯静态门户 ] ────► Pagefind 客户端 WASM 离线检索 (<20ms 响应)
@@ -61,8 +61,8 @@ draft: false
### 本地算力闭环,零云端转写成本
不用云端按分钟计费的语音转写 API,流水线直接运行在宿主机的 NVIDIA RTX 3060 显卡上。通过 `faster-whisper` 与 `Silero-VAD` 结合,自动切除无声段,并按标点符号聚合成 3 到 8 秒的自然语义分句。
### 严禁家庭 NAS 直出公网视频流
用自建 NAS 直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,宽带的上行带宽会被瞬间打满,甚至触发运营商对于 PCDN 的封禁。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
### 严禁源站存储节点直出公网视频流
用自建源站存储节点直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,源站节点的上行带宽会被瞬间打满,甚至触发运营商对于异常流量的管控限制。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
### 基于 Pagefind 的毫秒级离线搜索
前台不维护重型的 Meilisearch 或 Elasticsearch 数据库,直接编译为 Astro 静态站点并利用 Pagefind 构建分片索引。读者在浏览器端搜索时,通过 WebAssembly 按需拉取几十 KB 的分片,零服务器内存常驻,中文关键词检索延迟在 20ms 以内。
@@ -85,6 +85,6 @@ draft: false
如果你的企业或团队也积累了大量内部培训录屏、会议回放或知识资产,需要将其转化为可检索、支持时间戳精读的企业内部知识库:
- **WhatsApp 直接沟通**:[点击打开 WhatsApp (012-7972969)](https://wa.me/60127972969)
- **WhatsApp 直接沟通**:[点击打开 WhatsApp (+60 12-797 2969)](https://wa.me/60127972969)
- **电子邮箱**:[[email protected]](mailto:[email protected]?subject=关于视频知识库与内训检索系统的合作咨询)
- **官方主页**:[hoelee.com](https://hoelee.com)
@@ -14,7 +14,7 @@ banner: /banners/why-your-headless-browser-cant-scrape-everything.jpg
在 **Carousell** 上,一个无头浏览器把新上架的商品连价格、成色、图片链接一起
抓进数据库——毫无问题,稳定跑了几个月。在**闲鱼**上,一模一样的设置,每次都
只返回同样三个词:
只返回同一句拦截提示:
```
为了保障您的体验,请使用正常浏览器访问闲鱼~
@@ -123,7 +123,7 @@ JSON 烤进了 HTML(类似 `__PRELOADED_STATE__` 这种结构)。JavaScript
这组对照沉淀成了一条我现在每个项目都会用的规则:**在选工具之前,先判断目标是
服务端渲染还是 API 门槛。** 在 Carousell(服务端渲染)上,一个普通的 HTTP 解析器
几个月来归档了几千条带价格和图片的商品。在闲鱼(签名 API)上,我诚实地评估了
工作量和维护成本之后选择了停下——对一个偶尔手动搜一下就够用的需求来说,这本身
工作量和维护成本之后选择了停下——对于低频抽检即可满足的业务需求来说,这本身
就是正确的结局。
无头浏览器**有时候**是对的工具——截图、转 PDF、接管一个人工会话,或者目标本身