Files
hoelee-blog/docs/consultant-review/batches/batch-04.md
T
hoelee 86edd40c74 docs(review): consultant audit tracker, 10 post batches and the posts/case-studies upgrade plan
- docs/consultant-review/: review brief, per-batch post dumps (01-10),
  audit-tracker.json + AUDIT-TRACKER.md generated by scripts/manage_review.py
- scripts/manage_review.py: list/approve/approve-batch/generate over the tracker
- docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md: locked
  decision register + 4 work orders for the /posts/ archive rework and the
  case-studies portfolio treatment
2026-10-27 03:28:58 +08:00

4462 lines
238 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 博客文章顾问审阅资料包 — Batch-04
> **批次编号**:`Batch-04`
> **包含文章数**:9 篇
> **本批次总字符量**:179,512 字(严格限制 < 200,000 字)
> **模式**:BILINGUAL
> **生成时间**:2026-10-27 03:02:03
# 外部顾问审阅前置须知与工程背景规范
> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。
## 1. 站点定位与双重业务目的
- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。
- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。
## 2. 核心人设铁律与工程硬约束(必须严格遵守)
1. **彻底杜绝业余爱好者/玩具口吻**:
- ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。
- ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。
2. **严禁捏造虚假指标与性能数字**:
- 所有 benchmark 数据必须有明确的测试基准与单设备限制。
- 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。
- 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。
3. **去模板化叙事与真实物理锚点**:
- 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。
- 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。
4. **代码与配置展示规范**:
- 超过 20 行的配置或日志采用 HTML `<details><summary>` 原生折叠,`<summary>` 必须精准标注文档名与行数。
## 3. 顾问重点开刀维度
- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大?
- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻?
- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)?
- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置?
---
## 本批次文章目录索引
1. [devops] **它报告了成功,可实际上什么都没变** (`it-reported-success-nothing-had-changed`) — ~11,134 字符
2. [devops] **n8n v1 升级到 v2:一个日志文件里的七项废弃警告** (`n8n-v1-to-v2-upgrade-gotchas`) — ~18,432 字符
3. [devops] **公开的埋点与要 SSO 的仪表盘,怎么共用一个域名** (`one-hostname-public-tracker-sso-dashboard`) — ~20,675 字符
4. [devops] **Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式** (`passbolt-hang-three-failure-modes`) — ~11,803 字符
5. [devops] **当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26** (`patching-workbench-26-for-mariadb`) — ~24,124 字符
6. [devops] **PHP2Shell:我管的每台机器,PHP 运行时全都中了** (`php2shell-patching-every-php-runtime-i-manage`) — ~34,738 字符
7. [devops] **给自己改不了源码的应用装一扇前门** (`putting-a-front-door-on-an-app-you-cant-modify`) — ~20,292 字符
8. [devops] **给另一台机器上的数据库配一个 NocoDB 只读看板** (`read-only-nocodb-dashboard-for-a-remote-database`) — ~17,889 字符
9. [devops] **用 IPBan 替换 RDPGuard:没人写出来的三个坑** (`replacing-rdpguard-with-ipban`) — ~20,425 字符
---
## 文章 01 / 09:它报告了成功,可实际上什么都没变
- **Slug**: `it-reported-success-nothing-had-changed`
- **英文标题**: It Reported Success. Nothing Had Changed.
- **分类**: `devops` | **发布日期**: 2026-10-08
- **线上地址**: https://blog.hoelee.com/posts/it-reported-success-nothing-had-changed/ (EN) | https://blog.hoelee.com/posts/zh/it-reported-success-nothing-had-changed/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "它报告了成功,可实际上什么都没变"
description: "网络挂载盘对我的转换流水线撒的三种谎:一次 403 MB 的写入悄悄停在 12.5 MB、一次从未真正落地的重命名(元数据却言之凿凿)、以及一个和坏拷贝达成一致的字节数检查。"
pubDate: 2026-10-08
category: devops
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
---
## 一批"看起来没问题"的任务
我跑着一条转换流水线,处理一个 582 个文件的视频库。库在 NAS 上,通过第三方 SMB 客户端挂载成 Windows 盘符(就是那种把共享目录呈现成普通 `Z:\` 盘的东西)。文件有大有小,只有一个写入者。
流水线做的都是些显而易见的事:把源文件暂存到本地、编码、验证输出、上传到原文件旁边、替换原文件。我想到要检查的每一步,它都记了成功。
然后我开始审计**结果**,而不是审计日志。在 30 个被报告为"已完成"的文件里,**有一个根本没有变**——共享盘上还是原文件。没有报错、没有警告、没有失败步骤。日志写着 `ok`。
这就是本文要讲的失败模式:不是崩溃,不是文件损坏,而是一个**报告了它并未达成之结果的系统**。如果你的流水线用"自己的日志"来定义成功,那你对自己的磁盘上到底有什么,一无所知。
## 谎言一:写入提前停止,却返回成功
第一个最吓人,因为在调用的那一刻一切看起来都对。
```text
FAIL read-back mismatch
local = 3b7cd25d1de1 403,448,100 bytes
remote = 6a7785351031 12,582,912 bytes
```
一次 403 MB 输出文件的上传写入了 **12.5 MB** 就返回了。没有异常、没有非零退出码、没有短写返回码。客户端自己的错误——一个超时提示——是**几分钟后异步弹出**的,那时我的代码早就往下走了,成功日志都打完了。
这就是为什么在这类存储上,一个"信任 API 返回"的写入成功检查一文不值。写入调用回答的是另一个问题("我把数据交给缓存了吗?"),而不是你问的那个("对面那个文件对吗?")。
修法很无聊但很彻底:把你本地写出的东西做哈希,再把共享盘上的文件读回来做哈希,然后比对。不一致就重传——必要时两次,带退避。
```python
# 把 src 上传为 dst 之后
if hash_file(src) != hash_file(_reread(dst)):
retry_upload()
```
注意这个代价是故意的:它要再从网络上把文件读一遍。对一个 400 MB 的文件来说是几秒——相对一次 25 分钟的编码属于舍入误差。而换来的是:不必接受一个"有某个比例的文件悄悄不是你以为的样子"的库。
## 谎言二:重命名没有落地,元数据却说落地了
第二个藏在一个你无条件信任的文件系统原语后面:`os.replace()`——原子的、同一文件系统内的、不可能做一半的。只不过这里的"文件系统"其实是一个自带元数据缓存的网络客户端。
当时的顺序是:上传 `show.mp4.new.mp4`、校验哈希、`os.replace()` 覆盖 `show.mp4`、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 `ok 1085 MB -> 478 MB`。
共享盘上那个文件**是原封未动的原文件**:H.264、921,950,850 字节。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 `getsize()` 和 `mtime` 兴高采烈地描述着一个已经不存在的文件。
唯一能在撒谎的元数据缓存下活下来的检查,是把字节读回来:
```python
os.replace(tmp, dst) # 声称
if hash_file(dst) != hash_file(local): # 证据
raise VerificationError(dst)
```
加上这一步之后,同一次审计在原本 30 个里找出 1 个失败的规模上,找出了 **0 个**——更重要的是,今后任何静默失败都会变成响亮的失败,因为"目标处的字节和我产出的字节不一致"不是缓存能造假的东西。
## 谎言三:一个和坏拷贝达成一致的字节数检查
第三个最隐蔽,因为它骗过的是**验证本身**,而不只是操作。
把源文件从共享盘暂存到本地临时目录是一次拷贝。自然的检查是"字节数对不对?"——所以流水线比了字节数。它们一致。
然后在一次无关的排查里,同一个源文件解码失败了(`Error splitting the input into NAL units`),我就去找一个并不存在的坏文件。那个文件是好的:921,950,850 字节、头部健康、前 60 秒零错误解码。是那份**拷贝**被以某种保持长度不变的方式截断了,或者那次读取悄悄读短了、又被补齐了。
长度不是校验和。一次拷贝只有在"你读到的内容的哈希"与"共享盘上那份的哈希"一致时才算被验证——这要么意味着读两遍,要么意味着读完后算哈希、再和另一时刻取的哈希比对。
## 让事情更糟的那些度量陷阱
追这三个问题的过程中,我的**质量检查**也在撒谎,值得点名,因为它们并不只属于网络存储:
- **解码测试会在错误的画面上通过。** `ffmpeg -v error -i out.mp4 -f null -` 在一个带着 106 帧错误时间戳画面的文件上什么都没打印。"没有报错"只意味着"没有报错",不意味着"正确"。
- **SSIM 在平坦帧上没有意义。** 两帧几乎全黑也能算出 0.25,而它们在视觉上完全相同。任何"单帧过低即判缺陷"的阈值都会拒绝好编码;任何"整体忽略这些帧"的做法又会漏掉真缺陷。
- **对 open GOP 的源做输入 seek,会解出错误的画面。** 把一个文件和**它自己**比对——一侧从中间 seek 进去解码、另一侧顺序解码——测出最低 SSIM 0.25。一个完全由我自己的比对工具制造的"系统性缺陷"假象。
- **流拷贝不是无损切片。** 用 `-ss ... -c copy` 从带 B 帧的 H.264 文件里切出 60 秒,本该 1500 帧却只得到 1270 帧。拿来做拖拽预览可以;拿来做测试夹具是致命的。
- **两个并发任务共用临时目录,会互相覆盖中间产物。** 我的解码裸帧文件用了固定文件名,于是两个同时运行的闸门比的是两个不同的视频,报出"100% 的帧低于阈值"。现在每一条临时路径都带上进程 id。
## 纪律
这些都不需要什么聪明办法。它需要一条规则:**一个步骤算完成,是在你独立观察到了结果之后,而不是在工具报告了结果之时。**
落到一条会往共享盘写东西的流水线上:
1. **源文件读两遍。** 暂存、哈希、再抓一次共享盘的读数做哈希、比对。长度不是证据。
2. **替换任何东西之前,先给上传做哈希。**
3. **替换之后,给目标做哈希。** 缓存的元数据造不了假字节。
4. **验证内容,而不只是验证容器**——比画面,不比帧率。
5. **让每一次失败都响亮。** 一个失败得安全的闸门,代价是白烧一次编码;一个放行了坏文件的闸门,代价是整个库和你对它的信任。
6. **不要把写者并行化。** 三个任务共用一块共享盘和一个临时目录,制造出了短写、超时和互相污染的比较。当我降到单个写者,错误率归零。
我最在意的审计行不是"0 errors",而是:
```text
reported=30 landed=29 not-landed=0 wrong-codec=1 gone=0
```
那里的每一个数字都是**读目标读出来的**,不是信任日志得来的。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "It Reported Success. Nothing Had Changed."
description: "Three ways a network-mounted drive lied to my conversion pipeline: a write that silently stopped at 12.5 MB of 403 MB, a rename that never landed while the metadata said otherwise, and a size check that agreed with a corrupted copy."
pubDate: 2026-10-08
category: devops
tags: [storage, smb, verification, ffmpeg, python]
ogImage: /og/it-reported-success-nothing-had-changed.jpg
banner: /banners/it-reported-success-nothing-had-changed.jpg
draft: false
---
## The batch that looked fine
I run a conversion pipeline over a 582-file video library that lives on a
NAS and is mounted as a Windows drive letter through a third-party SMB
client (the kind that presents the share as a normal `Z:\` drive). Small
files, big media files, one writer.
The pipeline does the obvious things: stage the source locally, encode,
verify the output, upload it next to the original, replace the original. It
logged success on every step I had thought to check.
Then I started auditing the *results* instead of the log. Over a set of 30
files the pipeline had reported as done, **one had not changed at all** —
the file on the share was still the original. No error, no warning, no
failed step. The log said `ok`.
That is the failure mode this post is about: not a crash, not a corrupted
file, but a system that reports an outcome it did not achieve. If your
pipeline's success is defined by its own log, you have no idea what is
actually on disk.
## Lie 1: a write that stops early and returns success
The first one is the scariest because everything looks right at the moment
of the call.
```text
FAIL read-back mismatch
local = 3b7cd25d1de1 403,448,100 bytes
remote = 6a7785351031 12,582,912 bytes
```
The upload of a 403 MB output file wrote **12.5 MB** and returned. No
exception, no non-zero exit, no short-write return code. The client's own
error — a timeout toast — appeared **asynchronously, minutes later**, long
after my code had moved on and printed its success line.
This is why a "did the write succeed?" check that trusts the API is worth
nothing on this class of storage. The write call answered a different
question ("did I hand the data to the cache?") than the one you asked ("is
the file on the other side correct?").
The fix is boring and total: hash what you wrote locally, read the file back
from the share, hash that, and compare. On a mismatch, re-upload. Twice, if
needed, with a backoff.
```python
# after uploading dst from src
if hash_file(src) != hash_file(_reread(dst)):
retry_upload()
```
Note the deliberate cost: this reads the file over the network a second
time. For a 400 MB file that is a few seconds — against a 25-minute encode
it is rounding error. The alternative is a library where some fraction of
files are silently not what you think.
## Lie 2: a rename that does not land, with metadata that says it did
The second one hides behind a filesystem primitive you trust blindly:
`os.replace()` — atomic, same-filesystem, cannot half-happen. Except
"filesystem" here means a network client with its own metadata cache.
The sequence was: upload `show.mp4.new.mp4`, verify the hash, `os.replace()`
it over `show.mp4`, then probe the result to confirm. The probe said HEVC,
the size looked right, the run logged `ok 1085 MB -> 478 MB`.
The file on the share was **the untouched original**: H.264, 921,950,850
bytes. The replace had not been applied; the client served cached attributes
for the path, so `getsize()` and `mtime` happily described a file that no
longer existed.
The only check that survives a lying metadata cache is reading the bytes
back:
```python
os.replace(tmp, dst) # claim
if hash_file(dst) != hash_file(local): # evidence
raise VerificationError(dst)
```
After I added that step, the same audit that found 1-in-30 failures found
**zero** — and, more importantly, any future silent failure turns into a
loud one, because "the bytes at the destination do not match the bytes I
produced" is not a thing a cache can fake.
## Lie 3: a size check that agrees with a bad copy
The third one is the subtlest, because it fools *verification itself*, not
just the operation.
Staging a source file from the share into local scratch is a copy. The
natural check is "did the right number of bytes arrive?" — so the pipeline
compared byte counts. They matched.
Then, in an unrelated investigation, the same source file failed to decode
("Error splitting the input into NAL units"), and I went looking for a
corrupt file that was not there. The file was fine: 921,950,850 bytes,
healthy header, sixty seconds decoded with zero errors. The *copy* had been
truncated in a way that preserved the length, or the read had silently
returned short and been padded.
Length is not a checksum. A copy is verified when the hash of what you read
matches the hash of what is on the share — which means reading the share
twice, or hashing after the read and comparing against a hash taken on a
different occasion.
## The metric traps that made it worse
While chasing those three, my *quality* checks were lying too, in ways worth
naming because they are not specific to network storage:
- **A decode test passes on wrong pictures.** `ffmpeg -v error -i out.mp4 -f
null -` printed nothing on a file with 106 frames decoded from the wrong
timestamps. "No errors" means "no errors", not "correct".
- **SSIM is meaningless on flat frames.** Two nearly-black frames can score
0.25 while being visually identical. Any threshold that treats a single low
frame as proof of a defect will reject good encodes — and any that ignores
frames wholesale will miss real ones.
- **Seeking into an open-GOP source decodes the wrong pictures.** Comparing a
file against *itself*, with one side decoding from a mid-file seek and the
other sequentially, measured min SSIM 0.25. A false "systematic defect"
produced entirely by my own comparison harness.
- **A stream copy is not a lossless slice.** Cutting 60 seconds out of an
H.264 file with B-frames using `-ss ... -c copy` produced 1270 frames where
1500 were expected. Fine for scrubbing; fatal as a test fixture.
- **Two concurrent runs sharing a scratch directory will overwrite each
other's intermediates.** Mine had fixed filenames for the decoded raw
frames, so two gates running at once compared two different videos and
reported 100% of frames below the threshold. Every temporary path now
carries the process id.
## The discipline
None of this needs cleverness. It needs a rule: **a step is done when you
have independently observed the outcome, not when the tool reported it.**
In practice, for a pipeline that writes to a share:
1. **Read the source twice.** Stage the file, hash it, hash a fresh read of
the share, compare. Length is not evidence. 2. **Hash the upload before you
replace anything.** 3. **Hash the destination after the replace.** Cached
metadata cannot fake bytes. 4. **Verify the content, not just the
container** — compare pictures, not frame rates. 5. **Make every failure
loud.** A gate that fails safe costs one wasted encode; a gate that passes a
bad file costs a corrupted library and your trust in it. 6. **Do not
parallelise the writer.** Three jobs sharing one share and one scratch
directory produced short writes, timeouts and cross-contaminated
comparisons. When I dropped to one writer, the error rate went to zero.
The audit line I care about is not "0 errors". It is:
```text
reported=30 landed=29 not-landed=0 wrong-codec=1 gone=0
```
Every number there was produced by reading the destination, not by trusting
the log.
````
---
## 文章 02 / 09:n8n v1 升级到 v2:一个日志文件里的七项废弃警告
- **Slug**: `n8n-v1-to-v2-upgrade-gotchas`
- **英文标题**: Upgrading n8n v1 to v2: Seven Deprecations in One Log File
- **分类**: `devops` | **发布日期**: 2026-09-18
- **线上地址**: https://blog.hoelee.com/posts/n8n-v1-to-v2-upgrade-gotchas/ (EN) | https://blog.hoelee.com/posts/zh/n8n-v1-to-v2-upgrade-gotchas/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "n8n v1 升级到 v2:一个日志文件里的七项废弃警告"
description: "我的自建 n8n 从 1.x 升级到 2.40.1,一次性暴露出七处静默失效——包括一个被 schema 校验拒绝的遥测事件、废弃的 webhook 变量,以及一个悄悄把 AI 沙盒关掉的数据库覆盖值。"
pubDate: 2026-09-18
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
---
n8n 是我整个自建技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
然后我拉了 `n8nio/n8n:2.40.1` 镜像并重启容器。升级本身只花了大约九十秒。而搞清楚它*弄坏了什么*花了我剩下的一整晚——而其中几乎所有信息,n8n 在启动时就已经写在一个日志文件里了。只是我第一次读得不够仔细。
这篇文章就是那个日志文件的解读,让你在规划自己的 v1 → v2 升级时能提前准备,而不是晚上十一点才发现问题。
## 为什么这件事重要
自动化平台的大版本升级,和其他服务升级的性质不一样。n8n 是*运行其他一切东西的那个东西*:它一旦起不来,你的备份、权限同步、内部 API 全部跟着停。更麻烦的是,v2 里坏掉的东西大多不会报错——它只打印一次废弃警告,然后就悄悄换了一种行为方式。
下面这七项,都是在一个真实的、有点混乱的、生产形态的安装上实际命中的。其中三项改变了我的技术栈的行为。还有一项,悄悄把一个功能*关掉了*。
## 从这里开始:n8n 在启动时会告诉你哪里不对
在动任何工作流之前,先从头读容器日志。在 v2 全新的启动过程中,n8n 会明确打印出一整块废弃警告:
```text
There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
both test and production webhooks.
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
change to `false` in a future version.
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
removed in a future version.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
reduced from 2 GiB to 256 MiB in a future version.
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
5000 to 1000 in a future version.
```
这一块就是你的迁移清单。我那七项坑里的六项都在里面。
## 1. 你的环境变量值现在可能过不了 schema 校验
这是最让我困惑的一项,因为它看起来完全像一条无意义的报错:
```text
Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
```
我当时设置的是 `EXECUTIONS_DATA_SAVE_ON_ERROR=error`——这个值在 v1 里完全合法,而且是我刻意选的,因为对一台繁忙的实例来说,「只保存失败的执行记录」是合理的默认策略。但在 v2 里这个值已经不在允许集合内了,现在的合法值是 `all` 或 `none`。
失败方式才是关键。它没有崩溃。它甚至没有以一眼就能看出是错误的方式发出警告——它抛出的是一条 *telemetry schema validation* 消息,听起来像是 n8n 内部的问题,而不是我的配置问题。这个设置实际上被忽略了。
修法是把你的意图放到一个说得通的地方:选一个合法的值,然后用 pruning 控制数据量。
```env
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
```
**教训:** 在 v2 里,把你的环境变量当成一套有类型的、带 schema 的接口来看待。一个非法的值可能被静默丢弃,而不是大声拒绝。
## 2. `WEBHOOK_URL` 已被 `N8N_WEBHOOK_URL` 取代
如果你的 webhook 发布在反向代理后面——你几乎肯定是的,因为那是它们变得可访问的方式——那么这个 base URL 变量就是关键路径上的东西。它决定了 n8n 报告的是*公网* webhook 路径,还是 `http://localhost:5678/...`。
旧名字目前还能用,所以这一项不会立刻咬你。但注意它的措辞:新变量为**测试和生产 webhook 同时**设置 base URL。在我的环境里这两者在行为上已经出现了分叉,而这次的合并正是为了消除这一类 bug。
```env
# 升级前(仍可用,但已废弃)
WEBHOOK_URL=https://auto.example.com/
# 升级后
N8N_WEBHOOK_URL=https://auto.example.com/
```
## 3. Internal task runner 模式要取消了——而我的其实早就坏了
这一项一直在我的日志里,就在废弃警告块上面几行,而我已经读过去好几个月了:
```text
Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
```
如果你的任何工作流用了 **Python** 的 Code 节点,那它根本就没在 internal 模式下跑起来过——官方镜像里没有 Python。JavaScript 的 Code 节点是正常的(JS runner 会正常注册),所以这个问题可以无限期不被发现:一切*看起来*都是健康的。
v2 把方向挑明了:切到 `external` 模式,和一个独立启动器进程共享一个 auth token。
```env
N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<一串足够长的随机字符串>
```
**教训:** 「internal 模式已废弃」是标题,但真正的发现是:某一类 runner 可能已经*静默失效*好几个月了。要检查 `docker logs` 里的 runner 注册那行,而不是只看容器起没起来。
## 4. Task 超时默认值从 300 秒降到 60 秒
这一项是我最想替所有有慢工作流的人标红的:
```text
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
```
对自己的负载诚实一点。你有没有 Code 节点要遍历几千条记录,或者要调一个很慢的上游接口?我有——一个每夜的对账流程会遍历每个客户,每条记录调一次外部 API。在未来的某次升级中,它会在六十秒处停下,而我这边没有任何配置变更。
趁你人还在这个文件里,现在就显式设置:
```env
N8N_RUNNERS_TASK_TIMEOUT=300
```
对所有「默认值将会改变」形式的废弃警告,通用原则是:**如果你依赖当前的默认值,就把它显式钉死。** 否则这次升级就是一个静默的行为变更,而你会把它当成 bug 来排查,而不是认出它是一个过期的默认值。
## 5 和 6. 两个压缩节点上限缩水(2 GiB → 256 MiB,5000 → 1000 条)
这两项一起出现,只有当你在工作流里处理大负载的压缩/解压节点时才相关——而当你在工作流里搬运数据库导出或归档文件时,很容易就变成相关。
```text
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
future version.
```
内存上限降到八分之一,条目上限降到五分之一。不会报错;节点只是在一个你没设定的阈值处拒绝执行。如果你接近任何一个上限,两个都钉死。
## 7. 存储路径将在 v3 改名——而你的卷正好挂在旧路径上
这不是 v2 的破坏性变更,但 v2 会警告它,而且是真正涉及数据规划的那一项:
```text
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
```
再读一遍最后一句:*如果你有一个卷挂载在旧路径上,请在迁移后更新你的挂载配置。* 如果你设了迁移标志却保留旧的 bind mount,你现在就有两个目录,而你的二进制数据住在容器实际指向的那一个里。把改名和挂载变更放在同一个维护窗口里做——不要一个现在、一个「以后」。
## 那个不在日志里的:我的 AI 沙盒把自己关掉了
这是跟废弃警告毫无关系的发现,也是如果我不读完整的启动序列就永远抓不到的一项:
```text
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
```
我的环境变量说已启用。数据库说不是。**数据库赢了。**
环境变量是 `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`,在容器上依然设置正确。但有一个持久化在 n8n 自己的配置存储里的值,在启动时覆盖了它——而这个冲突唯一被报告的地方,就是一行日志里的一个括号。
这是一个超越 n8n 的、非常有价值的排查教训:当一个功能明明环境变量设对了却是关闭状态,就怀疑存在一个**优先级高于环境的持久化设置层**。容器配置不总是最终答案。在日志里 grep 那个功能名,不要看到环境变量就停下。
## 我会怎么做得不一样
1. **在宣布升级完成之前先读启动日志。** 所有对我重要的废弃警告,都在第一次启动时、在一个块里、清清楚楚打印出来了。我的 v1 习惯是检查「它起来了吗、工作流跑得动吗」——而这恰恰是漏掉全部七项的检查方式。
2. **把「默认值将会改变」当成待办事项,而不是警告。** 七项里有五项是未来的默认值变更。现在钉死它们只需要改一次配置,却能把以后的一次神秘故障转化为一次配置 diff。
3. **把容器配置和 n8n 自己存储的配置做对比。** 沙盒覆盖这件事教会我:env 只是两个输入之一,不是真相来源。当行为和配置不一致时,相信行为,然后去找那个优先级更高的层。
4. **钉死镜像 tag,并保留上一个。** 我是从 v1 直接跳到 `2.40.1` 的。本地留着旧镜像,是回滚能变成一条 `docker run` 而不是一次重新构建的原因。
## 结果
n8n 现在跑在 2.40.1 上,整块废弃警告都已处理:schema 非法的值已修正,`N8N_WEBHOOK_URL` 已就位,JS runner 正常注册,task 超时和压缩上限都已钉死,让下一次升级变成一次无操作而不是一次意外。
下游一切都还在工作——文件权限同步、每夜备份、语音合成接口。这才是这类升级值得追求的结果:不是「它起来了」,而是「它起来了,*而且*接下来三次升级的成本已经预付了」。
真正让人不舒服的地方在于,这些我本可以提前知道的信息占了多大比例。n8n 主动把整份清单递给了我,就在启动时。升级从来不是难的部分——读输出才是。
---
## 需要不用你天天盯着的自动化?
我搭建并维护自建自动化——n8n 工作流、Docker 技术栈,以及那些从来不是设计来互相通信的应用之间的胶水层。如果你正面临一次大版本升级,或者你有那种「能用,直到不能用」的自动化,我会规划迁移、在维护窗口执行、并把每一个配置决策都记录下来,让下一次升级变得无聊。
欢迎联系 [[email protected]](mailto:[email protected]?subject=n8n%20%E5%8D%87%E7%BA%A7)
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做什么。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "Upgrading n8n v1 to v2: Seven Deprecations in One Log File"
description: "My self-hosted n8n 1.x to 2.40.1 upgrade surfaced seven silent breakages at once — a telemetry schema rejection, deprecated webhook vars, and a DB override that quietly disabled the AI sandbox."
pubDate: 2026-09-18
category: devops
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.jpg
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.jpg
---
I run n8n as the automation backbone for my self-hosted stack — it handles
file-delivery permissions, database backups, and a text-to-speech API that a
reading app depends on. It had been sitting on the `1.123.x` line for the
better part of a year, quietly doing its job.
Then I pulled `n8nio/n8n:2.40.1` and restarted the container. The upgrade
itself took about ninety seconds. Understanding what it *broke* took the rest
of the evening — and almost all of it was already written down in a single log
file that n8n prints on boot. I just hadn't read it carefully enough the first
time.
This is that log file, decoded, so you can plan your own v1 → v2 jump instead
of discovering these at 11 PM.
## Why it matters
Major-version upgrades of an automation platform are different from upgrading
a leaf service. n8n is *the thing that runs everything else*: if it comes up
broken, your backups, your access-control syncs, and your internal APIs all
stop with it. Worse, most of what breaks in v2 doesn't throw an error — it
logs a deprecation notice once and then quietly does something different.
The seven items below are the ones that actually applied to a real, messy,
production-shaped install. Three of them changed behavior in my stack. One of
them silently turned a feature *off*.
## Start here: n8n tells you what's wrong at boot
Before touching a single workflow, read the container log from the top. On a
fresh v2 boot, n8n prints an explicit deprecation block:
```text
There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
both test and production webhooks.
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
change to `false` in a future version.
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
removed in a future version.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
reduced from 2 GiB to 256 MiB in a future version.
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
5000 to 1000 in a future version.
```
That block is your migration checklist. Six of my seven gotchas are in it.
## 1. Your env values can now fail *schema validation*
Here is the one that confused me most, because it looked like a nonsense error:
```text
Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
```
I had `EXECUTIONS_DATA_SAVE_ON_ERROR=error` set — a value that was perfectly
legal in v1, and that I'd chosen deliberately, because saving *only* failed
executions is the sane default for a busy instance. In v2 that value is no
longer in the allowed set, which is now `all` or `none`.
The failure mode is the interesting part. It didn't crash. It didn't even warn
in a way that reads like an error at a glance — it emitted a *telemetry schema
validation* message, which sounds like an n8n-internal problem, not a
configuration problem of mine. The setting was effectively ignored.
The fix is to move that intent somewhere coherent: pick a legal value and
control volume with pruning instead.
```env
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
```
**Lesson:** in v2, treat your environment variables as a typed interface with
a schema. An invalid value may be dropped silently rather than rejected loudly.
## 2. `WEBHOOK_URL` is deprecated for `N8N_WEBHOOK_URL`
If you publish webhooks behind a reverse proxy — which you almost certainly do,
because that's how they become reachable — the base URL variable is load-bearing.
It's what makes n8n report the *public* webhook path instead of
`http://localhost:5678/...`.
The old name still works today, so this one won't bite immediately. But note
the wording: the new variable sets the base URL for **both test and production
webhooks**. In my setup those had drifted apart in behavior, which is exactly
the class of bug this consolidation is meant to eliminate.
```env
# before (still functional, deprecated)
WEBHOOK_URL=https://auto.example.com/
# after
N8N_WEBHOOK_URL=https://auto.example.com/
```
## 3. Internal task runner mode is going away — and mine was already broken
This one was sitting in my logs the whole time, several lines above the
deprecation block, and I'd been reading past it for months:
```text
Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
```
If any workflow of yours uses a **Python** Code node, it has not been running
in internal mode at all — there's no Python in the stock image. JavaScript
Code nodes are fine (a JS runner registers normally), which is why this can go
unnoticed indefinitely: everything *looks* healthy.
v2 makes the direction of travel explicit: switch to `external` mode and share
an auth token with a separate launcher process.
```env
N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<a long random string>
```
**Lesson:** "internal mode is deprecated" is the headline, but the real
finding is that a runner type can be *silently non-functional* for months.
Check `docker logs` for the runner registration line, not just for up/down.
## 4. Task timeout drops from 300s to 60s
This is the one I'd flag hardest for anyone with slow workflows:
```text
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
```
Be honest about your own workloads. Do you have a Code node that loops over
thousands of records, or an HTTP call to a slow upstream? Mine do — a nightly
reconciliation walks every customer and calls an external API per record. On a
future upgrade, that stops at sixty seconds with no config change on my side.
Set it explicitly now, while you're already in the file:
```env
N8N_RUNNERS_TASK_TIMEOUT=300
```
The general principle for every deprecation of the form "the default will
change": **if you rely on the current default, pin it explicitly.** Otherwise
the upgrade is a silent behavior change, and you'll debug it as a bug rather
than recognise it as a stale default.
## 5 & 6. Two compression-node limits shrink (2 GiB → 256 MiB, 5000 → 1000 entries)
These two travel together and matter only if you use compression/decompression
nodes on large payloads — which is easy to end up doing when you're shuttling
database dumps or archives through a workflow.
```text
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
future version.
```
An eighth of the memory ceiling and a fifth of the entry limit. Nothing errors;
the node just refuses at a threshold you didn't set. Pin both if you're near
either.
## 7. The storage path renames in v3 — and you have a volume mounted at the old one
Not a v2 breakage, but v2 warns about it, and it's the one with real data-planning
implications:
```text
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
```
Read that last sentence again: *if you have a volume mounted at the old path,
update your mount configuration after migration.* If you set the migration flag
and keep your old bind mount, you now have two directories and your binary data
lives in whichever one the container is actually pointed at. Do the rename and
the mount change in the same maintenance window — not one now, one "later".
## The one that wasn't in the log: my AI sandbox disabled itself
Here's the finding that had nothing to do with a deprecation notice, and that
I'd never have caught without reading the full boot sequence:
```text
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
```
My environment said enabled. The database said otherwise. **The database won.**
The env var was `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`, and it was still set
correctly on the container. But a value persisted in n8n's own configuration
store overrode it at startup — and the only place that conflict is reported is
inside a parenthetical in a log line.
This is a genuinely valuable debugging lesson beyond n8n: when a feature is off
despite the env var being obviously right, suspect a **persisted settings layer
that outranks your environment**. The container config is not always the last
word. Grep for the feature name in the logs, and don't stop at the env value.
## What I'd do differently
1. **Read the boot log before declaring the upgrade done.** Every deprecation
that mattered to me was printed on startup, in one block, on the first run.
My v1 habit was to check "is it up, do the workflows run" — which is exactly
the check that misses all seven of these.
2. **Treat "the default will change" as a to-do, not a warning.** Five of the
seven items are future-default changes. Pinning them now costs one edit and
converts a mystery outage later into a config diff.
3. **Diff the container config against n8n's own stored config.** The sandbox
override taught me that env is one of two inputs, not the source of truth.
When behavior and configuration disagree, believe the behavior and go find
the higher-priority layer.
4. **Pin the image tag and keep the previous one.** I jumped v1 → `2.40.1`
directly. Having the old image on disk is what makes a rollback a
`docker run` instead of a rebuild.
## The result
n8n is on 2.40.1 with the full deprecation block resolved: the schema-invalid
value corrected, `N8N_WEBHOOK_URL` in place, the JS runner registering cleanly,
and the task timeout and compression limits pinned so the next upgrade is a
no-op rather than a surprise.
Everything downstream kept working — the file-permission syncs, the nightly
backups, the text-to-speech endpoint. That's the outcome worth aiming for with
an upgrade like this: not "it came back up", but "it came back up *and* the
next three upgrades are already paid for".
The uncomfortable part is how much of it I could have known in advance. n8n
handed me the entire list, unprompted, at startup. The upgrade was never the
hard part — reading the output was.
---
## Running automation you'd rather not babysit?
I build and maintain self-hosted automation — n8n workflows, Docker stacks,
and the glue between apps that were never designed to talk to each other. If
you're facing a major version upgrade, or you have automation that works until
it doesn't, I plan the migration, do it in a maintenance window, and document
every config decision so the next upgrade is boring.
Reach me at [[email protected]](mailto:[email protected]?subject=n8n%20upgrade) or
WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
````
---
## 文章 03 / 09:公开的埋点与要 SSO 的仪表盘,怎么共用一个域名
- **Slug**: `one-hostname-public-tracker-sso-dashboard`
- **英文标题**: One Hostname for a Public Tracker and an SSO-Gated Dashboard
- **分类**: `devops` | **发布日期**: 2026-09-30
- **线上地址**: https://blog.hoelee.com/posts/one-hostname-public-tracker-sso-dashboard/ (EN) | https://blog.hoelee.com/posts/zh/one-hostname-public-tracker-sso-dashboard/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "公开的埋点与要 SSO 的仪表盘,怎么共用一个域名"
description: "埋点必须公开,仪表盘绝不能公开。我用 authentik 的 proxy provider 加路径放行规则,把两者放在同一个域名上——不用第二个子域,也不用第二个证书。"
pubDate: 2026-09-30
category: devops
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
---
几乎每个统计工具都长成一个尴尬的形状:负责**收集**数据的那一半,必须让陌生人的浏览器在每次打开页面时都能访问;负责**展示**数据的那一半,必须只有我能访问。
通常这意味着两个域名:一个公开的埋点入口,一个私有的仪表盘。我不想要两个域名。我只想要一条 DNS 记录、一张证书、一件要记住的事——同时能在吉隆坡某家酒店的 wifi 上打开仪表盘,而不用把一个登录页发布到公网。
下面就是最后跑通的形状,以及两个让「看起来完全正常」的配置连续半小时返回 404 的坑。
## 约束清单
- **埋点必须公开。** `script.js` 和两个收集端点由每个访客的浏览器抓取。如果它们被鉴权挡住,你就什么都收不到,而且好几天都不会发现。
- **仪表盘绝不能公开。** 它显示每个访客的国家、城市、来源和页面路径。一个默认管理员登录挂在公网 URL 上,就是送给任何在证书透明度日志里翻到你子域的人。
- **没有泛解析 DNS。** `*.hoelee.com` 不解析,所以每个域名都是一条显式 DNS 记录加一张证书。第二个域名是实打实的工作量,还让我要维护的攻击面翻倍。
- **这个域名不走 Cloudflare。** 它是一条直指我家路由器的 A 记录,所以没有 WAF、没有 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠普通 nginx 头工作。
## 方案一:两个域名
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。我的博客一天只有个位数访问。我是在抄一个解决「我没有的问题」的架构,代价是第二条 DNS 记录、第二张证书、第二个会坏的地方。
两个域名是**规模**的答案,不是**需求**。一个域名就能同时承载两者,按路径分开就行。
## 方案二:nginx basic auth(这个方案行不通,原因值得记下来)
我第一反应是用最便宜的挡法:在统计应用前面放一个 nginx 容器,除了三个埋点路径以外全挂 `auth_basic`。四行配置,我以前用过。
它会把仪表盘彻底弄坏,而且坏的方式足够让人困惑,值得写下来。
应用自己的前端会用 bearer token 调自己的 API:
```
GET /api/websites HTTP/1.1
Authorization: Bearer <token>
```
浏览器每个请求只发**一个** `Authorization` 头。当前端加上它的 bearer token 时,这个头就**顶掉**了 basic auth 的凭据——于是网关读到的是一个 bearer token,却期望它是 base64 的 user:password,判定为未鉴权,返回 401。仪表盘的外壳能加载(那个请求带着 basic auth),然后每一个 API 调用都失败。你得到的是一个渲染出框架、里面什么都没有的界面,读起来像「统计工具坏了」,而不是「我的网关在跟我的应用打架」。
基于 cookie 的鉴权没有这个问题,因为凭据放在 `Cookie` 头里,应用自己的 token 永远不会碰它。所以:要么 cookie 鉴权,要么不要网关。
## 方案三:nginx 白名单网关(能用,但仪表盘变成只能内网访问)
下一版彻底去掉 basic auth,把网关做成纯白名单:`/script.js`、`/api/send`、`/api/heartbeat` 放行,其余一律 403。这确实安全——埋点公开,仪表盘根本不可达——如果你只在自己网络里看数据,这完全可以作为最终方案。
但我想在任何地方都能看仪表盘,所以它变成了退路而不是终点。我把容器停掉但保留着,后来证明这个决定是对的:回滚只需要一次 `docker start` 加一行配置。
## 方案四:authentik proxy provider + 按路径放行
我本来就在用 authentik 给二十来个自托管应用做单点登录。它们大多用同一种方式挡:反向代理把请求转给 authentik 的 embedded outpost,outpost 检查会话 cookie,未登录的访客被 302 送到登录流程,登录后再送回来。
proxy provider 上有一个正好为这个问题准备的字段:`skip_path_regex`。一行一条正则。**任何匹配的路径都会完全不鉴权**直接转发给应用;不匹配的一律弹到 SSO 登录。于是「哪部分公开」不再是 nginx 的事,而变成了应用层的策略。
```
mode: proxy
external_host: https://stats.hoelee.com
internal_host: http://umami:3000
skip_path_regex: ^/script\.js$
^/api/send
^/api/heartbeat$
^/mcp(/|$)
```
这就是全部策略:埋点脚本、收集端点、健康检查端点和 MCP 端点公开;这个域名上的其他所有路径都要过 SSO。
nginx 的 vhost 则从指向应用改成指向 outpost:
```nginx
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:10000; # the authentik outpost
}
```
## 坑一:一个 provider 模式,让「正常的 SSO」把整个应用变成 404
我建新 provider 的方式是克隆一个已经能用的。通常这是加应用最快也最安全的做法,而这正是我踩坑的原因。
我抄的那个 provider 用的是 `mode: forward_single`,`internal_host` 是空的。对于自带登录的应用(比如一个浏览器应用、一个自己有会话的监控界面),这是对的——outpost 只需要回答「这个访客允许吗」,应用由别的路径抵达。但它意味着 outpost **没有可代理的上游**。当你挡的应用自己没有登录(比如自托管的统计仪表盘),outpost **就是**那个反向代理,而 `internal_host` 为空时它无处可送。
症状之所以难缠,是因为鉴权那一半看起来完美:
```
$ curl -sI https://stats.hoelee.com/ | head -1
HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
404 # ← 但应用不在这里
```
302 到登录流程、品牌登录页正常渲染、流程走完——而每一个应用路径都返回 404。一旦开始找,有两样东西让定位变得很快:
1. **`x-powered-by` 头会告诉你哪一跳应答的。** 那个 404 带着 `x-powered-by: authentik`,证明它是 outpost 产生的,应用根本没收到请求。没有这个头,我就会去查应用的路由。
2. **拿一个能用的 provider 对照。** 同样的匿名请求打到已知正常的应用,会停在登录流程并返回 200。我的是 404——形状一样,最后一跳不同。
修复只是一对字段:`mode: proxy` 加一个真实的 `internal_host`。
```
mode: proxy # 不是 forward_single
internal_host: http://umami:3000 # outpost 把请求代理到这个容器
```
`forward_single` = 「应用在别处,你只负责检查访客」。
`proxy` = 「你就是反向代理,把请求送到 `internal_host`」。
怎么选:问一句**谁来产生响应**。
## 坑二:outpost 需要一分钟,而它会很有说服力地骗你
改完 provider 之后,embedded outpost 需要一到两分钟才会拉到新配置。在这个窗口里,域名会这样应答:
```
302 → /flows/-/default/authentication/ # 一个默认流程,slug 是 "-"
404 # 所有应用路径
```
这不是新配置失败,而是旧配置还没被替换。我在这上面浪费了两次时间:一次是判断接线坏了,一次是判断模式修改没生效。现在的做法是改一处、等九十秒、然后才判断结果。把新 provider 挂到 outpost 上也一样:provider 在 API 里立刻存在,但要一分钟后才开始应答请求。
## 我实际验证了什么,以及从哪里验证
这类改动用内网自测毫无意义。我的环境里有些域名有两层入口(一个 nginx vhost,加一条直连容器的隧道规则),所以从内网发的请求可以通过,而公网路径完全绕开网关。下面全部是从公网、用真实域名测的。
| 检查 | 结果 |
|---|---|
| 匿名 `GET /` | 302 → outpost → 该应用自己的登录流程 → **200** |
| `GET /login`、`/api/websites` | 302,被挡 |
| `GET /script.js` | **200**——埋点脚本对访客仍然加载 |
| `GET /api/heartbeat` | **200** |
| 用假 site id `POST /api/send` | **应用返回 400**,不是网关返回 403——证明请求真的到了应用 |
| 带 API key `POST /mcp` | **200**,`text/event-stream` |
| 不带 key `POST /mcp` | **401** `Missing bearer API key`——应用自己的鉴权,不是 SSO 网关 |
| 一次真实 pageview | 记录到 `country=MY region=MY-07 city=George Town`,浏览器、系统和来源都完整 |
最后一行才是关键。因为这个域名不走 Cloudflare,访客 IP 必须靠 `X-Forwarded-For` 穿过两跳代理,并且要告诉应用去读这个头(`CLIENT_IP_HEADER=x-forwarded-for`)而不是读 Cloudflare 的那个。一个数字在涨、但所有访客都被归到容器 IP 上的统计,看起来像成功,其实毫无用处。**能记录到正确的城市才是证据。**
## 我下次会怎么做
- **把上一个网关停掉,而不是删掉。** 方案三那个 nginx 白名单容器还在磁盘上,处于停止状态。回滚是 `docker start` 加 vhost 里改一行——三十秒,而不是在压力下凭记忆重建它。
- **在域名可达之前轮换应用的默认凭据,而不是之后。** 我有一个下午让公网 URL 和默认的管理员登录同时存在;而新子域几个小时内就会出现在证书透明度日志里。
- **一次只改一个字段,然后等。** 这次会话里三个错误结论,有两个来自在系统还没应用完变更时就去读结果。
- **也要测应用自己的登录流程。** SSO 通过不代表应用能用:网关可能完全正确,而它设下的会话 cookie 在下游被拒。我用 curl 验证埋点路径、用真实浏览器验证仪表盘,然后才敢说做完了。
## 说清楚取舍
- **双重登录。** 统计应用不支持 OIDC,所以 SSO 只解决了「能不能到这个路由」,应用仍会要它自己的用户名密码。每个浏览器多一次点击。真正保护数据的是应用内可以按账号开启的两步验证。
- **仪表盘的可用性等于 outpost 的可用性。** authentik 挂了,仪表盘就挂了;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。
- **我保留了一条后门。** 应用仍在局域网里发布一个端口,完全绕开 SSO。它从公网不可达(用外部主机验证过),仍然需要应用自己的凭据,但我宁可留着它,也不想因为一次 SSO 配置失误把自己锁在自己的统计之外。
## 结果
一个域名、一条 DNS 记录、一张证书。没有第二个子域,没有额外容器——网关就是我本来就在跑的 SSO 实例的一个功能。埋点对每个访客的浏览器可达,仪表盘对我随处可达,匿名访客看到的是登录页而不是仪表盘。
可以量化的版本:三个埋点路径加 MCP 端点对匿名请求应答正确,域名上其余所有路径 302 到 SSO,pageview 带着访客真实城市和来源落库——这也是整件事值得做的唯一理由。
如果你也在自托管统计(或任何有「公开收集端」的东西),先试试用一个域名加路径放行规则,再考虑加第二条 DNS 记录。两域名的拆法是给那些收集流量足以养一个 CDN 的公司用的架构。你的多半不是。
## 需要为你的业务做这个吗?
如果你想真正知道网站在发生什么,又不想把访客数据交给广告网络——或者你有一个绝不该从公网访问的内部工具——我可以帮你搭自托管、无 cookie 的统计,以及像这篇里那样的 SSO 网关:一个域名、不需要同意横幅、没有第三方脚本在每一页偷偷回连。
**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[[email protected]](mailto:[email protected]?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
网站设计与开发是我的主业;服务器加固与自托管基础设施是它的另一半。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "One Hostname for a Public Tracker and an SSO-Gated Dashboard"
description: "A web-analytics tracker has to be public and its dashboard must not be. How I put both on one hostname with an authentik proxy provider and path-based skip rules."
pubDate: 2026-09-30
category: devops
tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
ogImage: "/og/one-hostname-public-tracker-sso-dashboard.jpg"
banner: "/banners/one-hostname-public-tracker-sso-dashboard.jpg"
draft: false
---
Every analytics tool has the same awkward shape. The half that *collects* data
has to be reachable by a stranger's browser on every page load. The half that
*shows* the data must be reachable by nobody but me.
That normally means two hostnames: a public ingest endpoint and a private
dashboard. I did not want two hostnames. I wanted one DNS record, one
certificate, one thing to remember — and a dashboard I could open from a hotel
wifi in Kuala Lumpur without publishing a login page to the internet.
This is the shape that ended up working, and the two traps that made a
perfectly healthy-looking configuration serve 404s for half an hour.
## The constraint list
- **The tracker must be public.** `script.js` and the two ingest endpoints are
fetched by every visitor's browser. If they are behind auth, you collect
nothing and you will not notice for days.
- **The dashboard must not be public.** It shows every visitor's country, city,
referrer and page path. A default admin login on a public URL is a gift to
whoever finds the subdomain in a certificate-transparency log.
- **No wildcard DNS.** `*.hoelee.com` does not resolve, so every hostname is an
explicit DNS record plus a certificate. A second hostname is real work, and it
doubles the surface I have to keep patched.
- **This hostname does not go through Cloudflare.** It is a direct A record to my
router, so there is no WAF, no bot protection and no `CF-Connecting-IP` header
to lean on. Everything below has to work with plain nginx headers.
## Attempt 1 — two hostnames
The conventional split is what PostHog and Sentry do: `i.posthog.com` for ingest,
`app.posthog.com` for the UI. That is a real requirement when ingest is served
from a CDN at thousands of requests per second and the app is a stateful
database client. My blog gets a handful of visits a day. I was copying an
architecture that solves a problem I do not have, and paying for it with a second
DNS record, a second certificate and a second thing to break.
Two hostnames is the *scale* answer, not the *requirement*. One hostname can
carry both, split by path.
## Attempt 2 — nginx basic auth (this one does not work, and here is why)
My first instinct was the cheapest possible gate: an nginx container in front of
the analytics app, with `auth_basic` on everything except the three tracker
paths. It is four lines of config and I have used it before.
It breaks the dashboard completely, and the failure is confusing enough to be
worth writing down.
The app's own front end talks to its own API with a bearer token:
```
GET /api/websites HTTP/1.1
Authorization: Bearer <token>
```
A browser sends **one** `Authorization` header per request. When the front end
adds its bearer token, that header replaces the basic-auth credentials — so the
gate reads a bearer token where it expects a base64 user:password pair, decides
the request is unauthenticated, and returns 401. The dashboard shell loads
(that request carries basic auth), and then every single API call fails. You get
a UI that renders its layout and shows nothing, which reads as "the analytics
tool is broken" rather than "my gate is fighting my app".
Cookie-based auth does not have this problem, because the credentials live in a
`Cookie` header that the app's own tokens never touch. So: cookie auth, or no
gate.
## Attempt 3 — an nginx allowlist gate (works, but the dashboard goes LAN-only)
The next version dropped basic auth entirely and made the gate a pure allowlist:
`/script.js`, `/api/send` and `/api/heartbeat` pass through, everything else gets
403. That is genuinely safe — the tracker is public, the dashboard is not
reachable at all — and it is a fine permanent answer if you only ever look at
your stats from inside your own network.
I wanted to see the dashboard from anywhere, so this became the fallback rather
than the destination. I stopped the container and kept it, which turned out to be
the right call later: rollback was one `docker start` and one config line.
## Attempt 4 — authentik proxy provider with path-based skip rules
I already run authentik for single sign-on across about twenty self-hosted apps.
Most of them are gated the same way: the reverse proxy forwards the request to
authentik's embedded outpost, the outpost checks for a session cookie, and an
unauthenticated visitor gets a 302 to the login flow and back.
The proxy provider has a field built for exactly this problem:
`skip_path_regex`. One regex per line. Any path that matches is forwarded
straight to the app with **no authentication at all**; anything that does not
match is bounced to the SSO login. So the split stops being an nginx concern and
becomes an application-level policy.
```
mode: proxy
external_host: https://stats.hoelee.com
internal_host: http://umami:3000
skip_path_regex: ^/script\.js$
^/api/send
^/api/heartbeat$
^/mcp(/|$)
```
That is the whole policy: the tracker's asset, its ingest endpoint, its health
endpoint and its MCP endpoint are public; every other path on that hostname
requires SSO.
The nginx vhost then points at the outpost instead of at the app:
```nginx
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:10000; # the authentik outpost
}
```
## Trap 1 — the provider mode that 404s the whole app behind a "working" SSO
I built the new provider by cloning one that already worked in my instance. That
is normally the fastest and safest way to add an app, and it is exactly how I got
this wrong.
The provider I copied used `mode: forward_single` with an empty `internal_host`.
That is correct for apps that handle their own login (a browser app, a monitoring
UI behind its own session) — the outpost only has to answer "is this visitor
allowed?", and the app is reached by some other route. But it means the outpost
has **no upstream to proxy to**. When the app you are gating has no login of its
own — a self-hosted analytics dashboard, for instance — the outpost *is* the
proxy, and with an empty `internal_host` it has nowhere to send the request.
The symptom is nasty because the auth half looks perfect:
```
$ curl -sI https://stats.hoelee.com/ | head -1
HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
404 # ← but the app is not there
```
302 to the login flow, the branded login page renders, the flow completes — and
every application path returns 404. Two things made the diagnosis fast once I
looked for them:
1. **The `x-powered-by` header says which hop answered.** The 404 carried
`x-powered-by: authentik`, which proves the outpost produced it and the app
never saw the request. Without that header I would have been debugging the
application's routing.
2. **Compare against a provider that works.** The same anonymous request against
an app that is known good ends at the login flow with a 200. Mine ended at
404 — same shape, different last hop.
The fix is one field pair: `mode: proxy` with a real `internal_host`.
```
mode: proxy # not forward_single
internal_host: http://umami:3000 # the outpost proxies to the container
```
`forward_single` = "the app is somewhere else, just check the visitor".
`proxy` = "you are the reverse proxy, send the request to `internal_host`".
Pick by asking who serves the response.
## Trap 2 — the outpost needs a minute, and it lies convincingly
After changing a provider, the embedded outpost takes one to two minutes to pick
up the new configuration. During that window the hostname answers like this:
```
302 → /flows/-/default/authentication/ # a default flow, slug "-"
404 # on every app path
```
That is not the new configuration failing — it is the old configuration not yet
being replaced. I lost time on this twice: once concluding the wiring was broken,
once concluding the mode change had not taken effect. Now I change one thing,
wait ninety seconds, and only then judge the result. The same applies to
attaching a new provider to the outpost: the provider exists in the API
immediately, and starts answering requests a minute later.
## What I actually verified, and from where
Internal checks are worthless for this class of change. My instance has two
ingress layers on some hostnames (an nginx vhost and a tunnel rule straight to a
container), so a request from inside the LAN can pass while the public path
bypasses the gate entirely. Everything below was measured from the public
internet, on the real hostname.
| Check | Result |
|---|---|
| `GET /` (anonymous) | 302 → outpost → the app's own login flow → **200** |
| `GET /login`, `/api/websites` | 302, gated |
| `GET /script.js` | **200** — the tracker still loads for visitors |
| `GET /api/heartbeat` | **200** |
| `POST /api/send` with a bogus site id | **400 from the app**, not 403 from a gate — proof the request reaches the app |
| `POST /mcp` with the API key | **200**, `text/event-stream` |
| `POST /mcp` without the key | **401** `Missing bearer API key` — the app's own auth, not the SSO gate |
| A real pageview | recorded with `country=MY region=MY-07 city=George Town`, browser, OS and referrer intact |
That last row is the one that matters. Because this hostname does not go through
Cloudflare, the visitor's IP has to survive two proxy hops via
`X-Forwarded-For`, and the app has to be told to read that header
(`CLIENT_IP_HEADER=x-forwarded-for`) instead of the Cloudflare one. A pageview
counter that increments while every visitor is attributed to a container IP
looks like success and is useless. Recording the right city is the proof.
## What I would do differently
- **Keep the previous gate stopped, not deleted.** The nginx allowlist container
from attempt 3 is still on disk, stopped. Rollback is `docker start` plus one
line in the vhost — thirty seconds, versus rebuilding it from memory under
pressure.
- **Rotate the app's default credentials before the hostname is reachable, not
after.** I had a public URL and a default `admin`/`admin`-style login live at
the same time for part of an afternoon, and a new subdomain shows up in
certificate-transparency logs within hours.
- **Change one field, then wait.** Two of my three wrong conclusions in this
session came from reading a result before the system had finished applying it.
- **Test the app's own login flow too.** SSO passing does not mean the app works:
the gate can be perfect and the session cookie it sets can still be rejected
downstream. I verified the tracker path with curl and the dashboard with a real
browser, and only then called it done.
## The trade-offs, honestly
- **Double login.** The analytics app has no OIDC support, so SSO grants access
to the route and the app still asks for its own username and password. One
extra click, once per browser. Per-account two-factor authentication is
available inside the app, which is the part that actually protects the data.
- **The dashboard is as available as the outpost.** If authentik is down, the
dashboard is down. The tracker fails silently at the same time, which is
survivable: pages still load, the beacon just does not land.
- **I kept one break-glass path.** The app still publishes a port on the LAN,
which bypasses SSO entirely. It is not reachable from the internet (verified
from an external host), it still requires the app's own credentials, and I
would rather have it than be locked out of my own analytics by an SSO
misconfiguration.
## The result
One hostname, one DNS record, one certificate. No second subdomain, no extra
container — the gate is a feature of the SSO instance I was already running. The
tracker is reachable by every visitor's browser, the dashboard is reachable by
me from anywhere, and an anonymous visitor gets a login page instead of a
dashboard.
The measurable version: three tracker paths and the MCP endpoint answer
correctly to anonymous requests, every other path on the hostname returns 302 to
SSO, and pageviews land with the visitor's real city and referrer — which is the
only reason the whole exercise was worth doing.
If you are self-hosting analytics (or anything with a public ingest half), try
one hostname with path-based skip rules before you add a second DNS record. The
two-hostname split is an architecture for companies whose ingest traffic pays for
a CDN. Yours probably is not.
## Want this for your business?
If you want to know what your website is actually doing without handing your
visitors' data to an ad network — or you have an internal tool that should never
be reachable from the internet — I set up self-hosted, cookie-free analytics and
SSO gates like the one in this post: one hostname, no consent banner, no
third-party script phoning home from every page.
**WhatsApp: [+60 12-797 2969](https://wa.me/60127972969)** · **Email: [[email protected]](mailto:[email protected]?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
Website design and development is my main line of work; server hardening and
self-hosted infrastructure is the other half of it.
````
---
## 文章 04 / 09:Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式
- **Slug**: `passbolt-hang-three-failure-modes`
- **英文标题**: Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File
- **分类**: `devops` | **发布日期**: 2026-01-07
- **线上地址**: https://blog.hoelee.com/posts/passbolt-hang-three-failure-modes/ (EN) | https://blog.hoelee.com/posts/zh/passbolt-hang-three-failure-modes/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式"
description: "自托管的 Passbolt 连续几周返回 504:GPG 指纹为空卡住邮件投递,重建容器抹掉了修复,代理后面的 ssl.force 又造成无限重定向。"
pubDate: 2026-01-07
updatedDate: 2026-09-13
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
---
我在 Synology NAS 上用 Docker 跑 Passbolt——一个开源的团队密码管理器,前面
挂着一层 nginx 反向代理。连着几周的某个坏习惯让我头疼:Web 界面动不动就
卡死,返回 `504 Gateway Timeout`,偏偏在正需要密码的时候拒绝交出任何凭据。
这是追查那个卡死毛病到根因的记录——以及我自己那个"修复"又引出一个全新故障
模式的转折。
## 为什么这件事值得写
密码管理器是唯一一个不能掉链子的应用。当我正和客户谈到一半、需要服务器凭据
的时候,转圈的加载动画是绝对不能接受的。症状看起来毫无规律——有时正常、有时
504——但真正的根源只是一个缺失的文件,外加叠在上面的两个错误。如果你也在
Docker 里自托管 Passbolt,这三个坑里大概有一个正在前面等你。
## 症状:cron 卡住,界面跟着遭殃
Passbolt 每分钟跑一个 cron 任务处理它的邮件队列——找回链接、分享通知、测试
邮件。正常情况下一秒以内跑完。我这边每次要花 30–60 秒。
这为什么要紧?Passbolt 靠一组 PHP-FPM worker 提供 Web 界面。当 cron 任务在
发邮件时卡住,就会占住一个 worker。卡住的 cron 攒多了,worker 池就被耗尽——
普通页面请求开始排队,nginx 超时,于是你看到 504。
日志把话说得很明白:
```text
not starting: job is still running since ... (1m elapsed)
```
## 根源一:GPG 指纹为 null
Passbolt 用服务器 GPG 密钥加密存储 SMTP 设置。要用这些设置,它必须知道用哪把
密钥解密——这个值来自 `passbolt.gpg.serverFingerprint`,由配置文件
`/etc/passbolt/passbolt.php` 提供。
这个文件不存在。
于是指纹解析成 `null`,SMTP 设置的 GPG 解密失败,每个想发邮件的 cron 迭代都
卡在这个失败上。健康检查把它摊开来给你看:
```text
SMTP Setting errors: ... setDecryptKeyFromFingerprint():
Argument #1 ($fingerprint) must be of type string, null given
```
队列里还有一封名副其实的死邮件——一条几个月前就耗尽重试次数、永远发不出去的
`SMTP timeout` 记录。它每一分钟都被重新扫描一遍,雪上加霜。
```sql
DELETE FROM email_queue WHERE sent = 0 AND send_tries >= 4;
```
## 根源二:没活下来的修复(复发)
这里才是真正扎心的地方。这个 bug 我此前其实修过一次——通过在*容器内部*创建
`/etc/passbolt/passbolt.php`。两个星期后卡死回来了,文件也消失了。
原因:维护时我重建过容器,而 Docker 容器的可写层**天生就是临时的**。写进容器
文件系统(而不是挂载卷)的任何东西,都会在重建时消失。我的修复有效期和容器
一样长,而不是和部署一样长。
真正持久的三件套,全部活在*容器之外*:
1. 一个位于宿主机上的配置文件,通过 bind mount 挂进镜像的
`/etc/passbolt/passbolt.php`:
```php
<?php
return [
'App' => [
'fullBaseUrl' => env('APP_FULL_BASE_URL', 'https://pass.example.com'),
],
'passbolt' => [
'gpg' => [
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
],
],
];
```
2. compose 里的 bind mount——让它在重建后存活的关键:
```yaml
volumes:
- /volume1/docker/passbolt/session-config/passbolt.php:/etc/passbolt/passbolt.php:ro
```
3. 正确的环境变量名。我之前一直用 `PASSBOLT_GPG_SERVER_FINGERPRINT`,而
Passbolt 会静默忽略它。真正的变量是 `PASSBOLT_GPG_SERVER_KEY_FINGERPRINT`
——而且它要的是**完整的 40 位指纹**,不是截短的:
```yaml
PASSBOLT_GPG_SERVER_KEY_FINGERPRINT: "A3DD9B762D48722C10CF88DDB5372E46A54E1419"
```
## 根源三:我自己造成的重定向循环
卡死修好之后,我把 Passbolt 从 5.14.3 升到 5.15.0。然后,打开
`https://pass.example.com/`,浏览器直接报:
```text
ERR_TOO_MANY_REDIRECTS
```
我以为是升级弄坏了什么。其实没有。罪魁祸首是我留在配置文件里的 `ssl.force`:
```php
'passbolt' => [
'ssl' => [
'force' => true,
],
],
```
Passbolt 的 SSL 强制中间件会检查请求的 scheme,而在 TLS 终止型反向代理后面,
这个 scheme **永远是 `http`**——因为代理(nginx)终结 TLS 连接后,把纯 HTTP
转发给容器。中间件看到 `http`,尽职尽责地重定向到 `https://同一条URL`,nginx
再把它当 `http` 转回来,循环往复,永无止境。
解法在于想清楚到底谁负责 SSL。如果反向代理已经在边缘强制 HTTPS,应用就**不该**
再强制一遍。把 `ssl.force` 从配置里删掉(把 TLS 交给 nginx),循环立刻消失。
这条通用规则不止适用于 Passbolt,所有挂在反向代理后面的应用都一样:**要么代理
终结 TLS,要么应用自己终结——两者永不兼得**。代理已经在处理的时候,千万别在
应用里再开 `ssl.force`。
## 换了我现在会怎么做
1. **配置放进卷,不放容器。** 我一旦用 `docker exec` 写文件,就等于答应了下一次
重建时重做一遍。正确反应应当是:这东西需要扛过 `docker compose up` 吗?需要
就进 bind mount,而不是可写层。
2. **先查真实的变量名。** 那个指纹环境变量让我多走了弯路,因为我信了一个 Passbolt
根本不认的名字,而它不认的时候也不吭声。当某个配置值"不起作用"时,先去上游
文档核对准确的 key,再怀疑别的。
3. **测公网 URL,别只信健康检查。** 健康检查每次通过,却完全没发现重定向循环——
因为它从不走真实的公网代理路径。一条两秒的 `curl -I` 就能立刻暴露问题。
## 结果
卡死消失了:cron 从 30–60 秒降到 1 秒以内跑完,邮件队列清空,Web 界面毫秒级
响应而不是 504。Passbolt 现在跑着最新的 5.15.0,所有 GPG 和 SMTP 健康检查全绿
——而且这个修复是写成能扛过下一次容器重建的。
值得带走的经验:一个"偶尔卡死"的自托管服务,很少是真正的谜团。通常就是一个
缺失的配置值,只是以三种不同的方式浮出水面。修配置,不要修症状。
---
## 想要稳定的自托管基础设施?
如果你跑着 Passbolt、密码管理器、邮箱或仪表盘之类的服务,而且它们时不时抽风——界面
卡死、504、重启就消失的灵异问题——这正是我诊断和修复的范围。我熟悉 Docker、
反向代理和各种自托管技术栈,交付时会把一切整理成文档,让下一个人(或未来的你)
不用靠猜。
联系我:[[email protected]](mailto:[email protected]) 或 WhatsApp
[+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做的事情。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File"
description: "My self-hosted Passbolt returned 504s for weeks: a null GPG fingerprint stalled email delivery, a container recreate wiped the fix, and ssl.force behind a proxy caused a redirect loop."
pubDate: 2026-01-07
updatedDate: 2026-09-13
category: devops
tags: [passbolt, docker, gpg, smtp, reverse-proxy, portainer]
ogImage: /og/passbolt-hang-three-failure-modes.jpg
banner: /banners/passbolt-hang-three-failure-modes.jpg
---
I run Passbolt — an open-source password manager for teams — on a Synology
NAS in Docker, behind an nginx reverse proxy. For a stretch of weeks it
developed a nasty habit: the web UI would hang, return `504 Gateway Timeout`,
and refuse to hand over a password right when I needed it. This is the story
of chasing that hang to its root — and the twist where my own "fix" caused a
brand-new failure mode.
## Why it matters
A password manager is the one app you cannot afford to be flaky. When I'm
mid-conversation with a client and need a server credential, a loading spinner
is not an option. The symptoms looked random — sometimes up, sometimes
504 — but the cause turned out to be a single missing file, plus two mistakes
stacked on top of it. If you self-host Passbolt in Docker, one of these three
is probably in your future.
## The symptom: cron hangs, and the UI follows
Passbolt runs a cron job every minute that processes its email queue — account
recovery links, share notifications, test messages. Normally it completes in
under a second. Mine was taking 30–60 seconds every single run.
Why does that matter? Behind the scenes Passbolt serves the web UI through a
pool of PHP-FPM workers. When the cron job stalls on email sending, it ties up
a worker. Enough stalled crons, and the pool is exhausted — so ordinary page
requests queue up, nginx times out, and you get the 504.
The logs told the story plainly:
```text
not starting: job is still running since ... (1m elapsed)
```
## Root cause 1: a null GPG fingerprint
Passbolt stores its SMTP settings encrypted with the server's GPG key. To use
them, it needs to know which key to decrypt with — a value read from
`passbolt.gpg.serverFingerprint`. That value comes from a config file,
`/etc/passbolt/passbolt.php`.
That file didn't exist.
Without it, the fingerprint resolved to `null`, GPG decryption of the SMTP
settings failed, and every cron iteration that tried to send mail hung on the
failure. The healthcheck surfaced it as:
```text
SMTP Setting errors: ... setDecryptKeyFromFingerprint():
Argument #1 ($fingerprint) must be of type string, null given
```
There was also one genuinely dead email in the queue — an old `SMTP timeout`
record that had exhausted its retries months ago and would never send. It got
re-scanned every minute, adding to the stall.
```sql
DELETE FROM email_queue WHERE sent = 0 AND send_tries >= 4;
```
## Root cause 2: the fix that didn't survive (the relapse)
Here's the part that stung. I had fixed this exact bug once before — by
creating `/etc/passbolt/passbolt.php` *inside the container*. Two weeks later
the hang came back, and the file was gone.
The reason: I'd recreated the container during maintenance, and a Docker
container's writable layer is **ephemeral by design**. Anything written to the
filesystem inside the container (rather than into a mounted volume) vanishes on
recreate. My fix had the shelf life of the container, not of the deployment.
The durable version is three pieces that all live *outside* the container:
1. A config file on the host, bind-mounted into the image at
`/etc/passbolt/passbolt.php`:
```php
<?php
return [
'App' => [
'fullBaseUrl' => env('APP_FULL_BASE_URL', 'https://pass.example.com'),
],
'passbolt' => [
'gpg' => [
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
],
],
];
```
2. The bind mount in compose — the part that makes it survive recreate:
```yaml
volumes:
- /volume1/docker/passbolt/session-config/passbolt.php:/etc/passbolt/passbolt.php:ro
```
3. The correct environment variable name. I'd been using
`PASSBOLT_GPG_SERVER_FINGERPRINT`, which Passbolt silently ignores. The
real variable is `PASSBOLT_GPG_SERVER_KEY_FINGERPRINT` — and it wants the
**full 40-character** fingerprint, not a truncated one:
```yaml
PASSBOLT_GPG_SERVER_KEY_FINGERPRINT: "A3DD9B762D48722C10CF88DDB5372E46A54E1419"
```
## Root cause 3: the redirect loop I caused myself
With the hang fixed, I upgraded Passbolt from 5.14.3 to 5.15.0. Then, browsing
to `https://pass.example.com/`, the browser threw:
```text
ERR_TOO_MANY_REDIRECTS
```
I assumed the upgrade had broken something. It hadn't. The culprit was
`ssl.force`, which I'd left in my config file:
```php
'passbolt' => [
'ssl' => [
'force' => true,
],
],
```
Passbolt's SSL-force middleware inspects the request's scheme, and behind a
TLS-terminating reverse proxy that scheme is **always `http`** — because the
proxy (nginx) terminates the TLS connection, then forwards plain HTTP to the
container. So the middleware sees `http`, dutifully redirects to
`https://same-url`, nginx forwards it back as `http`, and the cycle repeats
forever.
The fix is to understand who owns SSL. If your reverse proxy already enforces
HTTPS at the edge, the app must **not** try to force it again. Removing
`ssl.force` from the config (leaving TLS to nginx) broke the loop immediately.
The general rule, which applies well beyond Passbolt to any app behind a
reverse proxy: **either** the proxy terminates TLS, **or** the app does — never
both, and never configure `ssl.force` in the app while the proxy already
handles it.
## What I'd do differently
1. **Config lives in volumes, not in containers.** The moment I wrote a file
into `docker exec`, I'd committed to redoing it on the next recreate. The
reflex should be: does this need to survive a `docker compose up`? Then it
goes in a bind mount, not the writable layer.
2. **Read the actual variable names.** The fingerprint env var cost me a clean
diagnosis because I trusted a wrong name that Passbolt ignored without
complaint. When a config value "doesn't work," check the upstream reference
for the exact key before assuming anything else is wrong.
3. **Test the public URL, not just the healthcheck.** The healthcheck passed
every time and missed the redirect loop entirely because it never exercised
the real public path through the proxy. A two-second `curl -I` against the
live URL would have caught it instantly.
## The result
The hang is gone: cron completes in under a second (down from 30–60s), the
email queue is clear, and the web UI responds in milliseconds instead of
504-ing. Passbolt is now running the current 5.15.0, with every GPG and SMTP
healthcheck green — and the fix is written in a way that survives the next
container recreate.
The lesson worth carrying: a self-hosted service that "sometimes hangs" is
rarely a mystery. It's usually one missing config value, surfaced three
different ways. Fix the config, not the symptom.
---
## Want stable self-hosted infrastructure for your business?
If you run services like Passbolt, a password manager, email, or a dashboard
and they're flaky — hanging UIs, 504s, mysteries that vanish on restart — I
diagnose and fix exactly these problems. I work with Docker, reverse proxies,
and self-hosted stacks, and I hand everything back documented so the next
person (or future you) isn't left guessing.
Reach me at [[email protected]](mailto:[email protected]) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
````
---
## 文章 05 / 09:当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26
- **Slug**: `patching-workbench-26-for-mariadb`
- **英文标题**: When Your Database Client Lies to You: Patching Workbench 26 for MariaDB
- **分类**: `devops` | **发布日期**: 2026-09-19
- **线上地址**: https://blog.hoelee.com/posts/patching-workbench-26-for-mariadb/ (EN) | https://blog.hoelee.com/posts/zh/patching-workbench-26-for-mariadb/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26"
description: "MySQL Workbench 26.7.0 能连上 MySQL 8.4,却在 MariaDB 上报出一个毫无意义的 TypeError。三处修补,以及为什么版本号不再能说明它是什么引擎。"
pubDate: 2026-09-19
category: devops
tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, self-hosting]
ogImage: /og/zh-patching-workbench-26-for-mariadb.jpg
banner: /banners/patching-workbench-26-for-mariadb.jpg
draft: false
---
我用一台 Windows 机器管着四个数据库服务:两个 MySQL 8.4、一个本地安装、还有一个跑在局域网 CyberPanel 后面的 MariaDB 10.11。全新的 MySQL Workbench 26.7.0 里,三个连接都正常打开,只有一个不行。
它给我的报错是这样的:
```
TypeError: DbModuleSession.on_session_message() missing 1 required positional argument: 'result'
```
这句话里没有任何关于数据库、网络、凭据或 MariaDB 的信息。它是一个客户端内部的 Python 参数个数错误。而这是我今年追过的最具误导性的一条报错——因为它**根本不是那个 bug**。它是**错误报告器在试图报告 bug 时自己崩掉了**。
这篇文章记录从这条报错一路查到 Oracle 自家发行代码里三处修补的过程,以及导致这三处的同一个设计假设。
## 为什么值得看
如果你身边同时放着不止一种数据库引擎——这边 MySQL、那边 MariaDB——你迟早会碰到这一类故障,而且报错文本会主动把你带偏。即使你从不开 Workbench,下面三点也有价值:
1. **一条垃圾报错本身就是一个线索。** 当工具报出一个结构上不可能的错误时,先怀疑它的错误处理路径,而不是它的业务逻辑。
2. **`major >= 8` 不是可靠的「这是不是 MySQL」判断。** MariaDB 报的版本号是 10.x 和 11.x。很多软件在这里栽跟头。
3. **版本号冲突是系统性问题**,不是某个应用里的一次性 bug——这也正是同一个修补必须在三个文件里各做一遍的原因。
## 环境
四个连接,以及哪一个坏了:
| 服务器 | 引擎 | 版本 | Workbench 26.7.0 |
|---|---|---|---|
| DSM NAS | MySQL | 8.4 | 正常 |
| unRaid | MySQL | 8.4 | 正常 |
| localhost(后来已移除) | MySQL | 8.4.6 | 正常 |
| CyberPanel 虚拟机 | **MariaDB** | **10.11.19** | **失败** |
事后看规律很明显:MySQL 8.4 的全部正常,唯一的 MariaDB 不正常。但「它是唯一的异类」不是诊断结论,当时我也不打算就此相信它。
## 我先试了什么,以及为什么都错了
**猜测一:TLS。** 连接配置里是 `useSSL = 1`,而 `sslCA`、`sslCert`、`sslKey` 全为空。对 MySQL 8.x 这没问题——它会自动生成证书。对 MariaDB 就未必了。
这个猜测结果**对了一半**,后面我会回头讲,因为它是一个真实存在的第二个故障,而且会报出**另一个**错误。
**猜测二:凭据或权限。** 也是错的,而且可以用很低成本排除。Workbench 26 自带一个 MySQL Shell 可执行文件。手工拿那个文件去连同一台主机,就能把「除 Workbench 自身代码之外的一切」都测一遍:
```bash
MSH="/c/Program Files/MySQL/MySQL Workbench/resources/app/shell/bin/mysqlsh.exe"
"$MSH" --sql --uri "admin:[email protected]:3306" \
-e "select current_user(), version();"
```
```
current_user() version()
admin@% 10.11.19-MariaDB-ubu2404
```
连上了。同一个可执行文件、同一台主机、同一组凭据——命令行能通,图形界面不能。这一下就把范围收得很窄:**网络、服务器、权限、密码全都没问题。** 坏掉的东西在 Workbench 自己的会话处理代码里。
如果你只从这篇文章带走一个技巧,就带这个。在调试一个客户端之前,先用它自带的底层工具直接连一次,把「这东西坏了」和「这个界面坏了」分开。
## 把真正的报错从里面挖出来
Workbench 26 的日志位置变了。有用的两个:
```
%APPDATA%\MySQL Workbench\MySQL Workbench-electron.log # 应用层,含会话 stderr
%APPDATA%\MySQL\mysqlsh\mysqlsh.log # 每次连接的日志
```
连接日志里有个奇怪的地方。会话**连上了**,然后就停住了:
```
Info: Connecting to MySQL at: [email protected]:3306
Info: main: tid=0: CONNECTED: 192.168.1.124:3306
Warning: Could not enable session tracking for sql_mode: Session tracker not supported by server
```
`CONNECTED`,然后什么都没有。没有报错,也没有断开。连接成功了,会话在之后静默死亡。
同时 Electron 日志里躺着开头那条 `TypeError`,重复了五十一次。这个重复次数就是线索:一个错误处理器每次失败都被调用一次,每次都崩。
### bug 就在错误处理器里
出自 `gui_plugin\core\dbms\DbSession.py`:
```python
def terminate_thread(self):
self._close_database(True)
if self.thread_error is not None:
logger.error(f"Thread {self._id} exiting with code {self.thread_error}")
self._message_callback("ERROR", self.thread_error) # 两个参数
self._term_complete.set()
```
而它调用的回调,在 `DbModuleSession.py` 里:
```python
def on_session_message(self, type, message, result, request_id=None):
```
三个必需参数。调用只传了两个。所以**每当一个会话线程死亡,负责上报它的代码也跟着死**——于是 `TypeError` 顶替了原本的错误,出现在你面前。
这就是这个 bug 难看见的全部原因。那个会话里的每一个真实错误,都被上报路径上的一次崩溃吞掉了。修好处理器并不能修好底层问题;它让底层问题**可见**。而你需要的就是这个。
正确的调用写法就在隔壁文件 `DbMySQLSession.py` 里摆着:
```python
self._message_callback(
"PENDING",
"Connection lost, reconnecting session...",
None,
self._current_task_id,
)
```
所以修法就是照抄它:
```python
self._message_callback(
"ERROR", str(self.thread_error), None, self._current_task_id
)
```
重启,重试,报错终于说了实话:
```
MySQL Error 1193 (HY000): Unknown system variable 'gtid_mode'
```
这才有点进展。这是一个真实的错误,而且指向一个真实的原因。
## 真正的根因
`gtid_mode` 是**MySQL 专有**的系统变量。MariaDB 没有它——人家有 `gtid_strict_mode`、`gtid_binlog_pos`、`gtid_current_pos` 等等,但没有叫 `gtid_mode` 的东西。
两边都可以在一分钟之内验证:
```bash
# MariaDB 上——列表里根本找不到 gtid_mode
"$MSH" --sql --uri "..." -e "show variables like 'gtid%';"
# MySQL 8.4 上——它在这儿
"$MSH" --sql --uri "..." -e "select @@gtid_mode;"
```
出问题的代码,在 `gui_plugin\migration\lib\backend\replication.py`:
```python
if log_bin:
if session.nversion >= 56000:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
```
MariaDB 10.11.19 的 `session.nversion` 是 `101119`。它大于 `56000`,于是守卫放行,于是查询发出,于是 MariaDB 拒绝,于是会话死亡。
**而它底下的设计缺陷在这里。** 这个守卫假设版本号能告诉你对面是哪个引擎。它不能。MariaDB 和 MySQL 共用同一套版本号体系:
| 引擎 | 报出的版本 |
|---|---|
| MySQL 5.7 | `5.7.x` |
| MySQL 8.0 / 8.4 | `8.0.x` / `8.4.x` |
| MariaDB 10.6 | `10.6.x` |
| MariaDB 10.11 | `10.11.x` |
| MariaDB 11.x | `11.x` |
在生产使用中,**每一个 MariaDB 版本号都高于每一个 MySQL 版本号**。所以 `nversion >= 56000` 对 MariaDB 为真,`>= 80000` 同样为真。任何写成 MySQL 版本比较的功能开关,遇到 MariaDB 都会放行,然后发过去它看不懂的语法。
可靠的判别依据是 `@@version_comment`,在 MariaDB 上返回 `mariadb.org binary distribution`,在 MySQL 上是别的值。Workbench 甚至**已经取到了**这个值——它把它存成 `VERSION_COMMENT`,就摆在 `VERSION_INFO` 旁边——然后完全没用它来做这个判断。
这不是某一处的粗心,而是一个错误假设被一致地贯彻了下去,所以同一个 bug 会在三个地方各出现一次。
## 三处修补,同一个错误
知道要找什么之后,剩下的都是机械工作。三处都是同一个修法:让那个 MySQL 专有的探测变得可容错。
### 修补 1 — `replication.py`
```python
if session.nversion >= 56000:
try:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
except Exception:
# MariaDB 没有 gtid_mode 系统变量;忽略并继续。
gtid_mode = None
```
值得注意:**同一个查询**在代码库另一处(`checks.py`)被调用时,**本来就包在 `try/except` 里**。Oracle 是知道这个调用在某些服务器上会失败的,只是没把这个认知用到这里。只有在 binlog 打开时才触发,而这台机器上恰好是开着的。
### 修补 2 — `DbSession.py`
就是前面说的错误处理器。严格来说这修的是上报,不是 MariaDB 兼容性——但正是它让修补 1 和修补 3 变得可被发现。要早点做。
### 修补 3 — `DbMySQLSessionSetupTasks.py`
下一个故障由修补 2 暴露出来:
```
MySQL Error 1193: Unknown system variable 'explain_json_format_version'
```
它在每次连接都会跑的一个会话初始化任务里:
```python
version = self.get_data(common.MySQLData.VERSION_INFO)
major, minor, _ = version.split(".", 2)
if int(major) < 8 or int(major) == 8 and int(minor) < 3:
return
self.execute("SET SESSION explain_json_format_version=2")
```
`@@version` 是 `10.11.19-MariaDB-ubu2404`。切开来:`major = 10`、`minor = 11`。`int(major) < 8` 吗?不是。守卫放行。`SET SESSION explain_json_format_version=2` 是 MySQL 专有语法。又是错误 1193。
同样的修法:
```python
try:
self.execute("SET SESSION explain_json_format_version=2")
except Exception:
pass
```
### 另一个独立的 TLS 故障
还记得猜测一吗?那是个真问题,只不过是另一个问题——而且它在**认证之前**就失败,所以看起来像凭据问题。
你的连接配置里可能存着 `useSSL = 1` 却没有任何证书。对 MySQL 8.x 无害。对关掉了 TLS 的 MariaDB,握手直接被拒:
```bash
"$MSH" --sql --uri "..." -e "select @@have_ssl;"
# have_ssl: DISABLED
"$MSH" --sql --uri "...?ssl-mode=REQUIRED" -e "select 1;"
# ERROR 2026 (HY000): SSL connection error:
# SSL is required but the server doesn't support it
```
修法是 `%APPDATA%\MySQL\Workbench\connections.xml` 里的一个值——只把那一条连接的 `useSSL` 改成 `0`。在可信局域网里,明文连接没问题。改之前先备份文件。
两个独立故障,两种不同的错误特征。脑子里要分开:**TLS 失败发生在登录之前;`1193` 系列失败发生在之后。** 如果服务器拒绝你的握手,凭据根本没机会被验证。
## 怎么给 Program Files 下的文件打补丁
Workbench 的文件是**只读**的,而这一点以值得记录的方式坑了我两次:写入会**静默失败**。PowerShell 的 `WriteAllText` 报告成功,而磁盘上的文件毫无变化——大小一样、时间戳一样。对内容做 `.Replace()` 也报告成功却什么都没做,原因是行尾符差异。
真正有效的做法:
1. **原地备份**——`Copy-Item $f "$f.bak"`,让原始文件待在你的版本旁边。
2. **清除只读属性**并且*确认*:`attrib -R`,然后用 `(Get-Item $f).Attributes` 检查 `ReadOnly` 确实没了。
3. **按行号打补丁**,不要做字符串替换——整行读出、拼接、整行写回。这样对 CRLF 和引号差异完全免疫。
4. 事后**恢复只读**:`attrib +R`。
5. **删掉过期的 `__pycache__` `.pyc`**——否则 Python 会继续跑旧的编译模块,然后你会得出「补丁没用」的结论。这条最容易忘,而且产生的结果非常令人困惑。
6. 重启前**验证语法**:对文件跑 `ast.parse()`,JS 则用 `node --check`。
然后重启应用,让修补后的源码被加载。
## 交回之前先测补丁
没被实际跑过的补丁只是猜测。由于故障取决于服务器行为,正确的做法是模拟服务器,而不是对着界面满怀希望地连:
```python
class FakeSession:
nversion = 101119 # MariaDB 10.11.19
def run_sql(self, q):
if "gtid_mode" in q:
raise Exception("1193 (HY000): Unknown system variable 'gtid_mode'")
if "@@log_bin, @@binlog_format" in q:
return FakeResult((1, "ROW")) # binlog 打开才会走到这条路径
```
用 `exec()` 载入修补后的函数,拿这个假会话调用,断言它正常返回而不是抛异常。五分钟的测试,把「我觉得这样行」变成「这条路径被覆盖了」。它也在你去怪应用之前,先确认了补丁确实被加载了。
## 结果
现在 Workbench 26.7.0 里四个连接全部可用,包括那台 MariaDB。Oracle 发行代码里三个文件被修补,每个旁边都留了 `.bak`:
| 文件 | 改动 |
|---|---|
| `replication.py` | 给 `select @@gtid_mode` 加容错 |
| `DbSession.py` | 修正错误处理器的参数个数 |
| `DbMySQLSessionSetupTasks.py` | 给 `SET SESSION explain_json_format_version` 加容错 |
有一点我不打算粉饰:这些是对一个闭源应用自带代码打的补丁,不是对上游的贡献。它们会**被下一次 Workbench 更新覆盖**,而故障清单可能不止三条——我在连接能用之后就停了。如果升级后再次出问题,那就准备重走一遍这条路径,而且可能发现同一错误的第四个实例。
## 我会怎么做得不一样
- **先用客户端自带的底层工具连一次。** 用 Workbench 自己安装目录里的 `mysqlsh`,一条命令就解决了「是不是服务器的问题」。我应该从这里开始,而不是先去猜 TLS。
- **把不可能出现的报错当成证据。** `on_session_message() missing 1 required positional argument` 不是数据库错误。当文本与领域不匹配时,怀疑错误路径本身——并且优先修它,因为它挡住了你看向其他一切的视线。
- **读隔壁的文件。** `gtid_mode` 的容错和正确的 `_message_callback` 签名,在同一个代码库里都另有正确写法。问题不是不知道正确做法,而是应用得不一致。
- **把版本号判断当成一种异味。** 任何用 `if version >= N` 来决定*面对哪种数据库引擎*的分支,都值得警惕。要问 MariaDB 报什么,而不是问 MySQL 报什么。
- **保留旧客户端。** Workbench 8.0.x 没有这个探测,连 MariaDB 毫无怨言。用 26.x 管 MySQL 8.4、用 8.0.x 管 MariaDB,是一个完全合理的策略——也正因为如此,这次只是一个晚上的智力题,而不是被堵住的一天。
版本号是最容易比较的东西,也是最容易被比较错的东西。两个项目共用一套编号体系,并不等于它们共用一套功能集。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB"
description: "MySQL Workbench 26.7.0 connects to MySQL 8.4 but fails on MariaDB with a useless TypeError. Three patches, and why version numbers stopped meaning what you think."
pubDate: 2026-09-19
category: devops
tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, self-hosting]
ogImage: /og/patching-workbench-26-for-mariadb.jpg
banner: /banners/patching-workbench-26-for-mariadb.jpg
draft: false
---
I run four database servers from one Windows machine: two MySQL 8.4 instances, one local install, and a MariaDB 10.11 box behind CyberPanel on my LAN. Three of them opened fine in the shiny new MySQL Workbench 26.7.0. One didn't.
The error it gave me was this:
```
TypeError: DbModuleSession.on_session_message() missing 1 required positional argument: 'result'
```
That message contains no information about databases, networking, credentials, or MariaDB. It's a Python argument-count error inside the client. And it was the single most misleading error message I've chased this year — because it wasn't the bug. It was **the error reporter crashing while trying to report the bug**.
This post is the trail from that message to three patches in Oracle's own shipping code, and the underlying design assumption that causes all three.
## Why this matters
If you keep more than one database engine around — MySQL here, MariaDB there — you'll hit this class of failure eventually, and the error text will actively mislead you. Three things in this post are worth knowing even if you never open Workbench:
1. **A garbage error message is itself a finding.** When a tool reports something structurally impossible, suspect its error path before its logic.
2. **`major >= 8` is not a reliable "is this MySQL?" test.** MariaDB reports version 10.x and 11.x. A lot of software gets this wrong.
3. **The version-number collision is systemic**, not a one-off bug in one app — it's why the same fix had to be applied three times in three files.
## The setup
The four connections, and which one broke:
| Server | Engine | Version | Workbench 26.7.0 |
|---|---|---|---|
| DSM NAS | MySQL | 8.4 | works |
| unRaid | MySQL | 8.4 | works |
| localhost (since removed) | MySQL | 8.4.6 | works |
| CyberPanel VM | **MariaDB** | **10.11.19** | **fails** |
The pattern is obvious in hindsight: everything MySQL 8.4 works, the one MariaDB doesn't. But "it's the odd one out" is not a diagnosis, and I didn't trust it yet.
## What I tried first, and why it was wrong
**Theory 1: TLS.** The connection profile had `useSSL = 1` with empty `sslCA`, `sslCert`, `sslKey`. For MySQL 8.x that's fine — it auto-generates certificates. For MariaDB, maybe not.
This one turned out to be **half right**, and I'll come back to it, because it's a genuine second fault that produces a *different* error.
**Theory 2: credentials or grants.** Also wrong, and I could disprove it cheaply. Workbench 26 ships its own MySQL Shell binary. Running that binary by hand against the same host is a direct test of everything except Workbench's own code:
```bash
MSH="/c/Program Files/MySQL/MySQL Workbench/resources/app/shell/bin/mysqlsh.exe"
"$MSH" --sql --uri "admin:[email protected]:3306" \
-e "select current_user(), version();"
```
```
current_user() version()
admin@% 10.11.19-MariaDB-ubu2404
```
It connects. Same binary, same host, same credentials — works from the command line, fails from the GUI. That narrows it decisively: **the network, server, grants, and password are all fine.** Whatever is broken lives in Workbench's own session-handling code.
If you take one technique from this post, take that one. Before debugging a client, drive the client's own underlying tool directly to split "the thing is broken" from "this UI is broken."
## Getting the real error out of it
Workbench 26 stores its logs somewhere new. The useful ones:
```
%APPDATA%\MySQL Workbench\MySQL Workbench-electron.log # app-level, session stderr
%APPDATA%\MySQL\mysqlsh\mysqlsh.log # per-connect log
```
The connect log showed something odd. The session *connected*, then stopped:
```
Info: Connecting to MySQL at: [email protected]:3306
Info: main: tid=0: CONNECTED: 192.168.1.124:3306
Warning: Could not enable session tracking for sql_mode: Session tracker not supported by server
```
`CONNECTED`, and then nothing. No error, no disconnect. The connection succeeded and the session died silently afterwards.
Meanwhile the Electron log had the `TypeError` from the top of this post, repeated fifty-one times. That repetition is the clue: an error handler being called once per failure, crashing every time.
### The bug was in the error handler
From `gui_plugin\core\dbms\DbSession.py`:
```python
def terminate_thread(self):
self._close_database(True)
if self.thread_error is not None:
logger.error(f"Thread {self._id} exiting with code {self.thread_error}")
self._message_callback("ERROR", self.thread_error) # two arguments
self._term_complete.set()
```
And the callback it's calling, in `DbModuleSession.py`:
```python
def on_session_message(self, type, message, result, request_id=None):
```
Three required parameters. The call passes two. So **whenever a session thread dies, the code that reports it dies too** — and the `TypeError` replaces the original error on its way to the user.
That's the whole reason this bug was hard to see. Every real error in that session was being swallowed by a crash in the reporting path. Fixing the handler doesn't fix the underlying problem; it makes the problem **visible**. Which is what you need.
The correct call shape is right there in a sibling file, `DbMySQLSession.py`:
```python
self._message_callback(
"PENDING",
"Connection lost, reconnecting session...",
None,
self._current_task_id,
)
```
So the fix is to match it:
```python
self._message_callback(
"ERROR", str(self.thread_error), None, self._current_task_id
)
```
Restart, retry, and the error message finally tells the truth:
```
MySQL Error 1193 (HY000): Unknown system variable 'gtid_mode'
```
Now we're somewhere. That's a real error, and it names a real cause.
## The actual root cause
`gtid_mode` is a **MySQL-only** system variable. MariaDB doesn't have it — it has `gtid_strict_mode`, `gtid_binlog_pos`, `gtid_current_pos`, and others, but nothing named `gtid_mode`.
You can confirm both halves in one minute:
```bash
# On MariaDB — no gtid_mode anywhere in the list
"$MSH" --sql --uri "..." -e "show variables like 'gtid%';"
# On MySQL 8.4 — there it is
"$MSH" --sql --uri "..." -e "select @@gtid_mode;"
```
The offending code, in `gui_plugin\migration\lib\backend\replication.py`:
```python
if log_bin:
if session.nversion >= 56000:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
```
`session.nversion` for MariaDB 10.11.19 is `101119`. That's greater than `56000`, so the guard passes, so the query runs, so MariaDB rejects it and the session dies.
**And here is the design flaw underneath it.** The guard assumes a version number can tell you which engine you're talking to. It can't. MariaDB and MySQL share a single version register:
| Engine | Reports as |
|---|---|
| MySQL 5.7 | `5.7.x` |
| MySQL 8.0 / 8.4 | `8.0.x` / `8.4.x` |
| MariaDB 10.6 | `10.6.x` |
| MariaDB 10.11 | `10.11.x` |
| MariaDB 11.x | `11.x` |
Every MariaDB version is numerically *above* every MySQL version in production use. So `nversion >= 56000` is true for MariaDB. So is `>= 80000`. Any feature-gate written as a MySQL version comparison will pass against MariaDB and then send it syntax it doesn't understand.
The reliable discriminator is `@@version_comment`, which returns `mariadb.org binary distribution` on MariaDB and something else on MySQL. Workbench even **fetches** that value — it stores it as `VERSION_COMMENT` right next to `VERSION_INFO` — and then never uses it to make this decision.
This is not sloppiness in one place. It's a wrong assumption applied consistently, which is why the same bug appeared three separate times.
## Three patches, same mistake
Once I knew what to look for, the rest were mechanical. All three are the same fix: make the MySQL-only probe tolerant.
### Patch 1 — `replication.py`
```python
if session.nversion >= 56000:
try:
gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0]
except Exception:
# MariaDB has no gtid_mode system variable; ignore and continue.
gtid_mode = None
```
Worth noting: the *same* query is called elsewhere in the codebase, in `checks.py`, and **there it's already wrapped in `try/except`**. Oracle knew this call could fail on some servers. They just didn't apply that knowledge here. Only triggers when binlog is enabled, which it is on this box.
### Patch 2 — `DbSession.py`
The error handler from earlier. Strictly this fixes reporting, not MariaDB compatibility — but it's the patch that made patches 1 and 3 findable. Do it early.
### Patch 3 — `DbMySQLSessionSetupTasks.py`
Next failure, surfaced by patch 2:
```
MySQL Error 1193: Unknown system variable 'explain_json_format_version'
```
Which lives here, in a session-setup task that runs on every connect:
```python
version = self.get_data(common.MySQLData.VERSION_INFO)
major, minor, _ = version.split(".", 2)
if int(major) < 8 or int(major) == 8 and int(minor) < 3:
return
self.execute("SET SESSION explain_json_format_version=2")
```
`@@version` is `10.11.19-MariaDB-ubu2404`. Split it: `major = 10`, `minor = 11`. Is `int(major) < 8`? No. Guard passes. `SET SESSION explain_json_format_version=2` is MySQL-only. Error 1193 again.
Same fix:
```python
try:
self.execute("SET SESSION explain_json_format_version=2")
except Exception:
pass
```
### The separate TLS fault
Remember Theory 1? It was a real problem, just a different one — and it fails *before* authentication, which makes it look like a credential issue.
Your connection profile can hold `useSSL = 1` with no certificates. Against MySQL 8.x that's harmless. Against MariaDB with TLS switched off, the handshake is refused outright:
```bash
"$MSH" --sql --uri "..." -e "select @@have_ssl;"
# have_ssl: DISABLED
"$MSH" --sql --uri "...?ssl-mode=REQUIRED" -e "select 1;"
# ERROR 2026 (HY000): SSL connection error:
# SSL is required but the server doesn't support it
```
The fix is one value in `%APPDATA%\MySQL\Workbench\connections.xml` — set `useSSL` to `0` for that connection only. On a trusted LAN, a plain connection is fine. Back the file up first.
Two independent faults, two different error signatures. Worth separating in your head: **TLS failures happen before login; the `1193` failures happen after.** If the server rejects your handshake, credentials never get evaluated.
## How to patch files under Program Files
The Workbench files are shipped **read-only**, and this bit me twice in a way worth documenting: writes fail *silently*. PowerShell's `WriteAllText` reported success while the file on disk was unchanged — same size, same timestamp. A `.Replace()` on the content reported success and did nothing, because of line-ending differences.
What actually works:
1. **Back up in place** — `Copy-Item $f "$f.bak"`, so the pristine original sits next to your version.
2. **Clear the read-only flag** and *confirm* it: `attrib -R`, then check `(Get-Item $f).Attributes` no longer says `ReadOnly`.
3. **Patch by line index**, not string replacement — read all lines, splice, write all lines. Immune to CRLF and quoting differences.
4. **Restore read-only** afterwards with `attrib +R`.
5. **Delete the stale `__pycache__` `.pyc`** — otherwise Python keeps running the old compiled module and you'll conclude your patch didn't work. This one is easy to forget and produces a genuinely confusing result.
6. **Verify syntax** before relaunching: `ast.parse()` on the file, or `node --check` for JS.
Then restart the app so the patched source is loaded.
## Test the patch before you hand it back
A patched build that hasn't been exercised is a guess. Since the failure depends on server behaviour, simulate the server rather than clicking hopefully at the GUI:
```python
class FakeSession:
nversion = 101119 # MariaDB 10.11.19
def run_sql(self, q):
if "gtid_mode" in q:
raise Exception("1193 (HY000): Unknown system variable 'gtid_mode'")
if "@@log_bin, @@binlog_format" in q:
return FakeResult((1, "ROW")) # binlog ON triggers the path
```
Load the patched function with `exec()`, call it with that fake, and assert it returns instead of raising. It's a five-minute test that turns "I think this works" into "this path is covered." It also confirmed the patch was genuinely being loaded, before I blamed the app.
## The result
All four connections work from Workbench 26.7.0 now, including the MariaDB box. Three patched files in Oracle's shipping code, each with a `.bak` beside it:
| File | Change |
|---|---|
| `replication.py` | guard `select @@gtid_mode` |
| `DbSession.py` | fix error-handler argument count |
| `DbMySQLSessionSetupTasks.py` | guard `SET SESSION explain_json_format_version` |
One thing I'd flag rather than dress up: these are patches to a closed application's bundled code, not a contribution upstream. They'll be **overwritten by the next Workbench update**, and the fault list may be longer than three — I stopped when the connections worked. If it breaks again after upgrading, expect to walk the same trail and possibly find a fourth instance of the same mistake.
## What I'd do differently
- **Drive the client's own binary first.** `mysqlsh` from Workbench's own install folder settled the "is it the server?" question in one command. I should have started there instead of theorising about TLS.
- **Treat an impossible error message as evidence.** `on_session_message() missing 1 required positional argument` is not a database error. When the text doesn't match the domain, suspect the error path itself — and fix that first, because it's blocking your view of everything else.
- **Read the sibling files.** Both the `gtid_mode` guard and the correct `_message_callback` signature existed correctly elsewhere in the same codebase. The bug wasn't ignorance of the right pattern; it was inconsistent application of it.
- **Notice version-gate arithmetic as a smell.** Any `if version >= N` branch that decides *which database engine* you're facing deserves suspicion. Ask what MariaDB reports, not what MySQL reports.
- **Keep the old client installed.** Workbench 8.0.x has no such probe and connects to MariaDB without complaint. Running 26.x for MySQL 8.4 and 8.0.x for MariaDB is a legitimate strategy — and it's the reason this was an evening's puzzle rather than a blocked workday.
Version numbers are the easiest thing to compare and the easiest thing to compare wrongly. Two projects sharing a numbering scheme is not the same as two projects sharing a feature set.
````
---
## 文章 06 / 09:PHP2Shell:我管的每台机器,PHP 运行时全都中了
- **Slug**: `php2shell-patching-every-php-runtime-i-manage`
- **英文标题**: PHP2Shell: I Measured Every PHP Runtime I Manage. All of Them Were Vulnerable.
- **分类**: `devops` | **发布日期**: 2026-10-23
- **线上地址**: https://blog.hoelee.com/posts/php2shell-patching-every-php-runtime-i-manage/ (EN) | https://blog.hoelee.com/posts/zh/php2shell-patching-every-php-runtime-i-manage/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "PHP2Shell:我管的每台机器,PHP 运行时全都中了"
description: "CVE-2026-109618 与 CVE-2026-109619 是 PHP 语言本身的未授权远程代码执行,披露前就已被利用。这篇记录我在自己管理的系统上实测到的版本、先修哪里,以及真正把它关掉的那几条命令。"
pubDate: 2026-10-23
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L4-secops-cicd-dr
tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patching", "self-hosting"]
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## 这次和过去十个「PHP 高危漏洞」不一样
大多数打着 PHP 名号的高危通告,其实是应用层的 bug 借了框架的名字:某个插件、某个 CMS 路由、某个反序列化写坏了的库。它们也危险,但补法很明确——升级那一个东西,影响面就是它服务的那些页面。
**PHP2Shell 在 PHP 自己身上。** 两个漏洞:CVE-2026-109618,内存耗尽(OOM)让一次操作半途中断、请求状态只初始化了一半,而 PHP 自己的清理逻辑随后把这份无效状态当成有效的;CVE-2026-109619,陈旧全局状态跨过请求边界存活下来。两个都是 CVSS v4.0 **9.2**,默认配置下未授权即可远程执行代码。Wiz 的研究团队在 `wordpress:latest`、`drupal:latest`、`joomla:latest` 上打出了 pre-auth RCE,另外还演示了一条纯内存泄漏路径:不需要执行任何代码,就能读到其他请求的密码、token 和 API key。
三个细节决定了我要怎么花这一天:
1. **从 PHP 5.4 到 8.5 全中。** 不是「部分版本」。一个只打印 Hello World 的 `index.php` 就够被打穿——所以「我这站是自己写的,没人会盯上」不构成任何缓解。
2. **披露前就已经在被利用。** AWS 的威胁情报在蜜罐里于 **10 月 17 日**就观测到 PHP2Shell 的利用尝试,比通告早四天;Cloudflare 在补丁发布当天就报告了攻击流量,包括试图绕过其防护的变种。
3. **只有打补丁能真修。** 到处转的 `session.upload_progress.enabled = 0` 只砍掉了一条利用路径,不是这一类 bug。PHP 自己的安全公告这么说,Datadog 的分析也这么说。把它当安全带可以,当修好不行。
修复版本(2026 年 10 月 21 日发布):**8.2.35 / 8.3.36 / 8.4.27 / 8.5.12**。PHP 8.1 及更早已停止支持,永远不会有了。
## 停止猜,改成实测
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 6 个容器化的 PHP 站)。加起来大概 170 个容器。
我第一反应是看镜像 tag 就收工——这恰恰是最容易出错的判断。`php:8.3-fpm-alpine` 是浮动 tag,它可以是任何 8.3.x;而一个从未被重建过的容器,永远停在它启动那一刻的版本。**Docker 的 tag 不是版本号。** 所以我改成进入每一个候选运行时里量:在容器里、或在宿主运行时自己的二进制上跑 `php -v` 和 `php -i`。
结果:
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS 41.216.187.102 | 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 永远没有,其余有 |
| DSM | Web Station PHP 套件 | **8.2.30 / 8.3.30 / 8.4.19** | 内网 + 隧道 | 当时还没有——22 日 19:45 才出 |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——同一个 tag 被重建了 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 有——5.17.0 带 8.4.27 |
| unRaid 7.1.4 | webGUI 的 PHP | **8.3.19** | 仅内网 | **没有** |
每一行都中。这不是巧合,这就是「只给应用打补丁、不给运行时打补丁」的机群长什么样,而这类 bug 已经在代码里躺了十年。
这张表同时给出了排序。决定先修谁的是两件事:**暴露面**(谁能碰到它)和**到底有没有修复版本**。公网上跑着 WordPress 的旧运行时排第一;只在内网应答的管理界面排最后。而「厂商还没出包」是一个必须说出口的真实状态,不能拿一个不存在的更新来糊过去。
## 第一步:一条命令关掉 8 个公网站
最急的是 CyberPanel 那台:8 个公网站(其中 5 个 WordPress)全跑在同一个 `lsphp84` 8.4.25 上。
LiteSpeed 的软件源里已经有修复版本,所以第一步的收益最高、风险最低——同一个 8.4 分支内的点升级:
```bash
sudo apt update
sudo apt-get install --only-upgrade $(dpkg -l | awk '/^ii +lsphp84/{print $2}')
/usr/local/lsws/lsphp84/bin/lsphp -v # PHP 8.4.27 (litespeed)
sudo /usr/local/lsws/bin/lswsctrl restart
```
两分钟后这 8 个站全部落在修复版上。我要说清楚:**真正把洞堵住的是这一步**,不是后面那场 8.5 迁移。后面那些只是「我想停在我想要的位置」,与「安全了」无关。
验证是大多数人会跳过、却最要紧的一步。**进程名什么都证明不了**:OpenLiteSpeed 为每个 vhost 起的 PHP 子进程,`argv[0]` 就是字面上的 `lsphp`,所以 `ps aux | grep lsphp` 只会给你八个一模一样的进程,看不出各自跑的哪个二进制。要按站点用户去读 PID 背后真正的可执行文件:
```bash
ps -eo user:20,pid,args | grep '[l]sphp' | while read u p a; do
echo "$u -> $(readlink -f /proc/$p/exe)"
done | sort -u
```
```text
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
numer9393 -> /usr/local/lsws/lsphp84/bin/lsphp
...
```
## 第二步:上 8.5.12,以及那个不存在的包
CVE 关掉之后,才有资格从容地做迁移。目标是:能上 8.5 的站全部上 **8.5.12**,保留一条 8.3 作回退,把两条过时分支(8.1、8.2)删掉。
第一次安装就失败了,失败方式值得记下来。我照着 8.4 的包列表做查找替换,生成了 `lsphp85-opcache`——整个 apt 事务直接中止:
```text
E: Unable to locate package lsphp85-opcache
```
PHP 8.5 里 OPcache 已经并进主包(用 `lsphp85 -i | grep 'Zend OPcache'` 可验证 → enabled),所以那个分支在 LiteSpeed 源里根本没有 `-opcache` 子包。正确做法是直接从仓库自己的清单取包名,而不是从上一个分支抄:
```bash
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
```
还有一个坑:`lsphp -m` 会打印用法说明然后退出,因为 LSAPI 这个 SAPI 没有脚本可跑。要用 `lsphp -i | grep …`,或者同目录的 CLI 二进制 `/usr/local/lsws/lsphp85/bin/php -m`。
## 一个站的 PHP 版本到底写在哪
这部分让我意外,也是我没有去点面板下拉框的原因。这台机器上每个 vhost 带**自己的** PHP handler——站点的运行时是它自己配置文件里的一行:
```text
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
extprocessor <siteuser> {
type lsapi
address UDS://tmp/lshttpd/<siteuser>.sock
path /usr/local/lsws/lsphp85/bin/lsphp <- 就是这一行
...
}
```
`httpd_config.conf` 里那个全局的 `extProcessor lsphp`(现在仍指向 `lsphp83`)只是默认 vhost 的回退。改它对你的站点毫无影响。
所以切换 = 改一行、测配置、平滑重启:
```bash
cp -a /usr/local/lsws/conf/vhosts/<domain>/vhost.conf \
/root/vhost-backup/<domain>.vhost.conf.bak-$(date +%F-%H%M)
sed -i 's#/usr/local/lsws/lsphp[0-9]*/bin/lsphp#/usr/local/lsws/lsphp85/bin/lsphp#' \
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
/usr/local/lsws/bin/lshttpd -t # 有 ERROR 就停手
/usr/local/lsws/bin/lswsctrl restart # 平滑,约 2 秒,全部 vhost
```
面板的数据库也算运行时状态的一部分。CyberPanel 把版本记在 `cyberpanel.websiteFunctions_websites.phpSelection`;如果 vhost 改成 8.5 而库里还写着 `PHP 8.4`,那么下一次面板侧保存(或建 staging 副本)就会理直气壮地把 vhost 改回面板以为的值:
```sql
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
```
我是一个站一个站推的:先测试站,再一个低流量客户站,然后其余,最后才是我自己的主站。任何时候回滚都是同一条 `sed` 反向执行加重启,五秒钟。
## 用字节数验证,不要用状态码
「200 OK」是很弱的断言。页面片段里的 PHP fatal、某个插件悄悄不渲染了、短代码返回空字符串——这些都还是 200。所以每次切换前我都抓下渲染好的首页和它的字节数,切换后要求字节数完全一致:
```bash
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 (一致)
test.hoelee.com 200 80869 (一致)
sifumail.com 200 44861 (一致)
numerology.digikedai.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 个插件,这正是字节数校验值得做的原因。)
## 这个漏洞顺手帮我找出的旧 bug
测试时我注意到,某个客户站的 `/xmlrpc.php`、`/.env`、`/readme.html` 仍然是 200——2026 年 10 月,在一个我九月特意加固过、写了八条 deny context 的 vhost 上。
原因不在那些 context,而在我自己早前那次编辑留在文件里的两个多余 `}`。`vhost.conf` 是 `virtualHost` 块的**主体**(外层大括号在 `httpd_config.conf` 里),所以那两个括号什么也没关闭,而它们之后追加的每一条 `context` 都落在 vhost 之外,压根没有注册。配置解析器其实每一次运行都在说这件事:
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
```
我把它当噪音读了好几周。而它本身就是全部诊断。删掉那两个括号、重载之后,同样三个 URL 变成了 **403**,配置测试从两条错误变成零。
教训不是「检查括号」。而是:**会静默失效的安全控制,比没有控制更糟**——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
## 厂商补丁在我干活干到一半时来了
我本来已经把 DSM 判死了。群晖归档里最新的 `PHP8.4` 是 8.4.19-0050,所以 Web Station 接下来的几周只能靠 `session.upload_progress.enabled = 0` 加一条防火墙规则撑着。然后 10 月 22 日 19:45,群晖发布了 SA-26:14,带的是真的修复包。几小时后我把它装上了:
```text
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
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` 调用它:
```python
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # 关键就是这一行
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# 然后在容器里:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
```
`PidMode` 那一行就是全部的教训。我第一次漏了它,`synopkg` 回的是:
```json
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
```
二进制还停在旧版本,而包已经死了,状态是 `broken / install_corruption`。用 `PidMode: host` 重跑同一个安装包,走的是修复路径(`stage: installed_and_started`),一次过。光挂 `/proc`、`/sys`、`/dev`、`/run` 不够——PID 命名空间必须是宿主机的。
还有两个小坑。SPK 直链的架构写在文件名里(`PHP8.4-x86_64-8.4.27-0051.spk`),去掉 `-x86_64-` 就是 404。另外,跑 Calendar 的那个服务并不是你刚重启的那个:`synopkg restart PHP8.4` 会回你「成功」,而 Calendar 的 FPM 还在跑旧二进制,因为 Calendar 用的是 **PHP 8.2**——`pkg-synocalendar-phpfpm.service`,`ExecStart=/usr/local/bin/php82-fpm`。得按名字重启那个 unit,然后从二进制本身证明版本,而不是相信 Package Center 的显示:
```text
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
```
有一个副作用值得点名:装包会把包启动起来。那台机器上的 PHP 8.3 一直是「装了但没跑」的状态,升级把它拉了起来,等于凭空多给我一个监听面,于是我把它停回去。另外 Calendar 这个包本身还差一个版本(3.0.3-21434,公告要求 3.0.4-21435,SPK 还没上公开 CDN),但它应答请求用的运行时已经打好了——那才是要紧的部分。
## 同一个 tag 被重建过,就是另一个运行时
`phpmyadmin:5.2.3` 是上面那套「浮动 tag 不可信」的反例,也是我现在除了版本号还要看重建日期的原因。
tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月 21 日被重建,烘进去的是 PHP **8.3.36**。所以修法是 `pull` 加重建容器,不用改版本号、不用读 changelog——容器里现在报 `PHP 8.3.36 (built: Oct 21 2026)`,之前是 8.3.33。
这次 pull 的代价也不只在本地。通过 API 重建那一个 stack,顺带把 `mysql:8.4.11` 重新解析了一遍,四个容器重启了大约三十秒。没有东西坏掉,但「只动一个容器」这种改动,值得在落地过程中盯着,而不是事后才发现。
## 我说不能动的那个应用
我原本把 `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`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
这次切换还牵出第二个跟 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`(也就是站点自己用的那个二进制)之后才结束。
我看的时候队列里正好没有卡住的信。那是运气,不是设计——那两条 cron 正是已付款订单出件和收据寄出的通道。
## 那个「复制了个寂寞」的备库
升级第二台 passbolt 时,冒出了那晚最不舒服的一个发现。
主库(5.15.0)和灾备备库(5.14.0)跑的都是 PHP 8.4.24,都需要换到 5.17.0 镜像,那里面才是 8.4.27。备库起来后,它自带的每一项检查都通过——指纹在、私钥能解密、JWT 对有效——只不过它的数据库是 **34 张表,主库是 56 张**,而 `Seconds_Behind_Source: 0`。
两个数字都是真的,但都不是看上去的意思。复制从来没带上 passbolt 的写入:主库的 binlog 被 `--binlog-do-db=appdb` 过滤着,而 appdb 这个库早就不存在了,所以那台机器上的每个 binlog 都是 181 字节。空流上的 `Seconds_Behind_Source: 0` 不是健康,是打着绿勾的沉默。
修它试了三次,中间有一个真坑。`--replicate-do-db=passbolt` 会按会话默认库去过滤 DDL,于是复制过来的 `CREATE TABLE` 被丢掉,而后面跟着的插入照样匹配——SQL 线程就停在 `Table 'passbolt.x' doesn't exist`。能用的是 `--replicate-wild-do-table=passbolt.%`,它按真实表名匹配,DDL 和 DML 都管。然后全量重灌(`--source-data=2`,让 dump 自带复制坐标)、`CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1`(非 TLS 链路上不带这个参数,`caching_sha2_password` 认证不过)、再把 `read_only` 收回去。验证方式是两边对账(56 张表、2 users、542 resources、644 secrets、525 permissions),以及往主库写一行探针、看它在五秒内出现在备库上。这个故事值得单独一篇。这里的重点是:给运行时打补丁,同时也是一次审计,而这次审计发现了一套一直只是摆设的灾备方案。
## 还没修好的部分
**unRaid** 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 **8.3.19**;当前稳定版(7.3.3)甚至 7.4.0-rc.1 带的都是 **8.4.26**——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
**mailcow** 是我第一轮判断错的地方。我把它的 `phpfpm` 容器(8.2.29)归成了「仅内部」,它不是:它的 web UI 在一个公网主机名上应答。镜像被 mailcow 的发布版本钉死(`ghcr.io/mailcow/phpfpm:8.2.29-3`),所以没有单独升的余地——修复是随 mailcow 自己的版本发出的,2026-10(10 月 21 日)带的是 `phpfpm:8.2.35-1`。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。
## 如果重来一次,我会改什么
1. **把版本探测做成定时任务,而不是事故发生时才做。** 上面这套实测花了我一小时,因为是在压力下做的。一个每周在每台主机、每个容器里跑 `php -v` 的小脚本,本来能在通告落地之前就把这张表给我。
2. **把面板数据库当成运行时状态来看。** 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
3. **当天就删旧分支。** 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
4. **每次都把配置测试的输出读出来。** 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
5. **假设厂商补丁会在事故中途到达。** 我早上把 DSM 判死,当天 19:45 就拿到了修复包。那份缓解方案本来只是占位,我却开始把它当成结论——在把「没有修复版本」当成发现写出去之前,回头再看一次公告。
## 结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。到那晚结束时,22 号量过的每一个运行时都动了,除了两个确实没有可装的:
| 位置 | 现在 | 怎么做的 |
|---|---|---|
| 8 个 CyberPanel 站 | **8.5.12** | 留 lsphp83 8.3.36 作回退 |
| 6 个 VPS 容器 | **8.3.36** | 浮动 tag,重建镜像 |
| DSM Web Station | **8.2.35 / 8.3.36 / 8.4.27** | SA-26:14 套件 |
| DSM phpMyAdmin | **8.3.36** | 同一个 tag,重建镜像 |
| passbolt 主库 + 备库 | **8.4.27** | 5.17.0 |
| mailcow php-fpm | 8.2.29 | 被 mailcow 版本钉死——2026-10 已排期 |
| unRaid webGUI | 8.3.19 | 厂商没有补丁 |
每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
同一次扫荡还带出两个跟 PHP2Shell 无关的发现:八条从未生效的 bot-block 规则,在一个公网站点上;以及一个「一直在复制、其实什么都没复制」的备库。
PHP 的零日很少见。静默失效的安全控制不少见。
---
## 需要有人帮你把这些做一遍?
我平时就在做自托管 PHP 机群的打补丁与维护——CyberPanel/OpenLiteSpeed 上的 WordPress、Traefik 后面的容器化 PHP、以及群晖和 unRaid 主机。如果你的站点不确定跑在哪个运行时上,我会上门把每一个都量出来、老实告诉你哪些厂商还没出包、其余的按你的流量节奏排期。
邮箱 [[email protected]](mailto:[email protected]?subject=PHP%20%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A1%A5%E4%B8%81%E5%AE%A1%E8%AE%A1),WhatsApp
[+60 12-797 2969](https://wa.me/60127972969),或看我在做什么:[hoelee.com](https://hoelee.com)。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "PHP2Shell: I Measured Every PHP Runtime I Manage. All of Them Were Vulnerable."
description: "CVE-2026-109618 and CVE-2026-109619 are unauthenticated RCE in PHP itself, exploited before disclosure. What I measured across the infrastructure I manage — and the exact fix that closed it."
pubDate: 2026-10-23
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L4-secops-cicd-dr
tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patching", "self-hosting"]
ogImage: /og/php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## Why this vulnerability is not like the last ten
Most PHP "critical" advisories are application bugs wearing a framework's
name: a plugin, a CMS route, a library with a bad unserialize. They are bad,
but they are patchable by updating one thing, and the blast radius is
whatever that one thing serves.
**PHP2Shell is in PHP itself.** Two bugs — CVE-2026-109618 (an out-of-memory
error that leaves request state half-initialised, which PHP's own cleanup
then treats as valid) and CVE-2026-109619 (stale global state that survives
the request that created it) — both CVSS v4.0 **9.2**, both allowing
unauthenticated remote code execution on a stock configuration. The Wiz
research team demonstrated pre-auth RCE against `wordpress:latest`,
`drupal:latest` and `joomla:latest` and separately showed a
memory-disclosure path that leaks other requests' passwords and API keys
without any code execution at all.
Three details decided how I spent my day:
1. **It affects everything from PHP 5.4 to 8.5.** Not "some versions". A
"Hello World" `index.php` is enough for the RCE — your application's code is
irrelevant, so "my site is custom, nobody targets it" is not a mitigation.
2. **It was exploited before it was disclosed.** AWS's threat intelligence
observed PHP2Shell exploitation attempts in a honeypot on **17 October**,
four days before the advisory. Cloudflare reported attack traffic, including
attempts to evade its protections, the same day the patches landed.
3. **Only patching fixes it.** The circulating `session.upload_progress.enabled
= 0` tweak cuts off one exploitation path, not the class of bug. PHP's own
advisory says so; so does Datadog's analysis. Treat it as a seatbelt, not a
fix.
Fixed versions, released 21 October 2026: **8.2.35, 8.3.36, 8.4.27,
8.5.12**. PHP 8.1 and older are end-of-life and will never get one.
## I stopped guessing and measured
The infrastructure I manage spans a Synology DSM NAS, an unRaid box, an
Ubuntu VM running CyberPanel/OpenLiteSpeed with eight production sites, and
a VPS with Traefik, mailcow and six containerised PHP sites — roughly 170
containers in total.
My first instinct was to check the image tags and call it done. That is how
you get this wrong. `php:8.3-fpm-alpine` is a floating tag — it can hold any
8.3.x, and a container that has not been recreated keeps whatever it started
with. Docker's tag is not a version. So I probed inside every candidate
runtime instead — `php -v` and `php -i` executed in the container or in the
host runtime's own binary.
What came back:
| Host | Runtime | Measured version | Reachable from | Patched build existed? |
|---|---|---|---|---|
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | public (6 sites) | yes — 8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | public — its web UI | yes, inside mailcow 2026-10 |
| CyberPanel VM | `lsphp84` (8 sites) | **8.4.25** | public (8 sites) | yes — 8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | unused / fallback | 8.1 never, others yes |
| DSM | Web Station PHP packages | **8.2.30 / 8.3.30 / 8.4.19** | LAN + tunnels | not yet — arrived 22 Oct, 19:45 |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | behind SSO | yes — the same tag was rebuilt |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | behind SSO / LAN | yes — 5.17.0 ships 8.4.27 |
| unRaid 7.1.4 | webGUI PHP | **8.3.19** | LAN only | **no** |
Every single row was vulnerable. That is not a coincidence — it is what a
ten-year-old bug class looks like on a fleet that patches applications and
not runtimes.
The table also produced the ranking. Two things decide the order of work:
**exposure** (who can reach it) and **whether a fix even exists**. A public
WordPress fleet on a pre-fix runtime comes first. An admin UI that only
answers on the LAN comes last. And "no vendor fix exists" is a real state
you have to name out loud rather than paper over with an update that doesn't
exist.
## Step 1: one command closed it on eight public sites
The CyberPanel VM was the urgent one: eight public sites — five of them
WordPress — all sharing `lsphp84` 8.4.25.
The LiteSpeed repository already carried the fixed build, so the first move
cost nothing in risk: a point upgrade inside the same 8.4 branch.
```bash
sudo apt update
sudo apt-get install --only-upgrade $(dpkg -l | awk '/^ii +lsphp84/{print $2}')
/usr/local/lsws/lsphp84/bin/lsphp -v # PHP 8.4.27 (litespeed)
sudo /usr/local/lsws/bin/lswsctrl restart
```
Two minutes later every one of those sites was on a patched runtime. I want
to be explicit that this — not the 8.5 migration that follows — is what
actually closed the hole. Everything after this was about ending up where I
wanted to be, not about being safe.
Verifying it is the part people skip, and it is the part that matters. **The
process name proves nothing**: OpenLiteSpeed spawns each vhost's PHP as a
child whose `argv[0]` is literally `lsphp`, so `ps aux | grep lsphp` shows
you eight identical processes and tells you nothing about which binary each
is running. Read the binary behind the PID instead, per site user:
```bash
ps -eo user:20,pid,args | grep '[l]sphp' | while read u p a; do
echo "$u -> $(readlink -f /proc/$p/exe)"
done | sort -u
```
```text
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
numer9393 -> /usr/local/lsws/lsphp84/bin/lsphp
...
```
## Step 2: moving to 8.5.12, and the package that does not exist
With the CVE closed I could work at a civilised pace. The target was PHP
**8.5.12** for everything that supports it, one branch of 8.3 kept as a
fallback, and the two obsolete branches (8.1, 8.2) removed.
The install failed on the first attempt, in a way worth recording. I
mirrored the existing 8.4 package set by find-and-replace, which produced
`lsphp85-opcache` — and the whole apt run aborted:
```text
E: Unable to locate package lsphp85-opcache
```
In PHP 8.5, OPcache is part of the base package (verify with
`lsphp85 -i | grep 'Zend OPcache'` → enabled), so the `-opcache` sub-package
simply does not exist for that branch in the LiteSpeed repo. Build the list
from the repository's own inventory instead of from the previous branch:
```bash
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
```
One more trap: `lsphp -m` prints the usage banner and exits, because the
LSAPI SAPI has no script to run. Use `lsphp -i | grep …`, or the sibling CLI
binary `/usr/local/lsws/lsphp85/bin/php -m`.
## Where a site's PHP version actually lives
This is the part that surprised me, and it is why I did not simply click the
version dropdown. On this box, each vhost carries its **own** PHP handler —
the site's runtime is a line in the site's own config file:
```text
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
extprocessor <siteuser> {
type lsapi
address UDS://tmp/lshttpd/<siteuser>.sock
path /usr/local/lsws/lsphp85/bin/lsphp <- this line
...
}
```
The global `extProcessor lsphp` in `httpd_config.conf` — which still points
at `lsphp83` — is only the fallback for the default vhost. Changing it does
nothing for your sites.
So the switch is a one-line edit, a config test, and a graceful restart:
```bash
cp -a /usr/local/lsws/conf/vhosts/<domain>/vhost.conf \
/root/vhost-backup/<domain>.vhost.conf.bak-$(date +%F-%H%M)
sed -i 's#/usr/local/lsws/lsphp[0-9]*/bin/lsphp#/usr/local/lsws/lsphp85/bin/lsphp#' \
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
/usr/local/lsws/bin/lshttpd -t # errors? stop here
/usr/local/lsws/bin/lswsctrl restart # graceful, ~2 s, all vhosts
```
The panel's database is part of the runtime state too. CyberPanel keeps
`phpSelection` in `cyberpanel.websiteFunctions_websites`; leave it saying
`PHP 8.4` while the vhost says 8.5 and the next panel-side save (or a
staging clone) will happily rewrite the vhost back to what the panel
believes:
```sql
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
```
I rolled it out one site at a time — a test WordPress install first, then a
low-traffic client site, then the rest, then my own site last. Rollback at
any point is the same `sed` in reverse plus a restart, five seconds.
## Verify with bytes, not status codes
"200 OK" is a weak assertion. A PHP fatal in a page fragment, a plugin that
silently stops rendering a block, a shortcode that returns an empty string —
all of those still return 200. So before each switch I captured the rendered
homepage and its byte count, and after each switch I required the same
count:
```bash
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
```
```text
hoelee.com 200 227390 (before and after: identical)
chaijoywood.com 200 266192 (identical)
nestz.com.my 200 222780 (identical)
truetrading.com.my 200 134535 (identical)
test.hoelee.com 200 80869 (identical)
sifumail.com 200 44861 (identical)
numerology.digikedai.com 200 142762 (moved to 8.5.12 later — see below)
```
I also grep every response for `Fatal error` / `Uncaught` / `Deprecated:`,
and hit `/wp-json/` — a 200 there means WordPress actually bootstrapped,
queried the database and serialised a response, which is a much stronger
signal than a cached homepage render. Zero errors across all of them.
(WordPress 6.9 and 7.x are documented as fully supporting PHP 8.5; the
residual risk is plugins, not core. My sites run 19–44 plugins each, which
is exactly why the byte-count check earned its keep.)
## The bug the vulnerability found for me
While testing, I noticed `/xmlrpc.php`, `/.env` and `/readme.html` were
still returning 200 on one client site — in October 2026, on a vhost I had
deliberately hardened in September with eight deny contexts.
The cause was not the contexts. It was two stray `}` left in that vhost file
by my own earlier edit. A `vhost.conf` is the **body** of a `virtualHost`
block — the outer braces live in `httpd_config.conf` — so those two braces
closed nothing, and every `context` appended after them sat outside the
vhost and registered nowhere. The config parser had been saying so on every
single run:
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
```
I had read that line as noise for weeks. It was the whole diagnosis. After
removing the two braces and reloading, the same three URLs answered **403**,
and the config test went from two errors to zero.
The lesson is not "check your braces". It is that **a security control that
fails silently is worse than no control**, because it buys you the
confidence without the protection — and the warning that would have told you
is sitting in a log you have decided is noisy.
## The vendor fix landed mid-sweep
I had already written DSM off. Synology's archive showed `PHP8.4`
8.4.19-0050 as the newest build, so Web Station was going to spend the next
weeks on `session.upload_progress.enabled = 0` and a firewall rule. Then, on
22 October at 19:45, Synology published SA-26:14 with actual patched
packages. I installed them a few hours later:
```text
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
```
The awkward part was access. That box's SSH answers only to a password I do
not hold, the admin UI wants a login, and `synopkg` — the only thing that
installs a `.spk` — needs root on the host. What I did have was the NAS's
Docker API. So the package manager ran inside a privileged container with
the host root bind- mounted, called through `chroot`:
```python
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # the part that matters
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# then, inside the container:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
```
That `PidMode` line is the whole lesson. I omitted it on the first attempt,
and `synopkg` answered:
```json
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
```
The binaries stayed at the old version; the package was simply dead, marked
`broken / install_corruption`. Re-running the identical install with
`PidMode: host` took the repair path (`stage: installed_and_started`) and
finished. Mounting `/proc`, `/sys`, `/dev` and `/run` was not enough on its
own — the PID namespace had to be the host's.
Two smaller traps. Direct SPK URLs carry the architecture inside the
filename (`PHP8.4-x86_64-8.4.27-0051.spk`); drop the `-x86_64-` and they
404. And the unit serving Calendar is not the one just restarted:
`synopkg restart PHP8.4` reports success while Calendar's FPM keeps the old
binary, because Calendar runs on
**PHP 8.2** — `pkg-synocalendar-phpfpm.service`, with
`ExecStart=/usr/local/bin/php82-fpm`. Restart that unit by name, then prove
the version from the binaries instead of trusting Package Center:
```text
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
```
One side effect worth naming: installing a package starts it. PHP 8.3 had
been installed but not running on that box; the upgrade brought it up and
handed me a listener I did not have before, so it went back to stopped. And
the Calendar package itself is still one build short of the advisory —
3.0.3-21434 against a required 3.0.4-21435, its SPK not on the public CDN
yet — but the runtime it serves requests with is patched, which is the part
that mattered.
## The same tag, rebuilt, is a different runtime
`phpmyadmin:5.2.3` is the counter-example to everything above about floating
tags, and the reason I now check the rebuild date as well as the version.
The tag had not changed since I deployed it, and the PHP inside had: the
image was rebuilt on 21 October with PHP **8.3.36** baked in. So the fix was
`pull` and recreate, no version bump, no changelog to read — and the
container reports `PHP 8.3.36 (built: Oct 21 2026)` where it used to say
8.3.33.
The cost of that pull was not local, either. Recreating that one stack
through the API also re-resolved `mysql:8.4.11` and restarted four
containers for about thirty seconds. Nothing broke, but a "one container"
change is worth watching while it lands rather than after.
## The application I said could not move
I left `numerology.digikedai.com` on 8.4.27 on purpose: CodeIgniter, and the
framework documentation stops at 8.4. That reasoning was worth checking
instead of trusting, so I checked it. CodeIgniter 4.7.0 onwards supports PHP
8.5 — the framework's own `composer.json` allows `^8.2`, and 4.7.4 is
explicit about it — and the app's route table loads clean under `E_ALL` on
8.5.12: `spark routes`, zero deprecations. So it moved with everything else,
and all eight sites ended on the same build.
That switch then exposed a second runtime that had nothing to do with the
CVE. The application's three cron jobs called `/usr/bin/php` — the
distribution's PHP 8.3.6 CLI, without `ext-intl` — while the site itself
served through LiteSpeed's build. The failure was quiet and total:
`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`, 429 lines
apiece in the auto-delivery and mail-queue worker logs, every scheduled run
dying before it did any work. Pointing the three crontab lines at
`/usr/local/lsws/lsphp85/bin/php`, the same binary the site serves with,
ended it.
The queue happened to be empty of stuck mail when I looked. That was luck,
not design — those two crons are how paid orders get delivered and how
receipts go out.
## The standby that had been replicating nothing
Upgrading the second passbolt instance produced the least comfortable
finding of the night.
The primary (5.15.0) and the DR standby (5.14.0) both ran PHP 8.4.24, and
both needed the 5.17.0 image, which carries 8.4.27. The standby came up
healthy by every check that ships with it — fingerprint present, private key
able to decrypt, valid JWT pair — with a database of **34 tables against the
primary's 56**, and `Seconds_Behind_Source: 0`.
Both numbers were true, and neither meant what it looked like. Replication
had never carried passbolt's writes: the primary's binary log was filtered
to `--binlog-do-db=appdb`, a database that no longer exists, so every binlog
on that server was 181 bytes. `Seconds_Behind_Source: 0` on an empty stream
is not health; it is silence with a green tick.
The fix took three attempts and one real trap. `--replicate-do-db=passbolt`
filters DDL by the session's default database, so a replicated
`CREATE TABLE` was dropped while the row inserts that followed still matched
— the SQL thread stopped with `Table 'passbolt.x' doesn't exist`. What works
is `--replicate-wild-do-table=passbolt.%`, which matches on the real table
name for DDL and DML alike. Then a full reseed (`--source-data=2`, so the
dump carries its own replication coordinates),
`CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1` — without it
`caching_sha2_password` will not authenticate over a non-TLS link — and
`read_only` back on. Verified by counts on both sides (56 tables, 2 users,
542 resources, 644 secrets, 525 permissions) and by writing a probe row on
the primary and watching it arrive on the standby within five seconds. That
one deserves its own post. The point here is that patching a runtime is also
an audit, and this audit found a disaster-recovery plan that had been
decorative.
## What is still not patched
**unRaid** bundles PHP in the OS image. My box runs 7.1.4 with webGUI PHP
**8.3.19**; the current stable (7.3.3) and even 7.4.0-rc.1 ship **8.4.26** — still
below 8.4.27. There is no patch to apply, so the control is reachability:
the webGUI answers on the LAN only and is not tunnelled.
**mailcow** is where my first pass was wrong. I had filed its `phpfpm` container
(8.2.29) as internal-only, and it is not: its web UI answers on a public
hostname. The image is pinned by the mailcow release
(`ghcr.io/mailcow/phpfpm:8.2.29-3`), so there is nothing to bump in
isolation — the fix ships inside mailcow's own release, and 2026-10 (21
October) carries `phpfpm:8.2.35-1`. That is an application upgrade with mail
downtime attached, not an image pull, so it gets its own window. Until then
that container is pre-fix, and writing that down is more useful than a table
that looks finished.
## What I'd do differently
1. **Probe versions on a schedule, not during an incident.** The measurement
work above took an hour because I was doing it under pressure. A tiny script
that runs `php -v` in every container and every host runtime weekly would
have answered the whole question in one table before the advisory landed.
2. **Treat the control panel's database as runtime state.** Half my confusion
was "the panel says 8.4" versus "the process says 8.5". Two sources of truth
is a bug.
3. **Delete the old branches on the same day.** 8.1 and 8.2 were installed,
unused, and unsupported — 194 MB of attack surface kept alive by inertia.
They are gone now.
4. **Read the config test output every time, out loud.** The stray-brace bug had
been printing its own diagnosis on every restart for a month.
5. **Assume the vendor fix can arrive mid-incident.** I wrote DSM off in the
morning and had a patched package by 19:45 the same day. The mitigation plan
was a placeholder I had started to treat as a decision — check the advisory
again before publishing "no fix exists" as a finding.
## The result
One command closed a 9.2-severity unauthenticated RCE across eight public
sites in about two minutes. By the end of the night every runtime I had
measured on the 22nd had moved, except the two that had nothing to install:
| Where | Now | How |
|---|---|---|
| 8 CyberPanel sites | **8.5.12** | lsphp83 8.3.36 kept as fallback |
| 6 VPS containers | **8.3.36** | floating tag, images recreated |
| DSM Web Station | **8.2.35 / 8.3.36 / 8.4.27** | SA-26:14 packages |
| DSM phpMyAdmin | **8.3.36** | same tag, rebuilt image |
| passbolt primary + standby | **8.4.27** | 5.17.0 |
| mailcow php-fpm | 8.2.29 | pinned to the mailcow release — 2026-10 queued |
| unRaid webGUI | 8.3.19 | no vendor fix |
Every site's rendered output was byte-identical before and after, there were
zero PHP errors, and the config test passes clean.
Two findings came out of the same sweep that had nothing to do with
PHP2Shell: eight bot-block rules that had never been live on a public site,
and a standby database that had been replicating nothing for as long as
anyone cared to check.
Zero-day PHP bugs are rare. Silent security controls are not.
---
## Want this done on your servers?
I patch and maintain self-hosted PHP fleets for a living — WordPress on
CyberPanel/OpenLiteSpeed, Dockerised PHP behind Traefik, Synology and unRaid
hosts included. If you have sites you are not sure are on a patched runtime,
I will measure every one of them, tell you honestly which ones have no
vendor fix yet, and schedule the rest around your traffic.
Reach me at
[[email protected]](mailto:[email protected]?subject=PHP%20runtime%20patch%20audit)
or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do
at [hoelee.com](https://hoelee.com).
````
---
## 文章 07 / 09:给自己改不了源码的应用装一扇前门
- **Slug**: `putting-a-front-door-on-an-app-you-cant-modify`
- **英文标题**: Putting a Front Door on an App You Can't Modify
- **分类**: `devops` | **发布日期**: 2025-06-24
- **线上地址**: https://blog.hoelee.com/posts/putting-a-front-door-on-an-app-you-cant-modify/ (EN) | https://blog.hoelee.com/posts/zh/putting-a-front-door-on-an-app-you-cant-modify/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "给自己改不了源码的应用装一扇前门"
description: "我自托管阅读服务的门面,是一个中文登录框;而应用本身是客户端渲染的 SPA,我改不动。于是我在它前面加了一个容器:nginx、一个双语页面,和三行注入。"
pubDate: 2025-06-24
updatedDate: 2026-10-06
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L2-edge-proxy-runtime
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
---
我的阅读服务,访客打开看到的第一样东西是一个中文登录框。没有说明这个站是什么,没有邀请码说明,没有品牌,链接分享出去也没有社交卡片,搜索引擎也没有任何值得收录的东西。就是一个登录弹窗,上面一首诗。
这个服务是我给家人和几位付费读者跑的,所以「新读者第一眼看到什么」不是装饰问题,它就是产品本身。而我改不了它——它背后是别人的代码:一个 hash 路由的 Vue 单页应用,界面不归我管,版本按别人的节奏发布。
**那么问题来了:怎么给一个自己改不了的自托管应用套上自己的首页、品牌和语言处理,既不 fork 它,也不做一个下次镜像更新就碎掉的包装层?**
## 先说为什么那些「显然的做法」都不行
**直接改应用的 HTML。** 没东西可改。这一步很多人都跳过,但它决定了后面整套方案:
```bash
curl -s https://book.example.com/index.html | wc -c
# 5879 — and the body is <div id="app"></div>
```
这个页面只是一个外壳,所有按钮、文字和弹窗都是之后由 JavaScript 画出来的。所以既没有标记可以改进,也没有东西可以重排。
**在代理层重写响应。** 这个最诱人——Traefik 或 nginx 的响应体重写中间件。它失败的理由和上面一样:body 重写只能重写**body 里存在的东西**,而 body 里只有一个空 div 和一个 script 标签。这条值得当成一条通用规则记住,在任何时候伸手去用反向代理的 body 重写功能之前先做一次:
> 用 `curl` 拿到原始响应,grep 一下你想干掉的那个字符串。如果它不在你收到的字节里,那么内容是客户端渲染的,body 重写就是错的工具——你需要的是 CSS 或 JS,并且注入到应用会加载的地方。
**改压缩后的 bundle。** 技术上可行,因为编译产物我看得到。但每次镜像更新都会带来重新构建(有时还重新压缩)的 bundle,于是这就变成一份对着别人的代码库长期维护的活。而且它的失败方式最糟糕:静默、在生产环境、在某个不相干的升级之后。
**把我自己的页面塞进应用的镜像里。** 这样我的落地页、SEO 标签和社交卡片就绑死在别人的发布节奏上,上游一个坏构建能把我整扇前门一起带走。我最不想做的事,就是让我的品牌依赖他们的 Dockerfile。
所以:不放进应用里,不重写应用,也不 fork。放在它**前面**。
## 方案:多一个容器,让它持有对外端口
这条 stack 里有两个服务。应用不对外暴露任何公开端口;一个极小的 `nginx:alpine` 网关持有它,并决定每个请求是什么:
```yaml
services:
reader:
# the app: loopback-only port kept for debugging, never public
# (127.0.0.1:7778:8080)
networks: [bridge_hoelee]
reader-gateway:
image: nginx:alpine
ports:
- "7777:80" # the only public door
networks: [bridge_hoelee]
volumes:
- /volume1/docker/reader/gateway/conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/docker/reader/gateway/html:/usr/share/nginx/html:ro
```
nginx 那一侧短到可以整段读完。有意思的决定不在路由本身,而在路由周围那四行:
```nginx
# resolve the app at request time, not at startup
resolver 127.0.0.11 valid=10s ipv6=off;
set $reader_upstream http://reader:8080;
location = / { # the front page: exact match, bare root only
root /usr/share/nginx/html;
try_files /index.html =404;
add_header Cache-Control "no-store" always;
}
location / { # everything else: the app, untouched
proxy_pass $reader_upstream;
client_max_body_size 1024m; # uploads still work through the middle box
}
```
**在请求时解析上游,而不是启动时。** 如果写成字面量 `proxy_pass http://reader:8080`,nginx 只在启动那一刻解析这个名字一次,解析不到就拒绝启动——于是应用每次重启或被替换,我的前门就跟着下线。改用变量加 Docker 内嵌 DNS(`127.0.0.11`),就把解析推迟到每个请求。应用停着的时候网关照样能启动,这件事在你第一次重建应用时很重要,在你半夜排查时也很重要。
**把应用自己的端口留给调试,而且一次改完。** 应用仍然在 loopback 上发布 `127.0.0.1:7778:8080`:从宿主机通过 SSH 能连,公网看不见。做这次切换时,旧容器必须和网关在**同一次** stack 更新里交接那个端口;拆成两次部署会绑定失败。
**别让中间那台机器变成新的限制。** 网关设置了 `client_max_body_size 1024m`,因为这个应用允许上传大体积的电子书。在一个有文件上传的应用前面加代理,是引入「只在某天有人上传大文件时才出现」的 bug 的经典方式。
**不同路由用不同缓存时长。** 首页是 `no-store`(我直接在磁盘上编辑它,希望刷新即生效),社交图缓存一天,注入脚本缓存五分钟。各一行。
## 页面本身
它是一个自包含的 HTML 文件:没有网络字体、没有 CDN、没有构建步骤。这不是为了极简而极简——它意味着这扇前门没有任何第三方依赖会变慢、被墙或停止服务,而且断网时它还能从缓存里渲染出来。
里面有两个决定值得照抄:
**两种语言同时存在于 DOM 里。** 每一句文案都出现两次——一个 `.zh` span 和一个 `.en` span——由 `<html>` 上的一个属性通过 CSS 决定显示哪一套。默认跟随浏览器语言,用 `?lang=en` / `?lang=zh` 可以覆盖,选择记在 `localStorage`。因为两种语言都在 HTML 里,搜索引擎会两种都收录,这是 JavaScript 切换器做不到的。
**邀请码故意不放在页面上。** 页面明确写着邀请码印在实体邀请卡上,而不是把它显示出来。一个公开的落地页如果泄露注册码,就等于把一个只发邀请的服务变成开放注册。
## 注入进应用的三行,以及它们为什么是重点
一个放在应用前面的落地页,如果不把接缝藏掉,看起来仍然是两个产品。应用所在的 location 块里有三条 `sub_filter` 规则负责这件事——它们在应用的外壳经过时打补丁(那 5,879 字节,是客户端渲染应用里唯一真的存在于字节中的部分):
```nginx
location = /index.html {
proxy_pass $reader_upstream;
proxy_set_header Accept-Encoding ""; # sub_filter cannot rewrite a compressed body
# 1. the app declares the wrong language, which disables browser translation
sub_filter '<html lang="en">' '<html lang="zh-CN">';
# 2. reload/bookmark/new tab -> back to the front door; the enter button sets the pass
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>';
}
```
第二条才是真正有意思的。刷新、用书签打开、或者新开标签页,都应该再回到首页——我希望应用的地址表现得像一个产品,而不是把陌生人丢进应用内部、还没有出口。机制是一个 `sessionStorage` 标记:没有标记就「弹回前门」,进入按钮会先写上标记,而脚本会**消费**掉它(删掉),这样下一次刷新又会回弹。少了「消费」这一步,就会在页面和应用之间无限来回跳,那个标签页按逻辑就关不掉了。`navigator.onLine` 的判断让离线场景放行,所以应用装成 PWA 断网时照常可用。
两个花了点时间的机械细节:
- 那个 location 里必须加 `proxy_set_header Accept-Encoding ""`,因为 `sub_filter` 无法重写压缩后的内容。它只作用于外壳的精确匹配,所以只有约 5 KB 失去压缩;应用的 JS 和 CSS 保持 gzip。
- 千万**不要**加 `sub_filter_types text/html`——那是默认值,加了 nginx 每次启动都会报一条 MIME type 重复的警告。
语言这一半值得单独一篇,也确实写了:[浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式](/posts/zh/adding-english-mode-to-a-chinese-only-web-app/) 讲的是那个属性谎言和应用内英文层。这里重要的只是这个手法的形状——把一个小补丁打在客户端渲染应用里唯一以真实标记形式到达的那一部分上。
## 用代理解决不了的品牌问题
同一类应用通常还会带上厂商自己的链接——一个 GitHub 图标、一个 Telegram 群、一个「关注我们」区块。学过前面那条规则,结论立刻就有了:这些按钮是 JavaScript 画出来的,所以没有代理重写碰得到它们,也没有 HTML 可以改。真正有效的是应用自己的扩展点。很多自托管应用都会留一个,这个应用会从它持久化卷里的某个目录加载一份自定义样式表:
```css
.bottom-icons a { display: none !important; } /* the vendor's repo link */
.index-wrapper > :nth-child(6 of .setting-wrapper) { display: none !important; }
```
因为这份文件住在数据卷而不是镜像里,它会活过每一次镜像重建——这正是把自定义项放在发布流程够不着的地方的意义。
## 脆弱的地方,以及我现在会检查什么
**这些注入依赖两个字面量字符串:** `'<html lang="en">'` 和 `'</head>'`。如果应用未来某个版本改了其中任何一个,补丁就会静默失效:不报错,页面也不坏,只是首页回弹行为不见了,而这件事可能几周都没人发现。正因为这种失败方式,这条 stack 的运维笔记里放的是断言,而不是描述:
```bash
curl -s https://book.example.com/index.html | grep -o '<html lang="[^"]*"' # want zh-CN
curl -s https://book.example.com/index.html | grep -c 'gatePass' # want 1
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://book.example.com/og.png
```
每次应用升级之后都跑一遍。「它还能打开」不等于「我的集成还在工作」。
**永远不要手改 NAS 生成的反向代理配置。** 网关上面那一层是平台写的,它每隔几分钟就用一个新的 UUID 文件名重新生成——手改的内容活到下一次重新生成为止,然后消失,通常是在你正在查别的问题的时候。所以那一层被当成不可变的,永久指向网关的端口;我控制的一切都活在它下面。
**把这扇前门留在应用镜像之外。** 整个设计就压在这个决定上。网关是独立的容器,配置文件在宿主机上,所以一次应用镜像替换——或者,像后来发生的那样,一次换了编程语言的上游重写——都带不走首页、SEO 元数据和翻译层。
**还有:用断言,不要用眼睛。** 上面那段里每一个数字都来自 `curl` 和字节数,而不是「我看了一眼,感觉没问题」。
## 结果
应用获得了一个首页、一段邀请说明、一张社交卡片、JSON-LD、中英切换、一层英文界面,并且被去掉了厂商品牌——而它本身一行都没有改:
| 检查项 | 结果 |
|---|---|
| `/`(首页) | 200,23 KB,`Cache-Control: no-store` |
| 应用外壳 | 200,`<html lang="zh-CN">`——那个语言谎言在路上被纠正 |
| 注入脚本存在 | 1 处 |
| `/og.png` | 200,`image/png`,185 KB |
| 应用容器端口 | 只在 loopback;对外端口属于网关 |
两个容器而不是一个,大约四十行 nginx,外加一扇应用的发布周期够不着的前门。当底下那个应用永远是别人的问题时——它永远是——这就是放你自己品牌最便宜的位置。
---
**你在把自托管应用当服务来跑——给客户、给同事、给付费用户——希望它别再像个原始安装?** 在一个你不维护的应用前面加上自己的前门、品牌和语言处理,正是我在做的一类封装工作。
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Front%20door%20for%20a%20self-hosted%20app) ·
[hoelee.com](https://hoelee.com)
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "Putting a Front Door on an App You Can't Modify"
description: "A self-hosted app whose front page was a Chinese login box — and a client-rendered SPA I couldn't edit. So I added a second container: nginx, a bilingual page, and three injected lines."
pubDate: 2025-06-24
updatedDate: 2026-10-06
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L2-edge-proxy-runtime
tags: ["nginx", "docker", "self-hosting", "seo", "branding"]
ogImage: "/og/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
---
The first thing a visitor to my reading service saw was a Chinese login box.
No explanation of what the site was, no invitation information, no branding,
no social card when the link was shared, and nothing a search engine could
sensibly index. Just a login dialog with a poem above it.
I run that service for my family and a handful of paying readers, so "what
does a new reader see first" is not a cosmetic question — it is the product.
And I could not change it, because the app behind it is somebody else's
code: a hash-routed Vue single-page application whose interface I do not
control and whose releases arrive on their schedule, not mine.
**How do you put your own front page, branding and language handling on a self-hosted app you can't edit — without forking it and without a wrapper that breaks on the next image update?**
## Why the obvious fixes don't work
**Edit the app's HTML.** There is nothing there to edit. This is the part people skip, and it decides the whole approach:
```bash
curl -s https://book.example.com/index.html | wc -c
# 5879 — and the body is <div id="app"></div>
```
The page ships as a shell. Every button, label and dialog is drawn later by
JavaScript. So there is no markup to improve and nothing to reorder.
**Rewrite the response in the proxy.** This is the tempting one — Traefik or nginx middlewares that rewrite response bodies. It fails for the same reason: a body rewrite can only rewrite what is *in the body*, and what's in the body is an empty div and a script tag. This is worth internalising as a general rule before you reach for any reverse-proxy rewrite feature:
> `curl` the raw response and grep for the string you want gone. If it isn't in the bytes you receive, the content is client-rendered and a body rewrite is the wrong tool — you need CSS or JS, injected somewhere the app will load it.
**Patch the minified bundle.** Technically possible, since I can see the compiled files. But every image update ships rewritten — and sometimes re-minified — bundles, so the patch is a permanent maintenance duty against a codebase I don't own. It also breaks in the worst way: silently, in production, after an unrelated upgrade.
**Put my page inside the app's image.** Now my landing page, SEO tags and social card are coupled to somebody else's release cadence, and a bad upstream build takes my front door down with it. The thing I wanted least was to make my brand depend on their Dockerfile.
So: not inside the app, not a rewrite of the app, and not a fork. In front
of it.
## The design: one extra container that owns the public port
The stack has two services. The app publishes no public port; a tiny
`nginx:alpine` gateway owns it and decides what each request is:
```yaml
services:
reader:
# the app: loopback-only port kept for debugging, never public
# (127.0.0.1:7778:8080)
networks: [bridge_hoelee]
reader-gateway:
image: nginx:alpine
ports:
- "7777:80" # the only public door
networks: [bridge_hoelee]
volumes:
- /volume1/docker/reader/gateway/conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/docker/reader/gateway/html:/usr/share/nginx/html:ro
```
The nginx side is short enough to read in full. The interesting decisions
are the four lines around the routing, not the routing itself:
```nginx
# resolve the app at request time, not at startup
resolver 127.0.0.11 valid=10s ipv6=off;
set $reader_upstream http://reader:8080;
location = / { # the front page: exact match, bare root only
root /usr/share/nginx/html;
try_files /index.html =404;
add_header Cache-Control "no-store" always;
}
location / { # everything else: the app, untouched
proxy_pass $reader_upstream;
client_max_body_size 1024m; # uploads still work through the middle box
}
```
**Resolve the upstream at request time.** With a literal `proxy_pass http://reader:8080`, nginx resolves that name once, at startup, and refuses to start if it can't — so the front page would go down whenever the app is restarting or being replaced. Using a variable plus Docker's embedded DNS resolver (`127.0.0.11`) defers resolution to each request. The gateway boots fine with the app stopped, which matters the first time you recreate the app and the last time you debug at midnight.
**Keep the app's own port for debugging, and move it in one pass.** The app still publishes `127.0.0.1:7778:8080` on loopback — reachable from the host over SSH, invisible to the internet. When I made that switch, the old container had to release the public port in the *same* stack update that gave it to the gateway; splitting it into two deploys fails to bind.
**Don't let the middle box become the new limit.** The gateway sets `client_max_body_size 1024m` because the app accepts large book uploads. A proxy in front of an app with file uploads is a classic way to introduce a bug that only appears on the one day somebody uploads something big.
**Different cache lifetimes per route.** The root page is `no-store` (I edit it directly on disk and want the change live on refresh), the social image is cached for a day, the injected script for five minutes. One line each.
## The page itself
It is a single self-contained HTML file: no web fonts, no CDN, no build
step. That's not minimalism for its own sake — it means the front door has
no third-party dependency that can be slow, blocked or discontinued, and it
renders from cache when the network is down.
Two decisions inside it are worth copying:
**Both languages live in the DOM at once.** Every string exists twice — a `.zh` span and an `.en` span — and one attribute on `<html>` decides which set is visible through CSS. The default follows the browser's language, a `?lang=en` / `?lang=zh` query parameter overrides it, and the choice is remembered in `localStorage`. Because both languages are in the HTML, a search engine indexes both, which a JavaScript-based switcher would not achieve.
**The invitation code is not on the page — deliberately.** The page explains that the code is printed on the physical invitation card and says so in plain language, rather than showing it. A public landing page that leaks the registration code turns an invite-only service into an open one.
## Three lines injected into the app, and why they're the difference
A landing page in front of an app still feels like two products unless the
seam is hidden. Three `sub_filter` rules in the app's location block do that
work — they patch the app's own shell (5,879 bytes of it) as it passes
through, which is the one part of a client-rendered app that *is* in the
bytes:
```nginx
location = /index.html {
proxy_pass $reader_upstream;
proxy_set_header Accept-Encoding ""; # sub_filter cannot rewrite a compressed body
# 1. the app declares the wrong language, which disables browser translation
sub_filter '<html lang="en">' '<html lang="zh-CN">';
# 2. reload/bookmark/new tab -> back to the front door; the enter button sets the pass
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>';
}
```
The second one is the interesting one. Reloading, using a bookmark, or
opening a new tab should land on the front page again — I wanted the app's
address to behave like a product, not to dump a stranger into an app
interior with no exit. The mechanism is a `sessionStorage` flag: no flag
means "bounce to the front door", the enter button sets the flag first, and
the script **consumes** it (deletes it) so the next reload bounces again.
Without that consumption step you get an infinite ping-pong between the page
and the app, and the browser tab becomes unclosable-by-logic. The
`navigator.onLine` guard lets offline use through, so the app still works as
an installed app with no network.
Two mechanical notes that cost me time:
- `proxy_set_header Accept-Encoding ""` is required in that location, because `sub_filter` cannot rewrite a body that arrived gzipped. It's scoped to an exact match on the shell so only ~5 KB loses compression; the app's JavaScript and CSS keep theirs.
- Do **not** add `sub_filter_types text/html` — that is the default, and adding it makes nginx log a duplicate MIME type warning on every start.
The language fix deserves its own post, and it got one: [No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App](/posts/adding-english-mode-to-a-chinese-only-web-app/) covers the attribute lie and the in-app English layer in detail. What matters here is the shape of the trick — a small patch applied to the one part of a client-rendered app that arrives as real markup.
## The branding problem you can't solve with a proxy
The same class of app usually carries the vendor's own links — a GitHub
icon, a Telegram group, a "follow us" block. Once you know the rule from
earlier, the conclusion is immediate: those buttons are drawn by JavaScript,
so no proxy rewrite can touch them and no HTML edit exists to make. What
does work is the app's own extension point. Many self-hosted apps ship one,
and this one loads a custom stylesheet from a directory inside its
persistent volume:
```css
.bottom-icons a { display: none !important; } /* the vendor's repo link */
.index-wrapper > :nth-child(6 of .setting-wrapper) { display: none !important; }
```
Because that file lives in the data volume rather than the image, it
survives every image rebuild — which is the whole point of putting
customisation where the release process can't reach it.
## What's fragile, and what I now check
**The injections depend on two literal strings.** `'<html lang="en">'` and `'</head>'`. If a future app version changes either one, the patch silently does nothing: no error, no broken page, just a missing front-door behaviour that nobody notices for weeks. That failure mode is why the operational notes for this stack carry the assertions instead of a description:
```bash
curl -s https://book.example.com/index.html | grep -o '<html lang="[^"]*"' # want zh-CN
curl -s https://book.example.com/index.html | grep -c 'gatePass' # want 1
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://book.example.com/og.png
```
Run them after every app upgrade. "It still loads" is not the same as "my
integration still works".
**Never hand-edit the NAS's generated reverse-proxy config.** The layer above the gateway is written by the platform, which regenerates it with a new UUID filename every few minutes — a hand edit lives until the next regeneration and then vanishes, usually while you're debugging something else. So that layer is treated as immutable and stays pointed at the gateway's port permanently; everything I control lives below it.
**Keep the front door out of the app's image.** This is the decision the whole design rests on. The gateway is a separate container with its own files on the host, so an app image swap — or, as it turned out later, an upstream rewrite in a different programming language — cannot take the front page, the SEO metadata or the translation layer with it.
**And assert, don't eyeball.** Every claim in the paragraph above came from `curl` and a byte count, not from looking at the page and deciding it seemed fine.
## The result
The app got a front page, an invitation explanation, an og card, JSON-LD, a
bilingual switch, an English-UI layer and its vendor branding removed — and
I did not modify one line of it:
| Check | Result |
|---|---|
| `/` (the front page) | 200, 23 KB, `Cache-Control: no-store` |
| The app shell | 200, `<html lang="zh-CN">` — the language lie corrected in flight |
| Injected script present | 1 occurrence |
| `/og.png` | 200, `image/png`, 185 KB |
| App container port | loopback only; the public port belongs to the gateway |
Two containers instead of one, about forty lines of nginx, and a front door
the app's own release cycle can't reach. When the app underneath is somebody
else's problem — which it always is — that's the cheapest place to put your
brand.
---
**Running a self-hosted app as a service — for clients, staff or customers — and want it to stop looking like a raw install?** Putting your own front door, branding and language handling in front of an app you don't maintain is the kind of packaging work I do.
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Front%20door%20for%20a%20self-hosted%20app)
· [hoelee.com](https://hoelee.com)
````
---
## 文章 08 / 09:给另一台机器上的数据库配一个 NocoDB 只读看板
- **Slug**: `read-only-nocodb-dashboard-for-a-remote-database`
- **英文标题**: A Read-Only NocoDB Dashboard for a Database on Another Machine
- **分类**: `devops` | **发布日期**: 2026-09-29
- **线上地址**: https://blog.hoelee.com/posts/read-only-nocodb-dashboard-for-a-remote-database/ (EN) | https://blog.hoelee.com/posts/zh/read-only-nocodb-dashboard-for-a-remote-database/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "给另一台机器上的数据库配一个 NocoDB 只读看板"
description: "把 NocoDB 直接指向另一台机器上的 MySQL/MariaDB,一行数据都不用复制 —— 以及我踩到的 Docker SNAT 源 IP 陷阱和两个 API 陷阱。"
pubDate: 2026-09-29
category: devops
blueprint: sme-digital-suite
blueprintLayer: L3-entitlement-backoffice
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
---
我的报告应用把每一条留资、每一张订单都写进一台小 VM 上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
最直觉的答案是做一个表格风格的后台界面。最糟的答案是把数据复制一份。这篇写的是我最后落地的配方:**让 NocoDB 直接指向真库,用的账号只能 `SELECT`** —— 外加三个吃掉我半下午的坑,其中一个是 Docker 网络事实,在任何「容器访问局域网」的场景里都会咬人,不只是这一次。
## 为什么不把行同步进 NocoDB?
给业主做看板有三条路,另两条也是正路,只是各有代价:
1. **应用写入时顺手推一份到 NocoDB 的 API。** 每条记录写两次、两边都要处理失败,而且推送第一次失败的那一刻,看板就开始悄悄漂移 —— 它变成了一个「通常是对的」的第二数据源。
2. **cron 定时同步。** 同样的重复数据,再加一个延迟窗口,再加一个「谁赢了」的问题:有人在看板里改了行怎么办?
3. **把数据库当作 NocoDB 的外部数据源。** 零重复、永远实时、一份连接配置。看板**就是**这个库,只是换了个方式看。
我选了第 3 条。代价说在前面:数据库的端口必须能被 NocoDB 所在的主机访问到,而且 NocoDB 手上会有一个数据库凭据。只要这个凭据是「只读 + 限定一个来源主机」的账号,这两点都可以接受 —— 后面的内容就是怎么把它做出来。
## 没人提醒你的坑:数据库到底看到哪个 IP
这是我最先做错的地方,而且它和 NocoDB 无关。
我的 NocoDB 跑在群晖 NAS 的 Docker 里,位于一个自定义 bridge 网络(`bridge_hoelee`,容器 IP `172.16.0.4`)。它要连的 MariaDB 在另一台机器 `192.168.1.124` 上 —— 在 bridge 子网之外。
我的第一反应是给容器子网授权:
```sql
-- 错的(更准确说:没用):容器自己的 IP 根本不是服务端看到的地址
CREATE USER 'nocodb_ro'@'172.16.0.%' IDENTIFIED BY '<password>';
```
它永远匹配不上。当容器连接**自己 bridge 网络之外**的地址时,流量会经宿主机做 NAT 出去 —— Docker 的 masquerade 规则把源地址改写成**宿主机**的 IP。数据库看到的是 `192.168.1.1`(NAS),不是 `172.16.0.4`(容器)。
别推理,去量。在**同一个 bridge 上任意一个容器**里跑一条 `SELECT` 就能得到真相,十秒钟的事:
```bash
docker exec mysql-server mysql -h192.168.1.124 -unocodb_ro -B -e "SELECT CURRENT_USER();"
# [email protected] <- 宿主机 IP,不是容器 IP
```
(口令用 `MYSQL_PWD` 传,别用 `-p<password>`:写在命令行里会进 `ps` 输出和 shell 历史。)
知道来源地址之后,授权就是一行 —— **一个 host、一个库、一个权限**:
```sql
CREATE USER 'nocodb_ro'@'192.168.1.1' IDENTIFIED BY '<password>';
GRANT SELECT ON appdb.* TO 'nocodb_ro'@'192.168.1.1';
FLUSH PRIVILEGES;
```
给看板账号两条我建议照做的规矩:
- **绝不复用应用自己的数据库账号。** 应用的账号能 `INSERT`/`UPDATE`/`DELETE`,看板的不能。万一这个凭据从浏览器会话里漏出去,影响面是「有人能读数据」,而不是「有人能改写生意」。
- **授权到确切的来源地址,不要通配。** `'192.168.1.1'` 就一行字,而且经得起 `SHOW GRANTS` 复核。
## 用 API 建外部数据源
NocoDB(我这边镜像是 `nocodb/nocodb:2026.09.0`,监听 `10380`)有 meta API;workspace token 存在它自己元数据库的 `nc_api_tokens` 表里。建一个外部 MySQL 数据源的调用长这样:
```bash
curl -s -X POST "$NC/api/v2/meta/bases/$BASE/sources" \
-H "xc-token: $TOKEN" -H 'Content-Type: application/json' \
-d '{
"type": "mysql2",
"title": "app-vm",
"config": {
"client": "mysql2",
"connection": {
"host": "192.168.1.124", "port": 3306,
"user": "nocodb_ro", "password": "<password>",
"database": "appdb"
}
}
}'
# {"id":"joblzodc9u91flnkw"}
```
这个返回有两个意外:
1. **拿到的是 job id,不是数据源。** 建源是异步的。而且**没有**任何查询 job 状态的路由 —— 我试了 `/api/v2/jobs/{id}`、`/api/v1/db/meta/jobs/{id}`、`/api/v2/meta/jobs/{id}`、`/api/v1/jobs/{id}`,四个全是 404。别在那儿轮询,去验证结果。
2. **它会把那个库里已有的表全部自动同步进 base。** 你不需要一张张加表。几秒之后,我的四张表已经全在 base 里了。
我最后用的验证方式是读数据源列表、看 `meta`:
```
source budabxoawz5lwn9 type=mysql2 order=1 meta=None
source b2sdp3iw0ed1bgo type=mysql2 order=2 meta={'dbVersion': '10.11.19-MariaDB-ubu2404'}
```
那个 `dbVersion` 是**对端**服务器回显的版本,所以它既是「连接成功」的证据,也顺手提醒你对面是 MariaDB 而不是 MySQL。
之后读行就是正常的 records API:
```bash
curl -s -H "xc-token: $TOKEN" \
"$NC/api/v2/tables/m01ejjhhne1h59z/records?limit=1" | jq '.pageInfo.totalRows'
```
## 陷阱:`POST /meta/bases/{id}/tables` 不是「挂载这张表」
因为表是自动出现的,我就以为那条建表路由是同一件事的**手动**版本 —— 从指定数据源挂一张已有的表。并不是。`POST /api/v2/meta/bases/{baseId}/tables` 是**在 base 的默认源里新建一张空表**。我传了四个表名,结果在 NocoDB 自己的内部数据库里多出四张空表:`nc_pd1g___leads`、`nc_pd1g___orders` 之类。
它们能干净删掉(`DELETE /api/v2/meta/tables/{tid}`),而且因为真表在外部源里,什么也没坏。但这是个很好的例子:**一个名字暗示「连接」、行为却是「新建」的 API 路由。** 对外部源来说,你想要的同步在建源那一刻就已经做完了。
## 只读源要付的代价
只读是设计目标,但它确实有两个看得见的后果,我宁愿写下来而不是假装不存在。
**你改不动表的元数据。** 我想把 leads 表的主显示列改成姓名,NocoDB 自动挑的是一个去重哈希。把列提升为主显示列时返回:
```
400 {"error":"ERR_DATABASE_OP_FAILED","message":"This request couldn't be processed by the database..."}
```
列顺序和显示与否,去视图(UI)里调。这是外观问题,不是数据问题。
**`DATETIME` 列会带上 UTC 标签。** 本地时间 01:54 写入的行,读回来是 `2026-09-27 01:54:16+00:00`。值是对的,时区后缀只是展示层的事。拿来浏览没问题,拿它做跨时区换算就错了 —— 别在它上面搭逻辑。
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
## 四个值得顺手拿走的小坑
1. **认服务要看端点,不要看你记忆里的端口。** 我第一批探测打到了错的端口,返回的是 Go 风格的 `404 page not found` 正文 —— 看起来就像「这个版本 API 搬家了」。在对的端口上 `curl -s /api/v1/health` 回了 `{"message":"OK"}`,一秒钟定案。相关的一点:未认证的 NocoDB meta 调用回的是 **401**;如果你拿到 404 且正文是 Go 味道的,说明你在跟另一个进程说话。
2. **NocoDB 的 `NC_DB` 不是 DSN。** 它是 `mysql2://mysql-server:3306/?d=<db>&u=<user>&p=<password>` —— 要解析 **query string**。我按 `user:password@host` 解析,得到空 host,然后静默跳过了一整段验证。
3. **软删除的 base 会回 `404 ERR_BASE_NOT_FOUND`。** base 其实还在 `nc_bases_v2` 里(`deleted=1`),但对 API 不可见。「Not found」在这里的意思是**已删除或超出权限范围**,不是**路由写错了** —— 先去 meta 表里查一眼,别在 URL 里找错字。
4. **base 列表是按 token 的 workspace 过滤的。** workspace token 只会列出该 workspace 里活着的 base。列表短得可疑,是权限范围或软删除的症状,不是认证失败。
## 换我会怎么做
- **先量出来源地址,再写授权。** 我因为按容器的 IP 去推理,先授了一个容器子网通配。十秒钟的 `SELECT CURRENT_USER()` 就能告诉我答案,而且这是你没法可靠地从 compose 文件里推出来的事实。
- **把只读当成架构,而不是限制。** 接受看板改不动元数据,外观的活留给视图层。
- **压根别碰建表那条路由。** 建源时已经全部导入完了;多伸一次手,只是给自己制造清理工作。
- **每个消费者一个数据库账号** —— 应用、看板、备份任务各一个。看板这个凭据最可能被贴进浏览器或工单里,所以它应该是整个系统里权限最弱的那个。
## 结果
- 一个外部数据源,**四张表实时可用**(留资、订单、订单文件、代理台账),**零重复行** —— 业主看到的就是应用写入的同一批行,写进去就能看到。
- 看板账号只能对**一个库、来自一个主机**做 `SELECT`,而且这是由**数据库**保证的:NocoDB 自己发起的删表尝试返回 `DROP command denied`。
- 耗时:第一遍约 30 分钟的 API 试探;配方写下来之后约 10 分钟(授权 → 建源 → 验证 `dbVersion` → 读一行)。
如果你是要给客户做这件事,这个形态值得照抄,哪怕最后选的不是 NocoDB:**看板连的是真数据,用的账号物理上写不进去,而且两半都要验证** —— 连接是通的(`CURRENT_USER()`、`dbVersion`),账号是无害的(`DROP` 被拒)。
---
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在 NAS 或小 VM 上。*
*需要给你的生意搭一套?[WhatsApp +60 12-797 2969](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
· [hoelee.com](https://hoelee.com)*
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "A Read-Only NocoDB Dashboard for a Database on Another Machine"
description: "Point NocoDB at a MySQL/MariaDB database on another machine without copying a single row — and avoid the Docker SNAT source-IP trap and two API traps I hit."
pubDate: 2026-09-29
category: devops
blueprint: sme-digital-suite
blueprintLayer: L3-entitlement-backoffice
tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
ogImage: /og/read-only-nocodb-dashboard-for-a-remote-database.jpg
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.jpg
draft: false
---
My report app writes every lead and every order into MariaDB running on a small VM. The
person who actually runs the business wanted to *look* at those rows — filter them, sort
them, export them — without SSH-ing into the VM and without asking me every time.
The obvious answer is a spreadsheet-style admin UI. The wrong answer is a second copy of
the data. This post is the recipe I ended up with: **NocoDB pointed straight at the real
database, through an account that can only `SELECT`** — plus the three traps that cost me
the afternoon, one of which is a Docker networking fact that will bite you in any
container-to-LAN setup, not just this one.
## Why not sync the rows into NocoDB?
There were three ways to get the owner a dashboard, and I want to be honest that the other
two are legitimate — they just cost something:
1. **Push every row to NocoDB's API as the app writes it.** Two writes per record, error
handling on both, and the dashboard silently drifts the first time a push fails. The
dashboard becomes a second system of record that is *usually* right.
2. **A cron job that syncs rows every N minutes.** Same duplication, plus a staleness
window, plus a "which side won?" problem when someone edits a row in the dashboard.
3. **Point NocoDB at the database as an external source.** Zero duplication, always
live, one connection config. The dashboard *is* the database, viewed differently.
I took option 3. The cost is real and worth stating up front: the database's port has to
be reachable from wherever NocoDB runs, and NocoDB now holds a database credential. Both
are acceptable if the credential is a read-only account limited to one host — which is
exactly what the rest of this post builds.
## The trap nobody warns you about: which IP the database sees
This is the part I got wrong first, and it is not NocoDB-specific.
My NocoDB runs in Docker on a Synology NAS, on a user-defined bridge network
(`bridge_hoelee`, container IP `172.16.0.4`). The MariaDB it needs to reach lives on a
different machine at `192.168.1.124` — outside the bridge subnet.
My first instinct was to grant the account to the container subnet:
```sql
-- WRONG (well, useless): the container's own IP is not what the server sees
CREATE USER 'nocodb_ro'@'172.16.0.%' IDENTIFIED BY '<password>';
```
It never matched. When a container connects to an address **outside its bridge network**,
the traffic is NATed out through the host — Docker's masquerade rule rewrites the source
address to the **host's** IP. The database sees `192.168.1.1` (the NAS), not
`172.16.0.4` (the container).
Don't reason about it, measure it. One `SELECT` from *any* container on the same bridge
tells you the truth, and it takes ten seconds:
```bash
docker exec mysql-server mysql -h192.168.1.124 -unocodb_ro -B -e "SELECT CURRENT_USER();"
# [email protected] <- the host IP, not the container IP
```
(Pass the password through `MYSQL_PWD` rather than `-p<password>`: on the command line it
ends up in `ps` output and your shell history.)
Once you know the source address, the grant writes itself — **one host, one schema, one
privilege**:
```sql
CREATE USER 'nocodb_ro'@'192.168.1.1' IDENTIFIED BY '<password>';
GRANT SELECT ON appdb.* TO 'nocodb_ro'@'192.168.1.1';
FLUSH PRIVILEGES;
```
Two rules I'd apply to any dashboard account:
- **Never reuse the application's own database user.** The app's account can `INSERT`,
`UPDATE` and `DELETE`; the dashboard's cannot. If the dashboard credential leaks out of
a browser session, the blast radius is "someone can read rows", not "someone can
rewrite the business".
- **Grant to the exact source address, not a wildcard.** `'192.168.1.1'` is one line, and
it survives a `SHOW GRANTS` review.
## Creating the external source through the API
NocoDB (image `nocodb/nocodb:2026.09.0` in my case, listening on `10380`) exposes a meta
API; the workspace token lives in the `nc_api_tokens` table of its own meta database. The
call that creates an external MySQL source looks like this:
```bash
curl -s -X POST "$NC/api/v2/meta/bases/$BASE/sources" \
-H "xc-token: $TOKEN" -H 'Content-Type: application/json' \
-d '{
"type": "mysql2",
"title": "app-vm",
"config": {
"client": "mysql2",
"connection": {
"host": "192.168.1.124", "port": 3306,
"user": "nocodb_ro", "password": "<password>",
"database": "appdb"
}
}
}'
# {"id":"joblzodc9u91flnkw"}
```
Two things about that response are surprising:
1. **You get a job id, not a source.** Source creation is asynchronous. There is also
**no job-status route** — I probed `/api/v2/jobs/{id}`, `/api/v1/db/meta/jobs/{id}`,
`/api/v2/meta/jobs/{id}` and `/api/v1/jobs/{id}`, and all four are 404. Don't sit there
polling; verify the outcome instead.
2. **It auto-syncs every existing table of that database into the base.** You do not add
tables one by one. A few seconds later the base already contained all four of my
tables.
The verification I settled on reads the source list and looks at `meta`:
```
source budabxoawz5lwn9 type=mysql2 order=1 meta=None
source b2sdp3iw0ed1bgo type=mysql2 order=2 meta={'dbVersion': '10.11.19-MariaDB-ubu2404'}
```
That `dbVersion` is echoed from the **target** server, so it doubles as proof that the
connection succeeded — and as a free reminder that the far end is MariaDB, not MySQL.
Reading rows is then the normal records API:
```bash
curl -s -H "xc-token: $TOKEN" \
"$NC/api/v2/tables/m01ejjhhne1h59z/records?limit=1" | jq '.pageInfo.totalRows'
```
## The trap: `POST /meta/bases/{id}/tables` does not mean "attach this table"
Because the tables appeared automatically, I assumed the table route was the *manual*
version of the same thing — attach an existing table from a chosen source. It is not.
`POST /api/v2/meta/bases/{baseId}/tables` **creates a brand-new empty table in the base's
default source**. I passed my four table names and got four empty tables inside NocoDB's
own internal database, named `nc_pd1g___leads`, `nc_pd1g___orders` and so on.
They delete cleanly (`DELETE /api/v2/meta/tables/{tid}`), and nothing was harmed because
the real tables live in the external source. But it is a good example of an API route
whose name suggests "connect" and whose behaviour is "create". For external sources, the
sync you want already happened at source-creation time.
## What a read-only source costs you
Read-only is the design goal, but it does have two visible consequences, and I'd rather
document them than pretend they don't exist.
**You cannot change the table metadata.** I wanted the leads table's primary display
column to be the person's name; NocoDB had auto-picked a dedupe hash instead. Promoting
the column returned:
```
400 {"error":"ERR_DATABASE_OP_FAILED","message":"This request couldn't be processed by the database..."}
```
Set column order and visibility in the view (UI) instead of fighting the API. It is a
cosmetic problem, not a data problem.
**`DATETIME` columns render with a UTC label.** A row written at 01:54 local time comes
back as `2026-09-27 01:54:16+00:00`. The value is correct; the zone suffix is a
presentation artefact. Fine for browsing, wrong for cross-timezone arithmetic — don't
build on it.
There is also a small proof hiding in this: the auto-sync pulled in my app's `migrations`
table (it is a table like any other). Trying to remove it from the base failed with
`DROP command denied` — from the database, not from NocoDB. That is the read-only grant
doing its job, and it is a better verification than any UI label.
## Four smaller gotchas worth stealing
1. **Identify a service by an endpoint, not by the port you remember.** My first probes
went to the wrong port and came back with Go-style `404 page not found` bodies, which
look exactly like "the API moved in this version". `curl -s /api/v1/health` on the
right port answered `{"message":"OK"}` and settled it in one second. Related: an
unauthenticated NocoDB meta call answers **401**; a 404 with a Go-flavoured body means
you are talking to a different process entirely.
2. **NocoDB's `NC_DB` is not a DSN.** It is
`mysql2://mysql-server:3306/?d=<db>&u=<user>&p=<password>` — parse the **query
string**. I parsed it as `user:password@host`, got an empty host, and silently skipped
a whole verification block.
3. **A soft-deleted base answers `404 ERR_BASE_NOT_FOUND`.** The base still exists in
`nc_bases_v2` (with `deleted=1`) but is invisible to the API. "Not found" here means
*deleted or out of scope*, not *wrong route* — check the meta table before you go
hunting for a typo in your URL.
4. **The bases list is scoped to the token's workspace.** A workspace token lists only
that workspace's live bases. A suspiciously short list is a scope or soft-delete
symptom, not an authentication failure.
## What I'd do differently
- **Measure the source address before writing the grant.** I granted a container-subnet
wildcard because I reasoned about the container's own IP. Ten seconds of
`SELECT CURRENT_USER()` would have told me the answer, and it is a fact you cannot
deduce reliably from the compose file.
- **Treat read-only as the architecture, not a limitation.** Accept that the dashboard
cannot rewrite metadata, and do cosmetic work in the view layer.
- **Don't call the table-create route at all.** The source creation already imported
everything; reaching for a second API route only created cleanup work.
- **One database account per consumer** — app, dashboard, backup job. The dashboard's
credential is the one most likely to be pasted into a browser or a ticket, so it should
be the weakest one in the system.
## The result
- One external source, **four tables live** (leads, orders, order files, agent ledger),
**zero duplicated rows** — the owner browses the same rows the application writes, the
moment they are written.
- The dashboard account can only `SELECT` on one schema from one host, and that is
enforced by the **database**: NocoDB's own table-drop attempt comes back
`DROP command denied`.
- Setup time: about 30 minutes of API probing on the first pass, ~10 minutes now that the
recipe is written down (grant → source → verify `dbVersion` → read a row).
If you are doing this for a client, the shape is worth copying even if NocoDB is not the
tool you choose: **the dashboard connects to the real data, through an account that
physically cannot write to it, and you verify both halves** — that the connection works
(`CURRENT_USER()`, `dbVersion`) and that the account is harmless (`DROP` refused).
---
*I'm Lee Teong Hoe (Mr Hoelee). I wire self-hosted dashboards like this to existing
databases — least-privilege accounts, NocoDB or plain SQL views, behind Traefik and
Cloudflare, on a NAS or a small VM.*
*Want this set up for your business? [WhatsApp +60 12-797 2969](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
· [hoelee.com](https://hoelee.com)*
````
---
## 文章 09 / 09:用 IPBan 替换 RDPGuard:没人写出来的三个坑
- **Slug**: `replacing-rdpguard-with-ipban`
- **英文标题**: Replacing RDPGuard With IPBan: The Traps Nobody Documents
- **分类**: `devops` | **发布日期**: 2026-09-19
- **线上地址**: https://blog.hoelee.com/posts/replacing-rdpguard-with-ipban/ (EN) | https://blog.hoelee.com/posts/zh/replacing-rdpguard-with-ipban/ (ZH)
- **本篇审核状态**: ⏳ 待审核 (Pending)
### ✍️ 顾问审阅意见区 (Review Feedback)
```text
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
```
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
````markdown
---
title: "用 IPBan 替换 RDPGuard:没人写出来的三个坑"
description: "我把付费的 RDPGuard 7.8.7 换成了开源的 IPBan 4.1.0,踩到三个没有文档的坑——其中一个卸载程序会悄悄放走 12 个正在攻击的 IP。"
pubDate: 2026-09-19
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L4-secops-cicd-dr
tags: [windows, security, rdp, ipban, rdpguard, brute-force, firewall, self-hosting]
ogImage: /og/zh-replacing-rdpguard-with-ipban.jpg
banner: /banners/replacing-rdpguard-with-ipban.jpg
draft: false
---
只要把一台开了远程桌面的 Windows 机器放到公网上,你就知道接下来会发生什么:几分钟内,日志里就塞满了来自世界各地租用服务器的登录失败记录。你必须有东西自动把它们挡掉。
我用了好几年 **RDPGuard 7.8.7**。它是能用的,但它是付费闭源的,而且我已经落后了三个大版本。所以我换成了 **IPBan**——MIT 许可、从 2011 年维护至今、GitHub 上大约 2200 星,确实是目前最接近即插即用的替代品。
整个迁移花了一个晚上。其中三步没有文档,而其中一步会悄悄**让 12 个正在攻击的 IP 重新获得访问权**。以下是真实发生的过程。
## 为什么这件事值得看
把 RDP 暴露在公网上不是「也许会有人来试试」的风险。在我这台机器上,IPBan 上线的**第一个小时内就检测并封禁了 16 个不同的攻击 IP**——其中一个遍历了管理员用户名词典,还有两个直接猜机器主人的真实名字作为登录名。这是有针对性的攻击,不是环境噪音。
如果你在跑 RDPGuard、IPBan 或任何类似的东西,这篇文章里有两件事跟你有关系,哪怕你永远不迁移:
1. **卸载一个拦截工具可能会把攻击者放出来。** 这类工具通常独占一条防火墙规则。工具没了,规则就没了,封禁也就没了。
2. **你以为控制封禁时长的那个配置键,多半不是。** 这个故事里两个工具都有这样「听起来没错、其实干别的事」的键。
## 我原本在跑什么
RDPGuard 装成了两个服务加三个进程:
```
RdpGuardService C:\Program Files (x86)\RdpGuard\rdpguard-svc.exe
RdpGuardProxy ...\RDPGuardProxyServer\RDPGuardProxyServer.exe
```
它一直在干活。它的防火墙规则里躺着 **12 个被封的 IP**,每个在 `C:\ProgramData\RdpGuard\blocked_ips\` 都有一份 XML 记录,里面存着 IP、尝试的用户名和协议。
最后一项,恰恰是最该检查的东西。
## 坑一:卸载程序会把所有人放出来
RDPGuard 独占了一条叫 `rdpguard-e1e259c5-local` 的 Windows 防火墙规则。12 个封禁全在这条规则里。
问题就在这里:卸载 RDPGuard 时,这条规则会跟着一起走。卸载程序没有任何理由去区分「不安全的工具」和「有用的封禁列表」——它只管删掉自己创建的东西。如果先卸载,你就等于给 12 个已知攻击者发了白纸一张。
所以在动卸载程序之前,我先把封禁导出来,喂给了 IPBan。IPBan 会读取服务目录里的纯文本 `ban.txt`——一行一个 IP——并在下一个周期消费掉:
```bash
# C:\app\IPBan\ban.txt — 一行一个 IP
118.70.185.179
121.162.129.47
163.47.35.89
183.80.60.33
20.57.167.231
202.165.14.28
45.131.194.223
45.141.233.12
45.146.54.21
5.181.86.179
5.181.86.60
61.216.137.152
```
IPBan 几秒内就读到了,自己把文件删掉,然后重写了它自己的规则:
```
19:22:00 Updating firewall with 12 entries...
19:22:00 Firewall entries updated: 118.70.185.179:add, 121.162.129.47:add,
163.47.35.89:add, 183.80.60.33:add, 20.57.167.231:add, 202.165.14.28:add,
45.131.194.223:add, 45.141.233.12:add, 45.146.54.21:add, 5.181.86.179:add,
5.181.86.60:add, 61.216.137.152:add
```
这些封禁记录也值得读一读,而不是直接删。12 个里有 2 个试用的用户名是 `HOELEE01` 和 `HOELEE1`——正好是机器主人的账号名,被猜出来了。另一个把 `Administrator` → `Administrador` → `Admin` 轮了一遍。这是针对具体个人的侦察。如果先让卸载程序跑,这类细节你根本不会知道自己丢了。
## 坑二:`--install-service` 根本不存在
IPBan 官方安装方式是一个 PowerShell 脚本,把最新版拉到 `C:\Program Files\IPBan` 并注册服务。我故意没用它,因为我已经把指定版本解压到 `C:\app\IPBan`,想保留那份配置和数据库。
于是我很自然地试了:
```
DigitalRuby.IPBan.exe --install-service
```
IPBan 4.1.0 的命令行非常精简。这是它的全部命令:
```
Commands:
version Get ipban software version
info Get information about hosting OS
migrate Migrate other provider to ipban.override.config
logfiletest <file> Test a log file with regexes for failures and successes
list List currently banned IPs (State=Active/in firewall)
unban <ip> Request UNBAN for an IP or for all currently banned IPs
ban <ip> Request BAN for an IP
```
没有 `--install-service`,没有 `--service`,跟服务相关的一个都没有。IPBan 的设计是「用脚本装」,而你不用脚本时,注册服务就是你自己的事。
答案是 `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"
```
**`sc.exe` 里的空格是承重的。** `type= own`、`start= auto`、`binPath= "..."` 每一处的 `=` 后面都**必须有一个空格**。省略了,`sc` 会把参数当成一整个 token 解析然后报错。这是个真实的陷阱:它看起来像打错字,所以看起来也可以不打。
跟官方脚本相比,我有两个刻意的差异:
| | 官方安装脚本 | 我用的 |
|---|---|---|
| 路径 | `C:\Program Files\IPBan` | `C:\app\IPBan`(沿用已有目录,配置得以保留) |
| 启动方式 | `delayed-auto` | `auto`——开机更早启动,对暴露 RDP 的机器更合适 |
验证是否注册成功,有四种方式:
```bash
$ sc.exe qc IPBAN
SERVICE_NAME: IPBAN
TYPE : 10 WIN32_OWN_PROCESS
START_TYPE : 2 AUTO_START
BINARY_PATH_NAME: C:\app\IPBan\DigitalRuby.IPBan.exe
SERVICE_START_NAME : LocalSystem
```
Windows 事件日志可以独立佐证——事件 ID 7045:
```
A service was installed in the system.
Service Name: IPBAN
Service Start Type: auto start
Service Account: LocalSystem
```
一个小小的瑕疵:因为 `sc create` 没给 `DisplayName=`,服务的显示名就是干巴巴的 `IPBAN`,而不是一个友好的名字。无害,但在 `services.msc` 里看起来像没装完。
## 坑三:`ExpireTime` 不是封禁时长
这个是我一开始搞错的,也是最容易坑到人的一个。
我想要 24 小时封禁。我在配置里搜到 `ExpireTime`,差点就改了。真正的键是 **`BanTime`**:
```xml
<!-- The duration of time to ban an ip address (DD:HH:MM:SS) -->
<add key="BanTime" value="01:00:00:00"/>
```
`ExpireTime` 干的是完全另一件事——它是**失败登录计数**在被清零之前会被记住多久:
```xml
<!-- The duration after the last failed login attempt that the ip is forgotten
(count reset back to 0). Set to 00:00:00:00 to use max duration. -->
<add key="ExpireTime" value="01:00:00:00"/>
```
两者的默认值都是 `01:00:00:00`,所以看起来像是可以互换的。它们不是。当你本想设 `BanTime` 却设了 `ExpireTime`,你的封禁时长根本没变——你只是悄悄改掉了攻击者累积次数的窗口。
格式是 `DD:HH:MM:SS`。所以 24 小时是 `01:00:00:00`,而 `00:00:00:00` 表示「9999 天」,也就是实际上永久。
还有第二个障眼法。`ipban.config` 里每个 `<LogFile>` 块都带一个自己的 `<FailedLoginThreshold>` 元素——而且**全部 25 个都是 `0`**,意思是「用全局默认值」。真正起作用的全局键是:
```xml
<!-- Number of failed logins before banning the ip address -->
<add key="FailedLoginAttemptsBeforeBan" value="5"/>
```
改那些 per-log 元素完全不会有任何效果。
## 怎样调 IPBan 又不搞丢配置
IPBan 把配置拆成两份:
| 文件 | 作用 |
|---|---|
| `ipban.config` | 官方默认值。**升级时会被覆盖——永远不要改。** |
| `ipban.override.config` | 你的设置。叠加在上面。改这个。 |
我实际在用的 `ipban.override.config`:
```xml
<appSettings>
<!-- 不要封禁这些。白名单优先级高于黑名单。 -->
<add key="Whitelist"
value="192.168.1.0/24,100.64.0.0/10,127.0.0.1,:?::1"/>
<!-- 3 次失败登录即封禁(默认 5 次) -->
<add key="FailedLoginAttemptsBeforeBan" value="3"/>
<!-- 封禁时长,DD:HH:MM:SS。01:00:00:00 = 24 小时 -->
<add key="BanTime" value="01:00:00:00"/>
</appSettings>
```
白名单这一项必须强调。IPBan 会监听 **20 个事件日志查询**,外加一堆日志文件——不只是 RDP,还有 OpenSSH、IIS、Exchange、MSSQL、MySQL、FTP、SMTP/IMAP/POP3、VoIP/SIP、VNC、Tomcat。上面任何一个都可能触发封禁。
如果你自己的地址不在白名单里,三次输错密码就能把你锁在自己的机器外面 24 小时。如果 RDP 是你唯一的远程通道、端口又是公开的,这就是个真实的恢复难题。白名单里的 IP 还会被单独放进一条 **Allow** 防火墙规则(`IPBan_GlobalWhitelist_0`),优先级更高。
注意在容器或 NAT 网络里你没法自己测试——当你的局域网和 Tailscale 段都进了白名单,就没有什么方便的办法从本机故意触发一次封禁。
## 常用命令
```bash
# 实时看日志
Get-Content C:\app\IPBan\logfile.txt -Wait -Tail 20
# 列出当前被封的 IP
C:\app\IPBan\DigitalRuby.IPBan.exe list
# 手动封禁 / 解封
C:\app\IPBan\DigitalRuby.IPBan.exe ban 203.0.113.9
C:\app\IPBan\DigitalRuby.IPBan.exe unban 203.0.113.9
C:\app\IPBan\DigitalRuby.IPBan.exe unban all
```
所有封禁都在同一条防火墙规则里,每个周期重写一遍,所以 GUI 是找错地方了——`list` 才是真相来源。
## 结果
第一个小时内封掉 16 个攻击 IP,迁移全程没有防护空窗:
- **12 个是导入**自 RDPGuard,所以卸载它没有放走任何东西
- **4 个是 IPBan** 自己抓的,其中一个还是在迁移过程中来的
IPBan 启动时会记录 `Remote ip address:`,这是确认服务把你哪个地址当作客户端的好办法。要验证配置是否真的生效,比看起来要难——IPBan 在文件变动时会**热重载**并记录 `Config file changed`,但它不会重新打印你的设置。完整重启(`Restart-Service IPBAN -Force`)需要管理员权限;在非提权 shell 里你会得到 `System error 5 / Access is denied`,然后可能误以为重启成功了,其实什么都没发生。
## 我会怎么改
- **卸载任何东西之前,先导出封禁列表。** 这一步要放到第一位,而不是事后补。任何独占防火墙规则的工具,都会连封禁列表一起带走。
- **读封禁记录,不只是看 IP。** 那些被尝试的用户名告诉我,这是有针对性的攻击。
- **不要只 grep 一个听起来像的键名——读它上面的注释。** `BanTime` 和 `ExpireTime` 默认值相同,这正是设错了还能通过测试的原因。
- **当官方安装脚本会让你的配置成为孤儿时,宁可自己用 `sc.exe`。** 如果你已经把某个版本解压到别处,再「正规地」装到 `Program Files`,等于把数据库和设置丢在原地。
- **治根,不要只治症。** 这类工具全都是在失败登录**之后**才反应。每个攻击者在每个封禁窗口内依然能试满它那个阈值次,而且 IP 无限多。真正耐久的做法是不发布 3389——限制为局域网和 VPN 段可达,让拦截工具只处理剩下的部分。
拦截工具只是缓解措施。如果一个端口本来不需要面向互联网,关掉它比防守它划算。
## 需要为你的业务做这个吗?
如果你有 Windows 服务器把 RDP、SQL 或邮件服务暴露在公网上,我可以帮你搭这类暴力破解防护——自动封禁接入 Windows 防火墙、合理的阈值,以及一份不会把你自己锁在外面的白名单。
**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[[email protected]](mailto:[email protected]?subject=RDP%20brute-force%20protection)** · **[hoelee.com](https://hoelee.com)**
网站设计与开发是我的主业;服务器加固与自托管基础设施是它的另一半。
````
### 🇬🇧 英文版原文 (English Markdown Primary)
````markdown
---
title: "Replacing RDPGuard With IPBan: The Traps Nobody Documents"
description: "I migrated from paid RDPGuard 7.8.7 to open-source IPBan 4.1.0 — and hit three undocumented traps, including an uninstaller that would have silently unbanned 12 attackers."
pubDate: 2026-09-19
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L4-secops-cicd-dr
tags: [windows, security, rdp, ipban, rdpguard, brute-force, firewall, self-hosting]
ogImage: /og/replacing-rdpguard-with-ipban.jpg
banner: /banners/replacing-rdpguard-with-ipban.jpg
draft: false
---
Anyone who has put a Windows machine on the public internet with Remote Desktop enabled knows the drill. Within minutes of the port being reachable, the log fills with failed logins from rented servers all over the world. Something has to block them automatically.
I ran **RDPGuard 7.8.7** for years. It worked. But it's paid, closed-source, and I was three major versions behind. So I replaced it with **IPBan** — MIT-licensed, maintained since 2011, about 2,200 stars on GitHub, and genuinely the closest thing to a drop-in replacement.
The migration took an evening. Three of the steps were undocumented and one of them would have quietly **removed protection for twelve active attackers**. This is what actually happened.
## Why this matters
RDP exposed to the internet is not a "maybe someone will try" risk. On this machine, IPBan detected and banned **16 distinct attacker IPs in its first hour** — including one that walked a dictionary of administrator usernames and two that guessed the machine owner's actual name as the login. That's a targeted attack, not ambient noise.
If you run RDPGuard, IPBan, or anything similar, two things in this post will matter to you even if you never migrate:
1. **Uninstalling a blocker can unban your attackers.** Most tools own a firewall rule. Remove the tool, lose the rule, lose the bans.
2. **The config key you think controls ban duration probably doesn't.** Both tools in this story have a key that *sounds* right and does something else.
## What I was running
RDPGuard installed as two services plus three processes:
```
RdpGuardService C:\Program Files (x86)\RdpGuard\rdpguard-svc.exe
RdpGuardProxy ...\RDPGuardProxyServer\RDPGuardProxyServer.exe
```
It was doing its job. Its firewall rule held **12 blocked IPs**, each with a per-ban XML record in `C:\ProgramData\RdpGuard\blocked_ips\` capturing the IP, the attempted username, and the protocol.
That last part turned out to be the most important thing I checked.
## Trap 1: The uninstaller would have unbanned everyone
RDPGuard owns a Windows Firewall rule named `rdpguard-e1e259c5-local`. All twelve bans live in that one rule.
Here's the problem: when you uninstall RDPGuard, that rule goes with it. The uninstaller has no reason to distinguish "unsafe tool" from "useful ban list" — it just removes what it created. Uninstall first, and you'd have handed twelve known attackers a clean slate.
So before touching the uninstaller, I exported the bans and fed them into IPBan. IPBan reads a plain `ban.txt` file in its service folder — one IP per line — and consumes it on the next cycle:
```bash
# C:\app\IPBan\ban.txt — one IP per line
118.70.185.179
121.162.129.47
163.47.35.89
183.80.60.33
20.57.167.231
202.165.14.28
45.131.194.223
45.141.233.12
45.146.54.21
5.181.86.179
5.181.86.60
61.216.137.152
```
IPBan picked it up within seconds, deleted the file itself, and rewrote its own rule:
```
19:22:00 Updating firewall with 12 entries...
19:22:00 Firewall entries updated: 118.70.185.179:add, 121.162.129.47:add,
163.47.35.89:add, 183.80.60.33:add, 20.57.167.231:add, 202.165.14.28:add,
45.131.194.223:add, 45.141.233.12:add, 45.146.54.21:add, 5.181.86.179:add,
5.181.86.60:add, 61.216.137.152:add
```
The ban records also turned out to be worth reading rather than deleting. Two of the twelve had tried the usernames `HOELEE01` and `HOELEE1` — the machine owner's actual handle, guessed. One had cycled `Administrator` → `Administrador` → `Admin`. That's reconnaissance aimed at a specific person, and it's the kind of detail you'd never know you'd lost if you let the uninstaller run first.
## Trap 2: `--install-service` does not exist
The official install path for IPBan is a PowerShell script that pulls the latest release into `C:\Program Files\IPBan` and registers the service. I deliberately skipped it, because I'd already unzipped a specific version to `C:\app\IPBan` and wanted to keep that config and database.
So I tried the obvious thing:
```
DigitalRuby.IPBan.exe --install-service
```
IPBan 4.1.0's CLI is minimal. Here is the entire command list:
```
Commands:
version Get ipban software version
info Get information about hosting OS
migrate Migrate other provider to ipban.override.config
logfiletest <file> Test a log file with regexes for failures and successes
list List currently banned IPs (State=Active/in firewall)
unban <ip> Request UNBAN for an IP or for all currently banned IPs
ban <ip> Request BAN for an IP
```
No `--install-service`. No `--service`. Nothing service-related at all. IPBan is designed to be installed by the script, and when you don't use the script, service registration is on you.
The answer is `sc.exe`, which the project's own uninstall script hints at (`sc.exe stop IPBAN` / `sc.exe delete IPBAN`):
```bash
sc.exe create IPBAN type= own start= auto ^
binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan"
```
**The spacing in `sc.exe` is load-bearing.** `type= own`, `start= auto`, and `binPath= "..."` each require **a space after the `=`**. Omit it and `sc` parses your argument as a single token and fails. This is a genuine footgun: it looks like a typo, and therefore looks optional.
Two deliberate differences from the official script:
| | Official installer | What I used |
|---|---|---|
| Path | `C:\Program Files\IPBan` | `C:\app\IPBan` (existing dir, config preserved) |
| Startup | `delayed-auto` | `auto` — starts earlier at boot, better for an exposed RDP box |
Verifying it landed, four ways:
```bash
$ sc.exe qc IPBAN
SERVICE_NAME: IPBAN
TYPE : 10 WIN32_OWN_PROCESS
START_TYPE : 2 AUTO_START
BINARY_PATH_NAME: C:\app\IPBan\DigitalRuby.IPBan.exe
SERVICE_START_NAME : LocalSystem
```
And the Windows Event Log confirms the registration independently — event ID 7045:
```
A service was installed in the system.
Service Name: IPBAN
Service Start Type: auto start
Service Account: LocalSystem
```
One cosmetic wart: because `sc create` got no `DisplayName=`, the service's display name is the bare `IPBAN` rather than a friendly string. Harmless, but it looks unfinished in `services.msc`.
## Trap 3: `ExpireTime` is not the ban duration
This is the one I got wrong first, and it's the one most likely to bite anyone tuning either tool.
I wanted a 24-hour ban. I searched the config, found `ExpireTime`, and nearly changed it. The actual key is **`BanTime`**:
```xml
<!-- The duration of time to ban an ip address (DD:HH:MM:SS) -->
<add key="BanTime" value="01:00:00:00"/>
```
`ExpireTime` does something else entirely — it's how long a *failed-login count* is remembered before it resets to zero:
```xml
<!-- The duration after the last failed login attempt that the ip is forgotten
(count reset back to 0). Set to 00:00:00:00 to use max duration. -->
<add key="ExpireTime" value="01:00:00:00"/>
```
Both default to `01:00:00:00`, which makes them look interchangeable. They are not. Set `ExpireTime` when you meant `BanTime` and your ban durations never change — you've just quietly changed how long attackers accumulate strikes.
Format is `DD:HH:MM:SS`. So 24 hours is `01:00:00:00`, and `00:00:00:00` means "9999 days," i.e. effectively permanent.
There's a second red herring. Every `<LogFile>` block in `ipban.config` carries its own `<FailedLoginThreshold>` element — and **all 25 of them are `0`**, meaning "use the global default." The global key that actually governs is:
```xml
<!-- Number of failed logins before banning the ip address -->
<add key="FailedLoginAttemptsBeforeBan" value="5"/>
```
Changing the per-log elements would have done nothing at all.
## How to tune IPBan without losing your settings
IPBan splits configuration in two:
| File | Role |
|---|---|
| `ipban.config` | Shipped defaults. **Overwritten on update — never edit.** |
| `ipban.override.config` | Your settings. Merges over the top. Edit this one. |
My working `ipban.override.config`:
```xml
<appSettings>
<!-- Never ban these. Whitelist beats blacklist. -->
<add key="Whitelist"
value="192.168.1.0/24,100.64.0.0/10,127.0.0.1,:?::1"/>
<!-- Ban after 3 failed logins (default is 5) -->
<add key="FailedLoginAttemptsBeforeBan" value="3"/>
<!-- Ban duration, DD:HH:MM:SS. 01:00:00:00 = 24 hours -->
<add key="BanTime" value="01:00:00:00"/>
</appSettings>
```
The whitelist deserves emphasis. IPBan watches **20 event-log queries** plus a pile of log files — not just RDP, but OpenSSH, IIS, Exchange, MSSQL, MySQL, FTP, SMTP/IMAP/POP3, VoIP/SIP, VNC, Tomcat. Any of those can trigger a ban.
If your own address isn't whitelisted, three mistyped passwords lock you out of your own machine for 24 hours. If RDP is your only remote access and the port is public, that's a real recovery problem. Whitelisted IPs are also placed in a separate **Allow** firewall rule (`IPBan_GlobalWhitelist_0`), which takes precedence.
Note that in a container or NAT'd network you can't always self-test — with your LAN and Tailscale ranges whitelisted, there's no easy way to deliberately trigger a ban from your own machine.
## Useful commands
```bash
# Watch the log live
Get-Content C:\app\IPBan\logfile.txt -Wait -Tail 20
# List currently banned IPs
C:\app\IPBan\DigitalRuby.IPBan.exe list
# Manually ban / unban
C:\app\IPBan\DigitalRuby.IPBan.exe ban 203.0.113.9
C:\app\IPBan\DigitalRuby.IPBan.exe unban 203.0.113.9
C:\app\IPBan\DigitalRuby.IPBan.exe unban all
```
Bans all live in one firewall rule that gets rewritten each cycle, so the GUI is the wrong place to look — `list` is the source of truth.
## The result
Sixteen attacker IPs blocked within the first hour, with no protection gap during the migration:
- **12 ported** from RDPGuard, so uninstalling it unblocked nothing
- **4 caught** by IPBan itself, including one that arrived mid-migration
IPBan logs `Remote ip address:` on startup, which is a neat way to confirm which of your own addresses the service considers the client. Verifying config is genuinely applied is harder than it looks — IPBan **hot-reloads** on file change and logs `Config file changed`, but it does not re-print your settings. A full restart (`Restart-Service IPBAN -Force`) requires elevation; from a non-elevated shell you get `System error 5 / Access is denied` and may wrongly conclude the restart worked when nothing happened.
## What I'd do differently
- **Export bans before uninstalling anything.** Make this step one, not an afterthought. Any tool that owns a firewall rule will take its ban list with it.
- **Read the ban records, not just the IPs.** The attempted usernames told me the attack was targeted.
- **Don't grep for a plausible key name — read the comment above it.** `BanTime` and `ExpireTime` both default to the same value, which is exactly why the wrong one survives testing.
- **Prefer `sc.exe` when the official installer would orphan your config.** If you've already unzipped a version somewhere, installing "properly" into `Program Files` leaves your database and settings behind.
- **Fix the exposure, not just the symptom.** Every tool here reacts *after* failed logins. Each attacker still gets its full threshold of guesses per ban window, from unlimited IPs. The durable fix is to not publish 3389 — restrict it to your LAN and VPN ranges and let the blocker handle the residue.
A blocker is a mitigation. If a port doesn't need to face the internet, closing it beats defending it.
## Want this for your business?
If you're running Windows servers with RDP, SQL, or mail services reachable from the internet, I set up brute-force protection like this — automated IP banning wired into the Windows Firewall, sensible thresholds, and a whitelist that keeps you from locking yourself out.
**WhatsApp: [+60 12-797 2969](https://wa.me/60127972969)** · **Email: [[email protected]](mailto:[email protected]?subject=RDP%20brute-force%20protection)** · **[hoelee.com](https://hoelee.com)**
Website design and development is my main line of work; server hardening and self-hosted infrastructure is the other half of it.
````
---