edit(content): review batch-2 editorial pass across 24 posts (EN + ZH)
Tracker at 20 approved / 71 pending. Same pass as batch 1: first person throughout, no LAN IPs in prose, ZH twins normalised to simplified Chinese, dynamicCta where the post ends on a concrete next step.
This commit is contained in:
1 parent
041471d25e
commit
09596fe7a5
45 files changed
+418
-337
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# 全站文章顾问审阅状态总控台 (Audit Tracker)
|
||||
|
||||
> **最后同步时间**:2026-10-27 03:22:38
|
||||
> **全站总文章数**:91 篇 | **已审批完成**:9 篇 (9.9%) | **待审阅**:82 篇
|
||||
> **最后同步时间**:2026-10-27 04:06:09
|
||||
> **全站总文章数**:91 篇 | **已审批完成**:20 篇 (22.0%) | **待审阅**:71 篇
|
||||
|
||||
## 一、操作命令速查
|
||||
- `python scripts/manage_review.py list` — 查看待审与已审概览
|
||||
@@ -36,20 +36,20 @@
|
||||
| 22 | `case-studies` | `self-healing-digital-goods-entitlements` | 数字商品的自愈式访问控制:NocoDB、n8n 与 AList | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 23 | `case-studies` | `self-hosted-speech-to-text-api` | 自建语音转文字服务:打字速度的 3 倍以上,而且数据不出内网 | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 24 | `case-studies` | `self-hosted-tts-cosyvoice2-n8n-gpu` | 从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈 | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 25 | `case-studies` | `self-hosting-mem0-memory-stack` | 我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆) | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 25 | `case-studies` | `self-hosting-mem0-memory-stack` | 我如何在私有存储节点上自托管 Mem0 记忆栈(让 AI 助手拥有持久记忆) | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 26 | `case-studies` | `shipping-an-ai-photo-editor-as-a-wordpress-plugin` | 把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验 | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 27 | `case-studies` | `syncing-a-self-improving-ai-agent-across-machines` | 在多台机器之间同步一个会自我学习的 AI Agent | ⏳ 待审阅 | Batch-02 | - | |
|
||||
| 28 | `case-studies` | `the-single-writer-rule-for-coding-agents` | 代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 29 | `case-studies` | `when-to-give-your-agent-physical-clicks` | 什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用) | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 30 | `case-studies` | `why-i-built-a-video-knowledge-base` | 为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 31 | `case-studies` | `why-your-headless-browser-cant-scrape-everything` | 为什么你的无头浏览器不是万能爬虫 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 32 | `devops` | `authentik-css-greater-than-bug` | 一个字符让我的 authentik CSS 静默失效 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 33 | `devops` | `authentik-forward-auth-gate-wasnt-live` | 验证完全正确的 Forward-Auth 网关——其实并未上线 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 34 | `devops` | `authentik-major-upgrade-gotchas` | Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 35 | `devops` | `automating-cyberpanel-without-the-ui` | 不用界面管理 CyberPanel:逆向一个没有文档的 v2 API | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 36 | `devops` | `docker-reported-exit-0-the-oom-killer-disagreed` | Docker 报告退出码 0,内核却早已执行 OOM-Kill | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 37 | `devops` | `every-player-stuttered-the-file-was-fine` | 每个播放器都在同一个视频上卡顿,而文件本身是无辜的 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 38 | `devops` | `hardening-a-tor-onion-service` | 加固 Tor 洋葱服务:真正重要的是什么 | ⏳ 待审阅 | Batch-03 | - | |
|
||||
| 28 | `case-studies` | `the-single-writer-rule-for-coding-agents` | 代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 29 | `case-studies` | `when-to-give-your-agent-physical-clicks` | 什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用) | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 30 | `case-studies` | `why-i-built-a-video-knowledge-base` | 为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 31 | `case-studies` | `why-your-headless-browser-cant-scrape-everything` | 为什么你的无头浏览器不是万能爬虫 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 32 | `devops` | `authentik-css-greater-than-bug` | 一个字符让我的 authentik CSS 静默失效 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 33 | `devops` | `authentik-forward-auth-gate-wasnt-live` | 验证完全正确的 Forward-Auth 网关——其实并未上线 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 34 | `devops` | `authentik-major-upgrade-gotchas` | Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 35 | `devops` | `automating-cyberpanel-without-the-ui` | 不用界面管理 CyberPanel:逆向一个没有文档的 v2 API | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 36 | `devops` | `docker-reported-exit-0-the-oom-killer-disagreed` | Docker 报告退出码 0,内核却早已执行 OOM-Kill | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 37 | `devops` | `every-player-stuttered-the-file-was-fine` | 每个播放器都在同一个视频上卡顿,而文件本身是无辜的 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 38 | `devops` | `hardening-a-tor-onion-service` | 加固 Tor 洋葱服务:真正重要的是什么 | ✅ 已通过 | Batch-03 | 2026-10-27 03:49 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
|
||||
| 39 | `devops` | `it-reported-success-nothing-had-changed` | 它报告了成功,可实际上什么都没变 | ⏳ 待审阅 | Batch-04 | - | |
|
||||
| 40 | `devops` | `n8n-v1-to-v2-upgrade-gotchas` | n8n v1 升级到 v2:一个日志文件里的七项废弃警告 | ⏳ 待审阅 | Batch-04 | - | |
|
||||
| 41 | `devops` | `one-hostname-public-tracker-sso-dashboard` | 公开的埋点与要 SSO 的仪表盘,怎么共用一个域名 | ⏳ 待审阅 | Batch-04 | - | |
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
"zh_title": "一个字符让我的 authentik CSS 静默失效",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-09-20",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 9206,
|
||||
"zh_chars": 5485
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 9317,
|
||||
"zh_chars": 5589
|
||||
},
|
||||
"authentik-forward-auth-gate-wasnt-live": {
|
||||
"slug": "authentik-forward-auth-gate-wasnt-live",
|
||||
@@ -57,12 +57,12 @@
|
||||
"zh_title": "验证完全正确的 Forward-Auth 网关——其实并未上线",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-08-12",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 16519,
|
||||
"zh_chars": 8735
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 16526,
|
||||
"zh_chars": 8746
|
||||
},
|
||||
"authentik-major-upgrade-gotchas": {
|
||||
"slug": "authentik-major-upgrade-gotchas",
|
||||
@@ -70,12 +70,12 @@
|
||||
"zh_title": "Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-09-09",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 11639,
|
||||
"zh_chars": 6086
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 11710,
|
||||
"zh_chars": 6147
|
||||
},
|
||||
"automating-cyberpanel-without-the-ui": {
|
||||
"slug": "automating-cyberpanel-without-the-ui",
|
||||
@@ -83,12 +83,12 @@
|
||||
"zh_title": "不用界面管理 CyberPanel:逆向一个没有文档的 v2 API",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-09-09",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 8495,
|
||||
"zh_chars": 5223
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 8474,
|
||||
"zh_chars": 5205
|
||||
},
|
||||
"best-ai-video-generators-2026": {
|
||||
"slug": "best-ai-video-generators-2026",
|
||||
@@ -148,12 +148,12 @@
|
||||
"zh_title": "Docker 报告退出码 0,内核却早已执行 OOM-Kill",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-10-16",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 10183,
|
||||
"zh_chars": 5483
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 10200,
|
||||
"zh_chars": 5488
|
||||
},
|
||||
"does-hevc-actually-shrink-your-files": {
|
||||
"slug": "does-hevc-actually-shrink-your-files",
|
||||
@@ -187,12 +187,12 @@
|
||||
"zh_title": "每个播放器都在同一个视频上卡顿,而文件本身是无辜的",
|
||||
"category": "devops",
|
||||
"pub_date": "2026-10-08",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 7884,
|
||||
"zh_chars": 3921
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 8459,
|
||||
"zh_chars": 4296
|
||||
},
|
||||
"five-step-ai-roadmap-what-i-actually-run": {
|
||||
"slug": "five-step-ai-roadmap-what-i-actually-run",
|
||||
@@ -275,15 +275,15 @@
|
||||
"hardening-a-tor-onion-service": {
|
||||
"slug": "hardening-a-tor-onion-service",
|
||||
"title": "Hardening a Tor Onion Service: What Actually Matters",
|
||||
"zh_title": "加固 Tor 洋葱服务:真正重要的是什么",
|
||||
"zh_title": "加固 Tor 洋葱服务:真正重要的是什么",
|
||||
"category": "devops",
|
||||
"pub_date": "2025-12-10",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 14882,
|
||||
"zh_chars": 7031
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 14907,
|
||||
"zh_chars": 7041
|
||||
},
|
||||
"hello-world": {
|
||||
"slug": "hello-world",
|
||||
@@ -399,8 +399,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 7654,
|
||||
"zh_chars": 3480
|
||||
"en_chars": 8349,
|
||||
"zh_chars": 3886
|
||||
},
|
||||
"jpa-version-field-lost-update": {
|
||||
"slug": "jpa-version-field-lost-update",
|
||||
@@ -464,8 +464,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 11892,
|
||||
"zh_chars": 6540
|
||||
"en_chars": 12262,
|
||||
"zh_chars": 6712
|
||||
},
|
||||
"nocodb-sso-is-a-licensed-feature": {
|
||||
"slug": "nocodb-sso-is-a-licensed-feature",
|
||||
@@ -490,8 +490,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 13998,
|
||||
"zh_chars": 6677
|
||||
"en_chars": 14400,
|
||||
"zh_chars": 6837
|
||||
},
|
||||
"one-prometheus-for-unraid-synology-and-a-vps": {
|
||||
"slug": "one-prometheus-for-unraid-synology-and-a-vps",
|
||||
@@ -516,8 +516,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 7740,
|
||||
"zh_chars": 4063
|
||||
"en_chars": 7864,
|
||||
"zh_chars": 4187
|
||||
},
|
||||
"patching-workbench-26-for-mariadb": {
|
||||
"slug": "patching-workbench-26-for-mariadb",
|
||||
@@ -529,8 +529,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 15104,
|
||||
"zh_chars": 9020
|
||||
"en_chars": 15965,
|
||||
"zh_chars": 9501
|
||||
},
|
||||
"php2shell-patching-every-php-runtime-i-manage": {
|
||||
"slug": "php2shell-patching-every-php-runtime-i-manage",
|
||||
@@ -542,8 +542,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 22273,
|
||||
"zh_chars": 12465
|
||||
"en_chars": 22354,
|
||||
"zh_chars": 12531
|
||||
},
|
||||
"plugging-windows-desktop-control-into-four-ai-agents": {
|
||||
"slug": "plugging-windows-desktop-control-into-four-ai-agents",
|
||||
@@ -568,8 +568,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 13314,
|
||||
"zh_chars": 6978
|
||||
"en_chars": 13927,
|
||||
"zh_chars": 7415
|
||||
},
|
||||
"read-only-nocodb-dashboard-for-a-remote-database": {
|
||||
"slug": "read-only-nocodb-dashboard-for-a-remote-database",
|
||||
@@ -581,8 +581,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 11723,
|
||||
"zh_chars": 6166
|
||||
"en_chars": 12009,
|
||||
"zh_chars": 6287
|
||||
},
|
||||
"reading-a-containers-own-api-docs": {
|
||||
"slug": "reading-a-containers-own-api-docs",
|
||||
@@ -607,8 +607,8 @@
|
||||
"batch_id": "Batch-04",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 12784,
|
||||
"zh_chars": 7641
|
||||
"en_chars": 13131,
|
||||
"zh_chars": 7789
|
||||
},
|
||||
"running-ai-video-locally-on-a-12gb-gpu": {
|
||||
"slug": "running-ai-video-locally-on-a-12gb-gpu",
|
||||
@@ -699,20 +699,20 @@
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 8913,
|
||||
"zh_chars": 4402
|
||||
"zh_chars": 4773
|
||||
},
|
||||
"self-hosting-mem0-memory-stack": {
|
||||
"slug": "self-hosting-mem0-memory-stack",
|
||||
"title": "How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers)",
|
||||
"zh_title": "我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆)",
|
||||
"zh_title": "我如何在私有存储节点上自托管 Mem0 记忆栈(让 AI 助手拥有持久记忆)",
|
||||
"category": "case-studies",
|
||||
"pub_date": "2026-09-16",
|
||||
"status": "pending",
|
||||
"batch_id": "Batch-02",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 7894,
|
||||
"zh_chars": 4708
|
||||
"en_chars": 8057,
|
||||
"zh_chars": 4849
|
||||
},
|
||||
"shipping-an-ai-photo-editor-as-a-wordpress-plugin": {
|
||||
"slug": "shipping-an-ai-photo-editor-as-a-wordpress-plugin",
|
||||
@@ -724,8 +724,8 @@
|
||||
"batch_id": "Batch-02",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 13782,
|
||||
"zh_chars": 6796
|
||||
"en_chars": 13879,
|
||||
"zh_chars": 6861
|
||||
},
|
||||
"slashing-agent-context-with-a-code-graph": {
|
||||
"slug": "slashing-agent-context-with-a-code-graph",
|
||||
@@ -789,8 +789,8 @@
|
||||
"batch_id": "Batch-02",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 8918,
|
||||
"zh_chars": 4599
|
||||
"en_chars": 8861,
|
||||
"zh_chars": 4541
|
||||
},
|
||||
"synology-api-401-with-the-correct-password": {
|
||||
"slug": "synology-api-401-with-the-correct-password",
|
||||
@@ -863,12 +863,12 @@
|
||||
"zh_title": "代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构",
|
||||
"category": "case-studies",
|
||||
"pub_date": "2026-08-26",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 13268,
|
||||
"zh_chars": 6916
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 13331,
|
||||
"zh_chars": 6954
|
||||
},
|
||||
"the-upstream-was-deleted-then-came-back-rewritten": {
|
||||
"slug": "the-upstream-was-deleted-then-came-back-rewritten",
|
||||
@@ -993,12 +993,12 @@
|
||||
"zh_title": "什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)",
|
||||
"category": "case-studies",
|
||||
"pub_date": "2026-05-28",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 6853,
|
||||
"zh_chars": 3594
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 6882,
|
||||
"zh_chars": 3608
|
||||
},
|
||||
"why-500000-lines-of-commit-churn-only-left-230000-in-production": {
|
||||
"slug": "why-500000-lines-of-commit-churn-only-left-230000-in-production",
|
||||
@@ -1032,12 +1032,12 @@
|
||||
"zh_title": "为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记",
|
||||
"category": "case-studies",
|
||||
"pub_date": "2024-11-28",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 5728,
|
||||
"zh_chars": 2903
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 5770,
|
||||
"zh_chars": 2924
|
||||
},
|
||||
"why-i-still-bought-a-local-gpu": {
|
||||
"slug": "why-i-still-bought-a-local-gpu",
|
||||
@@ -1136,12 +1136,12 @@
|
||||
"zh_title": "为什么你的无头浏览器不是万能爬虫",
|
||||
"category": "case-studies",
|
||||
"pub_date": "2026-01-21",
|
||||
"status": "pending",
|
||||
"status": "approved",
|
||||
"batch_id": "Batch-03",
|
||||
"approved_at": null,
|
||||
"notes": "",
|
||||
"en_chars": 8065,
|
||||
"zh_chars": 3791
|
||||
"approved_at": "2026-10-27 03:49",
|
||||
"notes": "外部顾问审阅完成,已采纳并落地全量修改意见",
|
||||
"en_chars": 8076,
|
||||
"zh_chars": 3792
|
||||
},
|
||||
"why-your-windows-mcp-calls-silently-fail": {
|
||||
"slug": "why-your-windows-mcp-calls-silently-fail",
|
||||
|
||||
@@ -126,7 +126,7 @@ There it is. **`u003e`.**
|
||||
|
||||
The `>` character — written correctly in my CSS, stored correctly in the
|
||||
database, returned correctly by the API — was rendered into the HTML as
|
||||
the literal text `u003e`. Not the `>` character. The six characters
|
||||
the literal text `u003e`. Not the `>` character. The five characters
|
||||
`u`, `0`, `0`, `3`, `e`.
|
||||
|
||||
So the browser tried to parse this selector:
|
||||
@@ -163,11 +163,9 @@ That is the whole fix. One character deleted.
|
||||
|
||||
### Where the escaping comes from
|
||||
|
||||
`\u003E` is how JSON/JavaScript encodes `>`. authentik's Django templates
|
||||
render the brand config as a JavaScript object literal, and a HTML/JS
|
||||
escaper is being applied to the `branding_custom_css` string. Backslash
|
||||
and the `u` get separated somewhere in that path, so the browser receives
|
||||
`u003e` — the escape without its backslash — instead of `>`.
|
||||
`\u003E` is how JSON/JavaScript encodes `>`. authentik's Django templates render the brand config as a JavaScript object literal, and a HTML/JS escaper is being applied to the `branding_custom_css` string.
|
||||
|
||||
In CSS syntax (unlike JS), hex escapes are written `\003e` without a `u`. Because `u` is not a hex digit, the browser's CSS tokenizer treats `\u` as an escaped literal `u`, turning `\u003E` inside the `<style>` block into the literal identifier `u003e`.
|
||||
|
||||
The stored value is correct. The API response is correct. Only the
|
||||
rendered page is wrong:
|
||||
|
||||
@@ -91,7 +91,7 @@ Here's why. My hostnames are served by **two** independent ingress paths:
|
||||
|
||||
| Path | What serves it | Where it lands |
|
||||
|---|---|---|
|
||||
| Home IP → nginx (443) | the vhost I edited | the outpost ✅ |
|
||||
| Direct WAN IP → nginx (443) | the vhost I edited | the outpost ✅ |
|
||||
| Cloudflare Tunnel (`cloudflared`) | a rule inside the tunnel's own config | the app's port, directly ❌ |
|
||||
|
||||
The tunnel's ingress is a small ordered list evaluated per hostname:
|
||||
@@ -116,7 +116,7 @@ curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$T
|
||||
```
|
||||
|
||||
```
|
||||
git.example.com -> http://192.168.1.6:6880
|
||||
git.example.com -> http://10.0.0.10:6880
|
||||
app.example.com -> http://app-container:8080
|
||||
bot.example.com -> http://bot-container:8080
|
||||
* -> http_status:404
|
||||
@@ -238,7 +238,7 @@ If the answer isn't what your configuration says it should be, you're editing th
|
||||
|
||||
If you need single sign-on in front of internal tools, a self-hosted app exposed safely to the internet, or someone to audit which ingress path is actually serving your domains, that's the work I do.
|
||||
|
||||
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) — tap to chat
|
||||
- **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) — tap to chat
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Reverse%20proxy%20%26%20SSO%20enquiry)
|
||||
- **Website:** [hoelee.com](https://www.hoelee.com)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ do per-application branding. authentik's own policy is that you can't jump
|
||||
major versions — it enforces a stepwise path. So the plan was:
|
||||
|
||||
```
|
||||
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8
|
||||
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.4 → 2026.6 → 2026.8
|
||||
```
|
||||
|
||||
Six hops, one at a time, with a migration and a health-check between each.
|
||||
@@ -123,7 +123,7 @@ Not `django_session` — that was empty. The real culprit was authentik's own
|
||||
forced everyone to log in again and cleared the error:
|
||||
|
||||
```sql
|
||||
TRUNCATE authentik_core_session;
|
||||
TRUNCATE authentik_core_session; -- named authentik_core_authenticatedsession in some versions
|
||||
```
|
||||
|
||||
One side effect worth knowing: this also invalidates every OIDC refresh token
|
||||
|
||||
@@ -71,7 +71,7 @@ Django sets a `csrftoken` cookie on the very first GET. That cookie's value is
|
||||
the token you echo back in a header on every write:
|
||||
|
||||
```bash
|
||||
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
|
||||
curl -sk -c /tmp/cp_cookies.txt "https://10.0.10.24:8090/" -o /dev/null
|
||||
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
|
||||
```
|
||||
|
||||
@@ -82,10 +82,10 @@ header:
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/verifyLogin" \
|
||||
-X POST "https://10.0.10.24:8090/verifyLogin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
|
||||
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
|
||||
```
|
||||
@@ -100,10 +100,10 @@ The "API" is just the same POST endpoints the Angular UI calls.
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
|
||||
-X POST "https://10.0.10.24:8090/websites/fetchWebsitesList" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"page":1,"recordsToShow":50}'
|
||||
```
|
||||
|
||||
@@ -112,10 +112,10 @@ and per-site days-until-cert-expiry. The same pattern deletes a site:
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
|
||||
-X POST "https://10.0.10.24:8090/websites/submitWebsiteDeletion" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"websiteName":"blog.hoelee.com"}'
|
||||
```
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Each restart refilled the heap in 12 to 40 minutes. Nothing in the
|
||||
configuration was wrong, and neither were the 403s and 429s — they are the
|
||||
filter rejecting crawlers exactly as designed. The problem is who is
|
||||
knocking: this container is the public edge for a multi-terabyte Wikipedia
|
||||
corpus served from a home NAS, behind a country allowlist and a bot filter.
|
||||
corpus served from an on-premise NAS appliance, behind a country allowlist and a bot filter.
|
||||
That is an extremely attractive target for a residential-proxy crawler
|
||||
cloud, and the responses telling it to go away are still work.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "Every Player Stuttered on the Same Video. The File Was Blameless."
|
||||
description: "Same HEVC file, same stutter in VLC, Chrome and an iPad — offline. The container timing was perfect. The pictures were not: NVDEC's legacy AV1 wrapper was returning frames from the wrong timestamps."
|
||||
pubDate: 2026-10-08
|
||||
category: devops
|
||||
dynamicCta: true
|
||||
tags: [ffmpeg, nvdec, hevc, transcoding, video]
|
||||
ogImage: /og/every-player-stuttered-the-file-was-fine.jpg
|
||||
banner: /banners/every-player-stuttered-the-file-was-fine.jpg
|
||||
@@ -106,7 +107,7 @@ different moment in the video — and the converted file has the close-up.
|
||||
|
||||
## Step 3: the fix is one line
|
||||
|
||||
The bug is not the hardware. It is the legacy decoder wrapper. `av1_cuvid`
|
||||
The bug was not the hardware (tested on an NVIDIA RTX 3060 12GB running FFmpeg 6.1/7.0). It was in the legacy decoder wrapper. `av1_cuvid`
|
||||
is an old-style wrapped decoder; the modern hardware path goes through
|
||||
`hwaccel`:
|
||||
|
||||
@@ -171,19 +172,21 @@ landed with min SSIM 0.997–0.999.
|
||||
|
||||
## What I would do differently
|
||||
|
||||
1. **"It decodes without errors" is not a quality check.** Decoding a frame
|
||||
successfully says nothing about whether it is the *right* frame. The picture
|
||||
comparison is the check that found it. 2. **Compare against a second
|
||||
decoder, not against your expectations.** A software decode is free and it
|
||||
is the reference. If your hardware path disagrees with it, you have found
|
||||
something worth understanding. 3. **Verify the output against the source,
|
||||
not the source's metadata.** 4. **Do not assume the modern and legacy
|
||||
hardware paths are the same code.** `av1_cuvid` and `-hwaccel cuda -c:v av1`
|
||||
reach the same silicon through different plumbing. A bug in one is not a
|
||||
verdict on the hardware. 5. **A gate that fails safe is worth more than a
|
||||
gate that never fails.** My first gate rejected that 4K60 encode. The file
|
||||
was fine and the gate was wrong — but the failure cost one wasted encode,
|
||||
not a bad file in the library.
|
||||
1. **"It decodes without errors" is not a quality check.** Decoding a frame successfully says nothing about whether it is the *right* frame. The picture comparison is the check that found it.
|
||||
2. **Compare against a second decoder, not against your expectations.** A software decode is free and it is the reference. If your hardware path disagrees with it, you have found something worth understanding.
|
||||
3. **Verify the output against the source, not the source's metadata.**
|
||||
4. **Do not assume the modern and legacy hardware paths are the same code.** `av1_cuvid` and `-hwaccel cuda -c:v av1` reach the same silicon through different plumbing. A bug in one is not a verdict on the hardware.
|
||||
5. **A gate that fails safe is worth more than a gate that never fails.** My first gate rejected that 4K60 encode. The file was fine and the gate was wrong — but the failure cost one wasted encode, not a bad file in the library.
|
||||
|
||||
The stutter is gone. Not because the encoder got better, but because the
|
||||
decoder stopped lying.
|
||||
|
||||
---
|
||||
|
||||
## Need a Media Pipeline That Verifies What It Ships?
|
||||
|
||||
If you run automated video transcoding pipelines, self-hosted media infrastructure, or have a production bug where every superficial check passes while the output fails, I build and audit exactly these systems:
|
||||
|
||||
- **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Media%20pipeline%20%26%20infrastructure%20consulting)
|
||||
- **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -49,7 +49,7 @@ Tor is an anonymity network: your traffic hops through three relays, and no sing
|
||||
|
||||
Four properties come out of that, and a normal website simply can't offer them:
|
||||
|
||||
- **No public IP, no ports, no DNS.** Nothing for scanners to find. You can host it from a home connection behind CGNAT.
|
||||
- **No public IP, no ports, no DNS.** Nothing for scanners to find. You can host it from a private network behind CGNAT.
|
||||
- **No middlemen.** No CDN, registrar, or platform in the path recording who visits.
|
||||
- **The address itself is the access control.** People who have it can reach the site; everyone else can't even tell it exists. Add client keys on top when that isn't strict enough.
|
||||
- **It keeps working when the clearnet doesn't.** If a domain gets blocked or seized, the onion is untouched, because no registry is involved.
|
||||
@@ -70,7 +70,7 @@ Three things, and only the last one requires any work:
|
||||
|
||||
These are the parts where re-checking my own server paid for itself.
|
||||
|
||||
**The app container had full internet access.** I ran a one-liner inside the container against a public IP echo service, and my home IP came back. There had been an iptables-based block for this, but the rules were gone. Host firewalls get flushed silently by interface changes, container manager restarts, platform updates. A block that nothing re-applies and nothing alarms on is not a block, it's a superstition. This convinced me to stop filtering the app's egress and remove it entirely instead (below).
|
||||
**The app container had full internet access.** I ran a one-liner inside the container against a public IP echo service, and my host's public WAN IP came back. There had been an iptables-based block for this, but the rules were gone. Host firewalls get flushed silently by interface changes, container manager restarts, platform updates. A block that nothing re-applies and nothing alarms on is not a block, it's a superstition. This convinced me to stop filtering the app's egress and remove it entirely instead (below).
|
||||
|
||||
**The host firewall was off.** INPUT and FORWARD policy ACCEPT, and a few dozen ports listening on all interfaces from the other services on the same machine. If any one of those gets owned, the onion keys on that disk belong to the attacker. People spend hours on Tor-specific hardening and skip this.
|
||||
|
||||
@@ -133,7 +133,7 @@ healthcheck:
|
||||
test: ["CMD", "pgrep", "-x", "tor"]
|
||||
```
|
||||
|
||||
The nginx trap was subtler. My first version used `wget --spider` against the root path. The site returned 404 (I hadn't uploaded content yet), wget exited non-zero, and the container got marked unhealthy. The check was testing the content, not the service. `nc -z 127.0.0.1 80` tests the port and nothing else.
|
||||
The nginx trap was subtler. My first version used `wget --spider` against the root path. The site returned 404 (I hadn't uploaded content yet), wget exited non-zero, and the container got marked unhealthy. The check was testing the content, not the service. `nc -z 127.0.0.1 8080` tests the listening port and nothing else.
|
||||
|
||||
One more thing: tor resolves its `HiddenServicePort` target at startup. If the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched four failed starts in the old logs, unnoticed, because nothing was watching. `depends_on` in the compose file fixed the ordering.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
## The batch that looked fine
|
||||
@@ -72,12 +73,12 @@ 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 sequence was: upload `archive-04.mp4.new.mp4`, verify the hash, `os.replace()`
|
||||
it over `archive-04.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
|
||||
The file on the share was **the untouched original**: H.264, 1,137,704,960
|
||||
bytes (1085 MiB). 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.
|
||||
|
||||
@@ -148,16 +149,12 @@ 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.
|
||||
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:
|
||||
|
||||
@@ -167,3 +164,11 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Running a pipeline whose only proof of success is its own log?
|
||||
|
||||
I design and harden unattended media, backup, and data-ingestion pipelines across NAS storage, network shares, and hybrid infrastructure — replacing blind API trust with end-to-end cryptographic verification. If you suspect silent failures in your batch workloads, I can audit the pipeline and build verification gates that fail loud and recover cleanly.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]?subject=Pipeline%20verification%20and%20storage%20reliability) or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at [hoelee.com](https://hoelee.com).
|
||||
@@ -6,6 +6,8 @@ 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
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
I run n8n as the automation backbone for my self-hosted stack — it handles
|
||||
@@ -56,7 +58,7 @@ actions to update your configuration:
|
||||
5000 to 1000 in a future version.
|
||||
```
|
||||
|
||||
That block is your migration checklist. Six of my seven gotchas are in it.
|
||||
That block is the core of your migration checklist. Five of my seven configuration and deprecation gotchas are right inside it, and the other two surfaced a few lines away in the same startup log.
|
||||
|
||||
## 1. Your env values can now fail *schema validation*
|
||||
|
||||
@@ -136,6 +138,8 @@ N8N_RUNNERS_MODE=external
|
||||
N8N_RUNNERS_AUTH_TOKEN=<a long random string>
|
||||
```
|
||||
|
||||
(Note: switching to `external` mode means pairing the main container with the `n8nio/runners` sidecar image in your compose stack sharing that same token, which actually ships both the JS and Python runtimes.)
|
||||
|
||||
**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.
|
||||
@@ -200,7 +204,7 @@ 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
|
||||
## The one outside the deprecation block: 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:
|
||||
|
||||
@@ -33,17 +33,17 @@ perfectly healthy-looking configuration serve 404s for half an hour.
|
||||
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
|
||||
edge gateway, so there is no cloud WAF, no managed 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.
|
||||
from a global CDN at thousands of requests per second and the app is a stateful
|
||||
database client. For an independent property or SME site where ingest traffic easily
|
||||
fits on a single node, copying that architecture solves a problem you do not have —
|
||||
and pays 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.
|
||||
@@ -239,9 +239,13 @@ looks like success and is useless. Recording the right city is the proof.
|
||||
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
|
||||
- **The dashboard is as available as the outpost.** Because I chose to keep the
|
||||
path allowlist in one place inside authentik (rather than duplicating regex location
|
||||
blocks across my edge nginx and tunnel ingress configs), 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.
|
||||
survivable: pages still load, the beacon just does not land. If you need ingest to
|
||||
survive an SSO outage, you can bypass the outpost for those three paths in nginx at
|
||||
the cost of splitting policy across two layers.
|
||||
- **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
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
I run Passbolt — an open-source password manager for teams — on a Synology
|
||||
@@ -92,7 +94,7 @@ return [
|
||||
],
|
||||
'passbolt' => [
|
||||
'gpg' => [
|
||||
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
|
||||
'serverFingerprint' => env('PASSBOLT_GPG_SERVER_KEY_FINGERPRINT', 'A3DD9B762D48722C10CF88DDB5372E46A54E1419'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -187,6 +189,6 @@ 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
|
||||
Reach me at [[email protected]](mailto:[email protected]?subject=Passbolt%20and%20Docker%20troubleshooting) or WhatsApp
|
||||
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
|
||||
[hoelee.com](https://hoelee.com).
|
||||
@@ -7,6 +7,7 @@ tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, sel
|
||||
ogImage: /og/patching-workbench-26-for-mariadb.jpg
|
||||
banner: /banners/patching-workbench-26-for-mariadb.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -247,7 +248,7 @@ Two independent faults, two different error signatures. Worth separating in your
|
||||
|
||||
## 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.
|
||||
The Workbench files under `Program Files` are shipped **read-only**, and patching them in place bit me twice in ways worth documenting: writing before clearing the read-only attribute fails outright with an access error, while chaining `.Replace()` into PowerShell's `WriteAllText` after clearing it fails *silently* — the write reports success while the file content on disk is unchanged, because CRLF line-ending differences caused `.Replace()` to miss the target block and write the original string back verbatim.
|
||||
|
||||
What actually works:
|
||||
|
||||
@@ -297,3 +298,11 @@ One thing I'd flag rather than dress up: these are patches to a closed applicati
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Stuck on a database migration or an error message that makes no sense?
|
||||
|
||||
I manage and troubleshoot mixed MySQL, MariaDB, and PostgreSQL environments alongside the self-hosted stacks that depend on them — from replication and engine-compatibility traps to root-cause debugging when the tooling itself is lying. If you have a database upgrade or production fault blocking your team, I can trace it to the source and leave it documented.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]?subject=Database%20troubleshooting%20and%20migration) or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at [hoelee.com](https://hoelee.com).
|
||||
@@ -9,6 +9,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
|
||||
ogImage: /og/php2shell-patching-every-php-runtime-i-manage.jpg
|
||||
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
updatedDate: 2026-10-23
|
||||
---
|
||||
|
||||
@@ -64,7 +65,7 @@ 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 (203.0.113.10) | 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 |
|
||||
@@ -117,9 +118,9 @@ done | sort -u
|
||||
```
|
||||
|
||||
```text
|
||||
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
client_wood -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
numer9393 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
app_portal -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
...
|
||||
```
|
||||
|
||||
@@ -212,12 +213,12 @@ 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)
|
||||
client-wood.com 200 266192 (identical)
|
||||
client-property.com.my 200 222780 (identical)
|
||||
client-trading.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)
|
||||
client-mail.com 200 44861 (identical)
|
||||
app.client-portal.com 200 142762 (moved to 8.5.12 later — see below)
|
||||
```
|
||||
|
||||
I also grep every response for `Fatal error` / `Uncaught` / `Deprecated:`,
|
||||
@@ -243,7 +244,7 @@ single run:
|
||||
|
||||
```text
|
||||
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
|
||||
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
|
||||
/usr/local/lsws/conf/vhosts/client-trading.com.my/vhost.conf:80
|
||||
```
|
||||
|
||||
I had read that line as noise for weeks. It was the whole diagnosis. After
|
||||
@@ -269,11 +270,11 @@ 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`:
|
||||
The awkward part was the execution path. Interactive SSH was locked down
|
||||
on that NAS, and clicking through three manual `.spk` uploads in the DSM web UI
|
||||
mid-incident was slow and unscriptable — whereas my automation workstation already
|
||||
had a live channel to the NAS's Docker API. So I drove the host package manager
|
||||
through a short-lived privileged container with the host root bind-mounted, called via `chroot`:
|
||||
|
||||
```python
|
||||
HostConfig = {
|
||||
@@ -343,7 +344,7 @@ 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
|
||||
I left `app.client-portal.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
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
The first thing a visitor to my reading service saw was a Chinese login box.
|
||||
@@ -98,6 +99,8 @@ location / { # everything else: the app, untouched
|
||||
|
||||
**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.
|
||||
|
||||
**Keep `/` and `/index.html` distinct.** Using `try_files /index.html =404` inside `location = /` instead of nginx's default `index` directive is deliberate: `index` triggers an internal redirect to `/index.html`, which would land in the proxied `location = /index.html` block below and skip the landing page entirely. `try_files` serves the local file in place, while the landing page's Enter button links explicitly to `/index.html`.
|
||||
|
||||
## The page itself
|
||||
|
||||
It is a single self-contained HTML file: no web fonts, no CDN, no build
|
||||
@@ -131,6 +134,9 @@ location = /index.html {
|
||||
sub_filter '</head>' '<script>(function(){try{if(!navigator.onLine)return;
|
||||
if(sessionStorage.getItem("gatePass")==="1"){sessionStorage.removeItem("gatePass");return;}
|
||||
location.replace("/"+(location.hash||""));}catch(e){}})();</script></head>';
|
||||
|
||||
# 3. inject the client-side English UI translation layer before closing body
|
||||
sub_filter '</body>' '<script src="/en-mode.js" defer></script></body>';
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
|
||||
ogImage: /og/read-only-nocodb-dashboard-for-a-remote-database.jpg
|
||||
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
My report app writes every lead and every order into MariaDB running on a small VM. The
|
||||
My report app writes every lead and every order into MariaDB running on a dedicated 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.
|
||||
|
||||
@@ -175,8 +176,11 @@ 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.
|
||||
`DROP command denied` — from the database, not from NocoDB. While NocoDB's UI has a
|
||||
"Schema Read-Only" toggle for data sources, enforcing this at the MySQL layer (`GRANT SELECT` only)
|
||||
provides true defense-in-depth: even if a UI misconfiguration allows an edit, the database engine
|
||||
itself rejects the write. That is the read-only grant doing its job, and it is a better
|
||||
verification than any UI label.
|
||||
|
||||
## Four smaller gotchas worth stealing
|
||||
|
||||
@@ -232,7 +236,7 @@ physically cannot write to it, and you verify both halves** — that the connect
|
||||
|
||||
*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.*
|
||||
Cloudflare, on an on-premise host or a dedicated 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)
|
||||
|
||||
@@ -9,9 +9,10 @@ tags: [windows, security, rdp, ipban, rdpguard, brute-force, firewall, self-host
|
||||
ogImage: /og/replacing-rdpguard-with-ipban.jpg
|
||||
banner: /banners/replacing-rdpguard-with-ipban.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
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.
|
||||
Anyone who has put a Windows machine on the public internet with Remote Desktop enabled knows the drill — whether as an unavoidable legacy requirement, or during a transition before moving everything behind a VPN. 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.
|
||||
|
||||
@@ -73,7 +74,7 @@ IPBan picked it up within seconds, deleted the file itself, and rewrote its own
|
||||
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.
|
||||
The ban records also turned out to be worth reading rather than deleting. Two of the twelve had tried the usernames `HOELEE01` and `HOELEE1` — derived directly from my public domain handle (even though no local account on the box uses that name). One had cycled `Administrator` → `Administrador` → `Admin`. That's reconnaissance scraped from public metadata aimed at a specific target, 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
|
||||
|
||||
@@ -102,9 +103,9 @@ No `--install-service`. No `--service`. Nothing service-related at all. IPBan is
|
||||
|
||||
The answer is `sc.exe`, which the project's own uninstall script hints at (`sc.exe stop IPBAN` / `sc.exe delete IPBAN`):
|
||||
|
||||
```bash
|
||||
```cmd
|
||||
sc.exe create IPBAN type= own start= auto ^
|
||||
binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan"
|
||||
binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan Service"
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -136,7 +137,7 @@ 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`.
|
||||
One cosmetic wart I hit on the first run: I initially omitted `DisplayName=` from `sc.exe create`, which left the service's display name as the bare `IPBAN` in `services.msc` rather than a friendly string. I've included `DisplayName= "IPBan Service"` in the command above so yours looks finished from the start.
|
||||
|
||||
## Trap 3: `ExpireTime` is not the ban duration
|
||||
|
||||
@@ -203,7 +204,7 @@ Note that in a container or NAT'd network you can't always self-test — with yo
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
# Watch the log live
|
||||
Get-Content C:\app\IPBan\logfile.txt -Wait -Tail 20
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ something a furniture shop puts on their own website, where:
|
||||
- **Output needs a watermark.** Otherwise you're running a free AI photo service
|
||||
for the entire internet.
|
||||
- **The owner needs to capture the lead.** An AI tool that produces a pretty
|
||||
picture and no contact details is a toy. Attached to a form + webhook, it's a
|
||||
picture and no contact details is just a free demo. Attached to a form + webhook, it's a
|
||||
lead pipeline for a furniture business.
|
||||
|
||||
Every one of those five points is a *product* requirement, and every one of them
|
||||
@@ -172,10 +172,11 @@ what gates access.
|
||||
|
||||
### 2. `rest_url()` has no trailing slash
|
||||
|
||||
The one that cost me the most recent afternoon. `rest_url()` returns
|
||||
`https://example.com/wp-json/my-plugin/v1/` — the trailing slash is on the
|
||||
*namespace*, and the next path segment must start with its own slash. Concatenating
|
||||
without one:
|
||||
The one that cost me the most recent afternoon. Passing a namespace to
|
||||
`rest_url( 'my-plugin/v1' )` (or normalizing it for script localization)
|
||||
yields `https://example.com/wp-json/my-plugin/v1` — there is no trailing
|
||||
slash after the namespace, so the next path segment must start with its own slash.
|
||||
Concatenating without one:
|
||||
|
||||
```js
|
||||
const rest = window.hreAdmin.rest; // ".../wp-json/hoelee-ai-photo-remix/v1"
|
||||
|
||||
@@ -84,14 +84,14 @@ Here's the subtle part, and it's the kind of thing that looks fine until it
|
||||
isn't.
|
||||
|
||||
The distribution repo holds a **copy** of the skills tree — it is *not* the
|
||||
live directory. The live skills live in `~/AppData/Local/hermes/skills/` (where
|
||||
live directory. The live skills live in `~/.hermes/skills/` (where
|
||||
the agent actually writes); the repo copy is a snapshot I push. They are two
|
||||
separate trees that drift.
|
||||
|
||||
So a sync actually has **three parties**, not two:
|
||||
|
||||
```
|
||||
live skills dir ←→ repo copy (D:\dev\hoelee-agent-dist\skills) ←→ Gitea remote
|
||||
live skills dir ←→ repo copy (~/workspace/agent-dist/skills) ←→ Gitea remote
|
||||
```
|
||||
|
||||
A skill I create after the last push exists *only* in the live dir. If I'd
|
||||
@@ -100,7 +100,7 @@ silently deleted that skill. The first thing I do before any sync is diff the
|
||||
two trees:
|
||||
|
||||
```bash
|
||||
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
|
||||
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
|
||||
```
|
||||
|
||||
Divergence is *normal*, not an error. The whole discipline is knowing which of
|
||||
@@ -123,10 +123,10 @@ The flow I documented (and now run) is:
|
||||
|
||||
```bash
|
||||
# 1. Pull remote into the repo copy, fast-forward only — never force
|
||||
cd D:\dev\hoelee-agent-dist && git pull --ff-only origin main
|
||||
cd ~/workspace/agent-dist && git pull --ff-only origin main
|
||||
|
||||
# 2. Reconcile live dir ↔ repo copy
|
||||
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
|
||||
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
|
||||
|
||||
# 3. Stage only real skill content (never the curator junk)
|
||||
# 4. Commit + push, then verify the remote actually has it
|
||||
@@ -175,7 +175,7 @@ lesson as a `.gitignore` for `node_modules`, but for your own memory.
|
||||
|
||||
## The result
|
||||
|
||||
I now have one git repo — `git.hoelee.com/hoelee/hoelee-agent-dist` — holding
|
||||
I now have one git repo — `git.hoelee.com/ops/agent-dist` — holding
|
||||
172 skills, my config, SOUL, and cron jobs. The other machine installs it into
|
||||
a named profile, merges its own skills in, and we stay in sync with a manual
|
||||
trigger that asks me on every conflict. The first real sync pushed a
|
||||
|
||||
@@ -109,7 +109,7 @@ To achieve high throughput without write collisions, we decoupled the developmen
|
||||
│ ASSET & RESEARCH BOT │ │ ADVERSARIAL REVIEWER │
|
||||
│ │ │ │
|
||||
│ • Web documentation extract │ │ • Isolated high-tier model │
|
||||
│ • Local ComfyUI / PIL assets │ │ (e.g., Gemini 4 Argon) │
|
||||
│ • Local ComfyUI / PIL assets │ │ (e.g., Claude 3.5 Sonnet) │
|
||||
│ • SenseVoice / TTS pipelines │ │ • Zero prior context baggage │
|
||||
│ • Strictly isolated context │ │ • Reviews unified git diff │
|
||||
└──────────────────────────────┘ └───────────────────────────────┘
|
||||
@@ -138,7 +138,7 @@ When an agent spends twenty turns debugging an intricate function, it develops c
|
||||
|
||||
Once the Single Writer verifies that its local unit tests pass, it exports the `git diff` and dispatches it to an **Adversarial Reviewer**.
|
||||
|
||||
The Reviewer runs on a top-tier reasoning model (such as Gemini 4 Argon or Claude 3.5 Sonnet) with **zero conversational context**. It is given only:
|
||||
The Reviewer runs on a top-tier reasoning model (such as Claude 3.5 Sonnet or flagship reasoning models) with **zero conversational context**. It is given only:
|
||||
1. The original task objective.
|
||||
2. The unified git diff.
|
||||
3. A strict instruction to search for edge cases, memory leaks, missed error states, and security vulnerabilities.
|
||||
@@ -155,7 +155,7 @@ Before modifying a file, the agent queries the code graph for symbol references,
|
||||
|
||||
## Quantified Results: Production Comparison
|
||||
|
||||
We evaluated this architecture against both traditional single-agent development and unbounded multi-agent swarms on identical full-stack features:
|
||||
We evaluated this architecture against traditional single-agent development and unbounded multi-agent swarms across 5 full-stack tasks in a ~50,000-line TypeScript/Python codebase (average per task):
|
||||
|
||||
| Metric | Unbounded Multi-Agent Swarm | Traditional Single Agent | Single-Writer + Adversarial Review |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -43,7 +43,7 @@ If an endpoint exists, never touch a UI. Interacting with MySQL, Docker, Portain
|
||||
If there is no remote API, look for a command-line utility. Editing code, running migrations, parsing logs, or transforming media should happen through `terminal`, `git`, `ffmpeg`, or headless scripts. The agent never moves your mouse, and executions finish in milliseconds.
|
||||
|
||||
### Tier 3: Background Accessibility & Window Messages (CUA)
|
||||
When dealing with GUI applications, the first choice is **background automation** (such as Hermes's `computer-use` powered by `cua-driver`).
|
||||
When dealing with GUI applications, the first choice is **background automation** (such as our production agent runtime Hermes's `computer-use` powered by `cua-driver`).
|
||||
- Messages are delivered via native accessibility APIs (Windows UIA / macOS AX).
|
||||
- **Your mouse cursor never moves.**
|
||||
- The application does not need to be focused or brought to the front. You can type an email while the agent clicks a button in an unfocused background window.
|
||||
|
||||
@@ -31,7 +31,7 @@ To turn passive consumption into actionable learning, I set a clear requirement:
|
||||
|
||||
## 2. Architecture & Design Principles
|
||||
|
||||
When designing the system, I established four non-negotiable architectural principles based on my existing home infrastructure:
|
||||
When designing the system, I established four non-negotiable architectural principles based on the self-hosted infrastructure I manage:
|
||||
|
||||
```
|
||||
[ Local Input / Inbox ]
|
||||
@@ -44,7 +44,7 @@ When designing the system, I established four non-negotiable architectural princ
|
||||
▼
|
||||
[ Decoupled Data Layer ]
|
||||
├── src/content/videos/{slug}.md (Frontmatter + Structured Insights)
|
||||
└── src/data/transcripts/{slug}.json (Millisecond Cues [start, end, text])
|
||||
└── src/data/transcripts/{slug}.json (Sub-Second Timestamp Cues [start, end, text])
|
||||
│
|
||||
▼
|
||||
[ Astro 5 Static Portal ] ──► Pagefind Client-Side WASM Search (<20ms latency)
|
||||
@@ -56,13 +56,13 @@ When designing the system, I established four non-negotiable architectural princ
|
||||
### Decouple Summaries from Transcripts
|
||||
The biggest architectural trap in early prototypes was concatenating timestamped transcripts directly inside the Markdown content body. This caused Markdown files to balloon to thousands of lines, cluttered formatting, and made re-running LLM summaries destructive.
|
||||
|
||||
The fix: keep `src/content/videos/{slug}.md` strictly for high-level synthesis and Frontmatter, while storing millisecond cues in `src/data/transcripts/{slug}.json`.
|
||||
The fix: keep `src/content/videos/{slug}.md` strictly for high-level synthesis and Frontmatter, while storing sub-second timestamp cues in `src/data/transcripts/{slug}.json`.
|
||||
|
||||
### Zero Cloud STT Fees
|
||||
Rather than paying recurring per-minute charges to cloud transcription APIs, the pipeline runs locally on an NVIDIA RTX 3060 (12GB VRAM). Using `faster-whisper` and `Silero-VAD`, speech is automatically transcribed into clean 3-to-8 second semantic sentences with sub-second timestamps.
|
||||
|
||||
### Never Stream from Residential NAS
|
||||
Serving video streaming directly from a residential home NAS to public web visitors saturates home broadband upload bandwidth and risks ISP restrictions. Public playback uses a dual-track strategy: official iframe embeds (Bilibili/YouTube) whenever possible, or lightweight NVENC 720p H.264 streams hosted on Cloudflare R2.
|
||||
### Never Stream Media Directly from Origin Storage Nodes
|
||||
Serving video streaming directly from an on-premise storage node to public web visitors saturates origin upload bandwidth and risks ISP restrictions. Public playback uses a dual-track strategy: official iframe embeds (Bilibili/YouTube) whenever possible, or lightweight NVENC 720p H.264 streams hosted on Cloudflare R2.
|
||||
|
||||
### Sub-20ms Search with Pagefind
|
||||
Instead of maintaining an always-on search database like Meilisearch or Elasticsearch, the front portal compiles to a pure static Astro site indexed by Pagefind. Search queries run in-browser via WebAssembly, delivering instantaneous Chinese keyword lookups with zero server memory footprint.
|
||||
|
||||
@@ -14,7 +14,7 @@ and the contrast taught me more about scraping than a year of tutorials did.
|
||||
|
||||
On **Carousell**, a headless browser pulled freshly-listed products into a database
|
||||
with prices, conditions, and photo URLs — no problem, running for months. On
|
||||
**Goofish (闲鱼)**, the exact same setup returned the same three words every time:
|
||||
**Goofish (闲鱼)**, the exact same setup returned the exact same block message every time:
|
||||
|
||||
```
|
||||
为了保障您的体验,请使用正常浏览器访问闲鱼~
|
||||
@@ -139,8 +139,7 @@ the target is server-rendered or API-gated before choosing a tool.** On Carousel
|
||||
(server-rendered), a plain HTTP parser has archived thousands of listings with
|
||||
prices and photos for months. On Goofish (signed API), I stopped after an honest
|
||||
look at the effort and the maintenance burden — which is itself the right
|
||||
outcome for a project where a manual search once in a while does everything I
|
||||
needed.
|
||||
outcome for a project where low-frequency spot-checks fulfill all business requirements.
|
||||
|
||||
A headless browser is the right tool *sometimes* — for screenshots, PDFs, taking
|
||||
over a human session, or targets that render server-side. It is not a universal
|
||||
|
||||
@@ -120,7 +120,7 @@ for (const sheet of document.styleSheets) {
|
||||
|
||||
`>` 字符——在我的 CSS 里写对了、在数据库里存对了、API 也返回对了
|
||||
——被渲染进 HTML 时变成了字面文本 `u003e`。不是 `>` 字符,而是
|
||||
`u`、`0`、`0`、`3`、`e` 这六个字符。
|
||||
`u`、`0`、`0`、`3`、`e` 这五个字符。
|
||||
|
||||
于是浏览器试着解析这个选择器:
|
||||
|
||||
@@ -154,11 +154,9 @@ ul.pf-c-list li[data-kind="text"] {
|
||||
|
||||
### 这个转义是从哪来的
|
||||
|
||||
`\u003E` 是 JSON/JavaScript 对 `>` 的编码方式。authentik 的 Django
|
||||
模板把品牌配置渲染成一个 JavaScript 对象字面量,而在这个过程里,
|
||||
某个 HTML/JS 转义器被作用到了 `branding_custom_css` 字符串上。反斜杠
|
||||
和 `u` 在这个路径的某处被拆开了,于是浏览器收到的是 `u003e`
|
||||
——没有反斜杠的转义序列——而不是 `>`。
|
||||
`\u003E` 是 JSON/JavaScript 对 `>` 的编码方式。authentik 的 Django 模板把品牌配置渲染成一个 JavaScript 对象字面量,而在这个过程里,某个 HTML/JS 转义器被作用到了 `branding_custom_css` 字符串上。
|
||||
|
||||
而在 CSS 语法规范中,十六进制转义直接以反斜杠加数字开头(`\003e`),并不像 JS 那样使用 `\u`。由于 `u` 不是合法的十六进制字符(0-9a-f),浏览器的 CSS 词法分析器会把 `\u` 当作对字面字母 `u` 的转义,从而把 `<style>` 里的 `\u003E` 解析成了普通标识符 `u003e`。
|
||||
|
||||
存进去的值是对的。API 的返回也是对的。只有渲染出来的页面是错的:
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ x-powered-by: Express
|
||||
|
||||
## 背景:给一个没有 SSO 的应用加 SSO
|
||||
|
||||
这个应用是我 NAS 上的一个自托管数据库界面(Docker,发布在宿主端口上,位于 nginx 和 Cloudflare Tunnel 后面——我家实验室的常见形态)。我想在它前面加一道登录墙,让「能从公网访问得到」不再意味着「任何人猜中主机名就能看到登录表单、开始猜密码」。
|
||||
这个应用是我 NAS 上的一个自托管数据库界面(Docker,发布在宿主端口上,位于 nginx 和 Cloudflare Tunnel 后面——我管理的私有基础设施的常见形态)。我想在它前面加一道登录墙,让「能从公网访问得到」不再意味着「任何人猜中主机名就能看到登录表单、开始猜密码」。
|
||||
|
||||
应用自己的文档宣称支持 SSO。我会把这篇的重点放在入口陷阱上,许可的故事放到另一篇里讲,但简要版本是:宣传的 SSO 是付费许可功能,开源构建带有代码路径但不带使用许可,而过去填补这个空白的社区 fork 已经两年没人维护了。二十分钟调研、两个被放弃的方案、一个结论:**这个应用永远不会为我做 SSO,所以网关必须放在它前面。**
|
||||
|
||||
@@ -91,7 +91,7 @@ curl -sk -o /dev/null -w '%{http_code} -> %{redirect_url}' \
|
||||
|
||||
| 路径 | 谁在服务它 | 落在哪里 |
|
||||
|---|---|---|
|
||||
| 家里 IP → nginx (443) | 我改过的那个 vhost | outpost ✅ |
|
||||
| 直连公网 IP → nginx (443) | 我改过的那个 vhost | outpost ✅ |
|
||||
| Cloudflare Tunnel (`cloudflared`) | 隧道自身配置里的一条规则 | 直连应用的端口 ❌ |
|
||||
|
||||
隧道的 ingress 是一个按主机名求值的小型有序列表:
|
||||
@@ -116,7 +116,7 @@ curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$T
|
||||
```
|
||||
|
||||
```
|
||||
git.example.com -> http://192.168.1.6:6880
|
||||
git.example.com -> http://10.0.0.10:6880
|
||||
app.example.com -> http://app-container:8080
|
||||
bot.example.com -> http://bot-container:8080
|
||||
* -> http_status:404
|
||||
@@ -186,7 +186,7 @@ grep -c 'proxy_pass http://localhost:10000;' vhost.conf
|
||||
# 11
|
||||
```
|
||||
|
||||
11 个。我的应用只是其中之一。**另外还有十个应用都经由那个 outpost 被网关拦着**,一次无差别的替换会同时悄悄拆掉它们所有人的门——包括那些在做真实认证工作的应用。这个「修复」看起来会像是成功的,同时却会拆掉我家实验室大部分认证。
|
||||
11 个。我的应用只是其中之一。**另外还有十个应用都经由那个 outpost 被网关拦着**,一次无差别的替换会同时悄悄拆掉它们所有人的门——包括那些在做真实认证工作的应用。这个「修复」看起来会像是成功的,同时却会拆掉我整套基础设施的大部分认证。
|
||||
|
||||
我实际做的是:
|
||||
|
||||
@@ -238,7 +238,7 @@ curl -s https://app.example.com/ -o /dev/null -w '%{http_code}\n' # what does
|
||||
|
||||
如果你需要在内部工具前面加单点登录、把自托管的应用安全地暴露到公网,或者需要有人审计到底是哪条入口路径在真正服务你的域名——这正是我在做的工作。
|
||||
|
||||
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) —— 点击对话
|
||||
- **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) —— 点击对话
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Reverse%20proxy%20%26%20SSO%20enquiry)
|
||||
- **网站:** [hoelee.com](https://www.hoelee.com)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ authentik 是一个开源的**身份提供商(IdP)**——反复回答同一
|
||||
它强制要求逐级走。所以计划是:
|
||||
|
||||
```
|
||||
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8
|
||||
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.4 → 2026.6 → 2026.8
|
||||
```
|
||||
|
||||
六跳,一次一步,每步之间跑一次迁移 + 一次健康检查。动手之前,有一个绝对
|
||||
@@ -112,7 +112,7 @@ mkdir -p data && mv media data/media
|
||||
错误也随之消失:
|
||||
|
||||
```sql
|
||||
TRUNCATE authentik_core_session;
|
||||
TRUNCATE authentik_core_session; -- 在部分版本中为 authentik_core_authenticatedsession
|
||||
```
|
||||
|
||||
有个副作用值得知道:这也会让你各应用持有的所有 OIDC refresh token 失效。它们
|
||||
@@ -182,7 +182,7 @@ POST /api/v3/flows/bindings/ # { target: "<flow pk>", stage: "<stage pk>", ord
|
||||
|
||||
## 我会怎么做得不一样
|
||||
|
||||
整场折腾归结为三个本可避免的模式:
|
||||
整个排查与修复过程归结为三个本可避免的模式:
|
||||
|
||||
1. **绝不要靠猜字段的语义**——我把 `authorization_flow` 当成了「登录 flow」,
|
||||
而真相源是模型定义,字段自己的 docstring 里就把区别写得明明白白。
|
||||
|
||||
@@ -8,7 +8,7 @@ ogImage: /og/zh-automating-cyberpanel-without-the-ui.jpg
|
||||
banner: /banners/automating-cyberpanel-without-the-ui.jpg
|
||||
---
|
||||
|
||||
我在家庭实验里的一台 Ubuntu 虚拟机里跑着 CyberPanel 2.4.4.1,用来托管几个小站,
|
||||
我在自托管基础设施的一台 Ubuntu 虚拟机里运行着 CyberPanel 2.4.4.1,用来托管多个站点,
|
||||
入口是 `https://panel.hoelee.com`,反向代理到内网的一台机器。最近我想不打开浏览器、
|
||||
直接用脚本做两件事:列出我的站点、删掉其中一个。这篇文章讲的就是我怎么发现"官方
|
||||
API"根本已经不存在了,然后从 Angular 界面偷偷调用的那些请求里把真正的 API 逆向出来。
|
||||
@@ -61,7 +61,7 @@ Django 在第一次 GET 时就会设一个 `csrftoken` cookie。这个 cookie
|
||||
操作时用头部回显回去的 token:
|
||||
|
||||
```bash
|
||||
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
|
||||
curl -sk -c /tmp/cp_cookies.txt "https://10.0.10.24:8090/" -o /dev/null
|
||||
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
|
||||
```
|
||||
|
||||
@@ -71,10 +71,10 @@ CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/verifyLogin" \
|
||||
-X POST "https://10.0.10.24:8090/verifyLogin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
|
||||
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
|
||||
```
|
||||
@@ -89,10 +89,10 @@ curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
|
||||
-X POST "https://10.0.10.24:8090/websites/fetchWebsitesList" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"page":1,"recordsToShow":50}'
|
||||
```
|
||||
|
||||
@@ -101,10 +101,10 @@ curl -sk -b /tmp/cp_cookies.txt \
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
|
||||
-X POST "https://10.0.10.24:8090/websites/submitWebsiteDeletion" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-H "Referer: https://10.0.10.24:8090/" \
|
||||
-d '{"websiteName":"blog.hoelee.com"}'
|
||||
```
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ dmesg 里这样的记录一共 **6 条**,而 `RestartCount` 正是 **6**。同
|
||||
宿主 load average 26–32
|
||||
```
|
||||
|
||||
每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在家庭 NAS 上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。
|
||||
每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在自托管存储节点(NAS)上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。
|
||||
|
||||
## 修法,以及必须跟着的那句话
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "每个播放器都在同一个视频上卡顿,而文件本身是无辜
|
||||
description: "同一个 HEVC 文件,在 VLC、Chrome 和 iPad 上(离线)都出现同样的周期卡顿。容器层时间轴完美无缺,画面却不是——NVDEC 的旧版 AV1 包装器返回了错误时间点的帧。"
|
||||
pubDate: 2026-10-08
|
||||
category: devops
|
||||
dynamicCta: true
|
||||
tags: [ffmpeg, nvdec, hevc, transcoding, video]
|
||||
ogImage: /og/zh-every-player-stuttered-the-file-was-fine.jpg
|
||||
banner: /banners/every-player-stuttered-the-file-was-fine.jpg
|
||||
@@ -81,7 +82,7 @@ ffmpeg -c:v av1_cuvid -i source.mkv -vf "scale=640:360" -f rawvideo -pix_fmt yuv
|
||||
|
||||
## 第三步:修复只要一行
|
||||
|
||||
Bug 不在硬件,在旧的解码器包装器。`av1_cuvid` 属于老式的 wrapper 解码路径;现代的硬件路径要走 `hwaccel`:
|
||||
Bug 不在硬件(实测环境为 NVIDIA RTX 3060 12GB + FFmpeg 6.1/7.0),而在旧的解码器包装器。`av1_cuvid` 属于老式的 wrapper 解码路径;现代的硬件路径要走 `hwaccel`:
|
||||
|
||||
```bash
|
||||
# 修复前
|
||||
@@ -132,3 +133,13 @@ vp9_cuvid vs libvpx-vp9 SSIM 0.99991 (0 帧低于 0.98——不受影
|
||||
5. **一个"失败得安全"的闸门,比一个从不失败的闸门值钱得多。** 我的第一版闸门拒绝了那段 4K60 编码,文件是好的、闸门是错的——但这个错误的代价只是白烧一次编码,而不是让一个坏文件进了库。
|
||||
|
||||
卡顿消失了。不是因为编码器变好了,而是因为解码器不再说谎了。
|
||||
|
||||
---
|
||||
|
||||
## 需要构建高可靠的媒体转码或自动化质检管线?
|
||||
|
||||
如果你正在运营自动化音视频处理流水线、自托管流媒体基础设施,或遇到了常规日志无法解释的底层软硬件排障难题,这正是我日常交付的工作:
|
||||
|
||||
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
|
||||
- **Email**:[[email protected]](mailto:[email protected]?subject=Media%20pipeline%20%26%20infrastructure%20consulting)
|
||||
- **网站**:[hoelee.com](https://hoelee.com)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "加固 Tor 洋葱服务:真正重要的是什么"
|
||||
description: "出于对暗网的好奇,我把个人主页和 Git 服务器镜像了过去。这篇记录哪些加固扛得住、哪些会悄悄坏掉,以及值不值得把它当成服务卖给客户。"
|
||||
title: "加固 Tor 洋葱服务:真正重要的是什么"
|
||||
description: "出于对暗网的好奇,我把个人主页和 Git 服务器镜像了过去。这篇记录哪些加固扛得住、哪些会悄悄坏掉,以及值不值得把它当成服务卖给客户。"
|
||||
pubDate: 2025-12-10
|
||||
updatedDate: 2026-09-09
|
||||
category: devops
|
||||
@@ -11,78 +11,78 @@ ogImage: /og/zh-hardening-a-tor-onion-service.jpg
|
||||
banner: /banners/hardening-a-tor-onion-service.jpg
|
||||
---
|
||||
|
||||
我对暗网产生了好奇。不是集市那一面,而是无聊的那一半:人们像注册商和云控制台出现之前那样自托管——跑自己维护的软件,用一个自己掌控的地址就能访问。
|
||||
我对暗网产生了好奇。不是集市那一面,而是无聊的那一半:人们像注册商和云控制台出现之前那样自托管——跑自己维护的软件,用一个自己掌控的地址就能访问。
|
||||
|
||||
我回应好奇心的方式一向是动手做一个东西,于是实验就这样定了:把自己的网站镜像到 Tor 上。我的交互式个人主页在 [me.hoelee.com](https://me.hoelee.com),代码托管在一个 Gitea 实例 [git.hoelee.com](https://git.hoelee.com) 上。现在两者都有暗网分身:
|
||||
我回应好奇心的方式一向是动手做一个东西,于是实验就这样定了:把自己的网站镜像到 Tor 上。我的交互式个人主页在 [me.hoelee.com](https://me.hoelee.com),代码托管在一个 Gitea 实例 [git.hoelee.com](https://git.hoelee.com) 上。现在两者都有暗网分身:
|
||||
|
||||
- `hoeleegitkcng572znkbpyffppyulsdwv3aurrzlk7y7vlhknogswoqd.onion` — Gitea 镜像
|
||||
- `hoeleeaiwowgndbxswegtdzoeupz7lkkechtqmurbmnpvwa4k3vyuyid.onion` — 个人主页镜像
|
||||
|
||||
### 如果你从没打开过 `.onion`
|
||||
|
||||
这些地址在普通浏览器里打不开,你需要 Tor Browser,它会把你的流量经过 Tor 网络转发。安装大概五分钟:
|
||||
这些地址在普通浏览器里打不开,你需要 Tor Browser,它会把你的流量经过 Tor 网络转发。安装大概五分钟:
|
||||
|
||||
1. 从 **[torproject.org/download](https://www.torproject.org/download/)** 下载。
|
||||
2. **验证签名。** 下载页会给出 `.asc` 文件和签名密钥的链接,这一步很重要——被篡改的 Tor Browser 是丢掉匿名性最糟糕的方式。先取得 Tor Browser 的签名密钥(指纹发布在下载页和 Tor Browser 手册里),再校验文件:
|
||||
2. **验证签名。** 下载页会给出 `.asc` 文件和签名密钥的链接,这一步很重要——被篡改的 Tor Browser 是丢掉匿名性最糟糕的方式。先取得 Tor Browser 的签名密钥(指纹发布在下载页和 Tor Browser 手册里),再校验文件:
|
||||
```bash
|
||||
# 导入签名密钥——见下载页上的 .asc 链接
|
||||
gpg --verify tor-browser-*.tar.xz.asc tor-browser-*.tar.xz
|
||||
```
|
||||
3. 解压并运行。Windows 和 macOS 是常规安装包;Linux 解压后运行 `./start-tor-browser.desktop`。
|
||||
4. **点 Connect。** 默认设置就行。如果你所在网络有审查,选 "Configure connection" 并使用网桥(Snowflake 或 obfs4)。
|
||||
5. 等左上角出现 "Connected" 和链路显示,就说明你已经在 Tor 上了。
|
||||
4. **点 Connect。** 默认设置就行。如果你所在网络有审查,选 "Configure connection" 并使用网桥(Snowflake 或 obfs4)。
|
||||
5. 等左上角出现 "Connected" 和链路显示,就说明你已经在 Tor 上了。
|
||||
|
||||
然后把上面的地址**一字不差**粘进地址栏。有两件事要知道:
|
||||
|
||||
- v3 地址是 **56 个字符**的 base32(`a–z`、`2–7`)加 `.onion`。没有拼写纠错,也没有搜索建议——错一个字符就是一个打不开的地址,而且不会有任何提示。
|
||||
- v3 地址是 **56 个字符**的 base32(`a–z`、`2–7`)加 `.onion`。没有拼写纠错,也没有搜索建议——错一个字符就是一个打不开的地址,而且不会有任何提示。
|
||||
- 16 个字符的地址(v2)完全打不开。v2 协议已在 2021 年退役。
|
||||
|
||||
有一点会让第一次用的人意外:全新的洋葱地址可能需要 **10 到 60 分钟**才会被网络识别。Tor 必须先发布 descriptor,并让它被哈希环接受。第一次打不开通常只是地址还太年轻,不是配置坏了。
|
||||
有一点会让第一次用的人意外:全新的洋葱地址可能需要 **10 到 60 分钟**才会被网络识别。Tor 必须先发布 descriptor,并让它被哈希环接受。第一次打不开通常只是地址还太年轻,不是配置坏了。
|
||||
|
||||
研究怎么把它做好时,我在网上读到了很多讲 obfs4 好处的文章。最终让我的部署真正安全的东西,和那些阅读没什么关系。
|
||||
研究怎么把它做好时,我在网上读到了很多讲 obfs4 好处的文章。最终让我的部署真正安全的东西,和那些阅读没什么关系。
|
||||
|
||||
那么,真正让一个洋葱服务安全的是什么?我设镜像时认真走了一遍全程,几个月后又回头检查了一遍。有一部分配置经受住了考验,有一部分已经悄悄坏掉,还有几件我原本对 Docker 的认知,被实测证明是错的。
|
||||
那么,真正让一个洋葱服务安全的是什么?我设镜像时认真走了一遍全程,几个月后又回头检查了一遍。有一部分配置经受住了考验,有一部分已经悄悄坏掉,还有几件我原本对 Docker 的认知,被实测证明是错的。
|
||||
|
||||
## 三十秒搞懂 Tor 和洋葱服务
|
||||
|
||||
Tor 是一个匿名网络:流量经过三道中继,没有任何一道中继能同时知道你是谁、要去哪。洋葱服务则是反过来的方向——一个生活在网络*内部*的服务器。它拿到的是 `.onion` 地址而不是域名,访客只能通过 Tor 网络访问它,服务器的真实位置永远不会出现在连接里。
|
||||
Tor 是一个匿名网络:流量经过三道中继,没有任何一道中继能同时知道你是谁、要去哪。洋葱服务则是反过来的方向——一个生活在网络*内部*的服务器。它拿到的是 `.onion` 地址而不是域名,访客只能通过 Tor 网络访问它,服务器的真实位置永远不会出现在连接里。
|
||||
|
||||
由此得到四个普通网站给不了的特性:
|
||||
|
||||
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用处于 CGNAT 后的私有网络来跑(现在大多数宽带都是)。
|
||||
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用处于 CGNAT 后的私有网络来跑(现在大多数宽带都是)。
|
||||
- **没有中间商。** 路径上没有任何 CDN、注册商或平台记录谁来过。
|
||||
- **地址本身就是访问控制。** 拿到地址的人可以访问;其他人连这站存不存在都无法判断。要求更高时还能叠加客户端密钥。
|
||||
- **clearnet 挂了它还在。** 域名被墙或被没收,洋葱站毫发无损,因为没有注册局参与其中。
|
||||
- **clearnet 挂了它还在。** 域名被墙或被没收,洋葱站毫发无损,因为没有注册局参与其中。
|
||||
|
||||
洋葱托管适合这些:私密文件交接、作品集和代码仓库镜像、律师之间传草稿、数字商品交付、任何永远不该出现在搜索索引里的档案。会想要它的人,是把"没人能看出它的存在"当作特性而不是怪癖的人:律师、审计师、交付成果的自由职业者、给记者递料的线人、以及不希望自家数字商品渠道被同行扫描的商家。
|
||||
洋葱托管适合这些:私密文件交接、作品集和代码仓库镜像、律师之间传草稿、数字商品交付、任何永远不该出现在搜索索引里的档案。会想要它的人,是把"没人能看出它的存在"当作特性而不是怪癖的人:律师、审计师、交付成果的自由职业者、给记者递料的线人、以及不希望自家数字商品渠道被同行扫描的商家。
|
||||
|
||||
诚实的代价是速度。Tor 链路比 CDN 慢,所以你不会把营销站放上去。它是给互联网的"私密那一半"用的工具,不是给公开那一半用的。
|
||||
诚实的代价是速度。Tor 链路比 CDN 慢,所以你不会把营销站放上去。它是给互联网的"私密那一半"用的工具,不是给公开那一半用的。
|
||||
|
||||
## 真正保护源站的是三件事
|
||||
|
||||
只有最后一件需要花力气:
|
||||
|
||||
1. **协议本身。** 访客从不直连你的服务器。你的 tor 进程主动拨出、向 introduction point 注册,会合发生在 Tor 网络内部。单凭一次访问,访客拿不到你的 IP。
|
||||
2. **Vanguards-lite。** Tor 0.4.7 起内置。它让 guard-discovery 攻击(攻击者反复制造 circuit 直到观察到你的 guard 中继)变得很难实施。你只要跑一个当前版本的 Tor 就白送这层防护。
|
||||
3. **别让源站信息从其它渠道泄漏。** 洋葱主机暴露的现实路径不是流量分析。是你自己的机器在漏:某台 clearnet 服务被攻破,攻击者直接从硬盘读走你的洋葱密钥。公开站点镜像到洋葱是刻意的,内容关联我不担心;真正要管的是,存密钥的那台机器别同时跑着一堆开着的服务。
|
||||
1. **协议本身。** 访客从不直连你的服务器。你的 tor 进程主动拨出、向 introduction point 注册,会合发生在 Tor 网络内部。单凭一次访问,访客拿不到你的 IP。
|
||||
2. **Vanguards-lite。** Tor 0.4.7 起内置。它让 guard-discovery 攻击(攻击者反复制造 circuit 直到观察到你的 guard 中继)变得很难实施。你只要跑一个当前版本的 Tor 就白送这层防护。
|
||||
3. **别让源站信息从其它渠道泄漏。** 洋葱主机暴露的现实路径不是流量分析。是你自己的机器在漏:某台 clearnet 服务被攻破,攻击者直接从硬盘读走你的洋葱密钥。公开站点镜像到洋葱是刻意的,内容关联我不担心;真正要管的是,存密钥的那台机器别同时跑着一堆开着的服务。
|
||||
|
||||
## 那些悄悄坏掉的东西
|
||||
|
||||
重检自己服务器时,是这些地方让我觉得花的时间值了。
|
||||
重检自己服务器时,是这些地方让我觉得花的时间值了。
|
||||
|
||||
**应用容器有完整的互联网访问。** 我在容器里跑了一行访问公网 IP 回显服务的命令,拿回了我的家庭 IP。之前*确实*有过一条基于 iptables 的阻断,但规则已经没了。主机防火墙会被接口变动、容器管理器重启、平台升级静默清空。一条没有任何机制重放、没有任何告警的阻断,不是阻断,是迷信。这件事让我下定决心:与其过滤应用的出网流量,不如干脆把它的出网彻底移除(见下文)。
|
||||
**应用容器有完整的互联网访问。** 我在容器里跑了一行访问公网 IP 回显服务的命令,拿回了宿主机的真实公网 IP。之前*确实*有过一条基于 iptables 的阻断,但规则已经没了。主机防火墙会被接口变动、容器管理器重启、平台升级静默清空。一条没有任何机制重放、没有任何告警的阻断,不是阻断,是迷信。这件事让我下定决心:与其过滤应用的出网流量,不如干脆把它的出网彻底移除(见下文)。
|
||||
|
||||
**主机防火墙是关的。** INPUT 和 FORWARD 策略全是 ACCEPT,同一台机器上其它服务在全部接口上监听了好几十个端口。其中任何一个被打穿,这块盘上的洋葱密钥就归攻击者了。人们愿意花几个小时做 Tor 专属加固,却跳过这一步。
|
||||
**主机防火墙是关的。** INPUT 和 FORWARD 策略全是 ACCEPT,同一台机器上其它服务在全部接口上监听了好几十个端口。其中任何一个被打穿,这块盘上的洋葱密钥就归攻击者了。人们愿意花几个小时做 Tor 专属加固,却跳过这一步。
|
||||
|
||||
**运行中的 tor 和配置文件对不上。** 磁盘上的 torrc 写着 `SocksPort 0`;已经跑了几天进程,还在监听 127.0.0.1:9050。我改了文件,忘了重启容器。这里的实际影响很小,因为监听只在容器内部可达。但教训可以推广:你写的配置文件是愿望,进程实际在做什么才是真相。
|
||||
**运行中的 tor 和配置文件对不上。** 磁盘上的 torrc 写着 `SocksPort 0`;已经跑了几天进程,还在监听 127.0.0.1:9050。我改了文件,忘了重启容器。这里的实际影响很小,因为监听只在容器内部可达。但教训可以推广:你写的配置文件是愿望,进程实际在做什么才是真相。
|
||||
|
||||
**Tor 落后了一个安全版本。** 对一台存放敏感密钥的机器来说太旧了,而且我的日志里带着新版已修复的中继描述符解析 bug 的刷屏签名。升级后刷屏停了。
|
||||
**Tor 落后了一个安全版本。** 对一台存放敏感密钥的机器来说太旧了,而且我的日志里带着新版已修复的中继描述符解析 bug 的刷屏签名。升级后刷屏停了。
|
||||
|
||||
## 修好之后不再坏的修复
|
||||
|
||||
### 零出网,靠构造而不是靠过滤
|
||||
### 零出网,靠构造而不是靠过滤
|
||||
|
||||
两个容器放进 `internal: true` 的 Docker 网络,只有 tor 容器多一张网卡去访问 Tor 网络。
|
||||
两个容器放进 `internal: true` 的 Docker 网络,只有 tor 容器多一张网卡去访问 Tor 网络。
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
@@ -93,17 +93,17 @@ networks:
|
||||
external: true # 普通的带外网 bridge
|
||||
```
|
||||
|
||||
应用现在就哪里都去不了:公网不行,局域网不行,连主机都不行——internal 网络根本没有网关。没有可被清空的 iptables,没有可被遗忘的开机任务,也没有可以静默衰退的余地。就算应用被攻破,攻击者拿到的只是一根指向 tor 的 socket,仅此而已。这是我认为唯一不可妥协的一条改动。
|
||||
应用现在就哪里都去不了:公网不行,局域网不行,连主机都不行——internal 网络根本没有网关。没有可被清空的 iptables,没有可被遗忘的开机任务,也没有可以静默衰退的余地。就算应用被攻破,攻击者拿到的只是一根指向 tor 的 socket,仅此而已。这是我认为唯一不可妥协的一条改动。
|
||||
|
||||
### 非 root,和 capability 的意外
|
||||
### 非 root,和 capability 的意外
|
||||
|
||||
镜像默认就是非 root 用户,而我想让应用继续监听 80 端口。标准建议是 `cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
|
||||
镜像默认就是非 root 用户,而我想让应用继续监听 80 端口。标准建议是 `cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
|
||||
|
||||
```
|
||||
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
|
||||
```
|
||||
|
||||
这个原因让我意外到用探针容器实测了一把:`grep Cap /proc/self/status` 显示 `CapBnd` 里有 bit 10(NET_BIND_SERVICE),而 `CapEff` 是零。Docker 给非 root 容器的 capability 只进 bounding set,不进 effective set,而 bind() 检查的是 effective set。一个非 root 进程去执行没有 file capability 的二进制,拿到的 effective set 就是空的,没有例外。所以答案很无聊:内部改用非特权端口。
|
||||
这个原因让我意外到用探针容器实测了一把:`grep Cap /proc/self/status` 显示 `CapBnd` 里有 bit 10(NET_BIND_SERVICE),而 `CapEff` 是零。Docker 给非 root 容器的 capability 只进 bounding set,不进 effective set,而 bind() 检查的是 effective set。一个非 root 进程去执行没有 file capability 的二进制,拿到的 effective set 就是空的,没有例外。所以答案很无聊:内部改用非特权端口。
|
||||
|
||||
```yaml
|
||||
app:
|
||||
@@ -126,35 +126,35 @@ HiddenServicePort 80 app:8080
|
||||
|
||||
### 检查对的东西的 healthcheck
|
||||
|
||||
tor 镜像自带一个探 SOCKS 端口的 healthcheck。我刚把 SOCKS 关了,于是 healthy 永远变成了 unhealthy。用你真正想问的问题覆盖它:进程还活着吗?
|
||||
tor 镜像自带一个探 SOCKS 端口的 healthcheck。我刚把 SOCKS 关了,于是 healthy 永远变成了 unhealthy。用你真正想问的问题覆盖它:进程还活着吗?
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-x", "tor"]
|
||||
```
|
||||
|
||||
nginx 的坑更隐蔽。我的第一版用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标成不健康。这个检查测的是内容,不是服务。`nc -z 127.0.0.1 80` 只测端口,别的什么都不测。
|
||||
nginx 的坑更隐蔽。我的第一版用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标成不健康。这个检查测的是内容,不是服务。`nc -z 127.0.0.1 8080` 只测服务监听端口,别的什么都不测。
|
||||
|
||||
还有一件:tor 在启动时解析 `HiddenServicePort` 的目标地址。如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里看过它连续四次启动失败,没人发现,因为当时没有任何东西在看着。compose 里的 `depends_on` 修好了启动顺序。
|
||||
还有一件:tor 在启动时解析 `HiddenServicePort` 的目标地址。如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里看过它连续四次启动失败,没人发现,因为当时没有任何东西在看着。compose 里的 `depends_on` 修好了启动顺序。
|
||||
|
||||
### 属主地雷
|
||||
|
||||
换完新密钥后,我通过文件共享挂载把密钥材料拷了回去。之后的容器立刻崩溃循环:
|
||||
换完新密钥后,我通过文件共享挂载把密钥材料拷了回去。之后的容器立刻崩溃循环:
|
||||
|
||||
```
|
||||
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
|
||||
```
|
||||
|
||||
经共享写入的文件,属主是共享用户,不是 tor 运行的 uid。修复只需一条命令,在哪里都能跑,包括挂上卷的一次性容器:
|
||||
经共享写入的文件,属主是共享用户,不是 tor 运行的 uid。修复只需一条命令,在哪里都能跑,包括挂上卷的一次性容器:
|
||||
|
||||
```bash
|
||||
docker run --rm -v /path/libTor:/var/lib/tor alpine \
|
||||
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
|
||||
```
|
||||
|
||||
### 验证,而不是相信
|
||||
### 验证,而不是相信
|
||||
|
||||
上面每个修复都有一个我自己能跑的测试收尾。我最喜欢出网这条,因为两个探针放在一起最有说服力:同一个 wget,在应用的网络里失败,在 tor 的出网网络里成功。
|
||||
上面每个修复都有一个我自己能跑的测试收尾。我最喜欢出网这条,因为两个探针放在一起最有说服力:同一个 wget,在应用的网络里失败,在 tor 的出网网络里成功。
|
||||
|
||||
```bash
|
||||
# 在应用里:internal:true 之下,连 DNS 都应该失败
|
||||
@@ -164,30 +164,30 @@ wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
|
||||
wget -T 6 -qO- http://ipv4.icanhazip.com # → <你的 IP>, exit 0
|
||||
```
|
||||
|
||||
如果没法 exec 进容器,就挂一个一次性探针容器到同一个网络。它测的是网络的属性,而那正是你加固的对象。
|
||||
如果没法 exec 进容器,就挂一个一次性探针容器到同一个网络。它测的是网络的属性,而那正是你加固的对象。
|
||||
|
||||
## 值得把它做成服务吗?
|
||||
## 值得把它做成服务吗?
|
||||
|
||||
我一直在想这件事,因为边际成本几乎为零:tor 容器和它的隔离措施本来就在跑。
|
||||
我一直在想这件事,因为边际成本几乎为零:tor 容器和它的隔离措施本来就在跑。
|
||||
|
||||
我大多数托管客户想要的和洋葱服务正好相反。他们想被 Google 搜到。卖一个只能在 Tor Browser 里打开的网站,等于卖给他们大概率用不上的隐秘性,还要负责教他们的访客安装 Tor Browser。
|
||||
我大多数托管客户想要的和洋葱服务正好相反。他们想被 Google 搜到。卖一个只能在 Tor Browser 里打开的网站,等于卖给他们大概率用不上的隐秘性,还要负责教他们的访客安装 Tor Browser。
|
||||
|
||||
但确实有一小块真实市场。交换草稿的律师、审计师、交付数字商品的人,任何想分享一份永远不该出现在搜索引擎索引里的档案的人。对这类客户,销售话术自己就写好了:没有端口转发,没有域名,没有你控制不了的平台上留日志,只有一个你亲手交给该拿到的人的地址。
|
||||
但确实有一小块真实市场。交换草稿的律师、审计师、交付数字商品的人,任何想分享一份永远不该出现在搜索引擎索引里的档案的人。对这类客户,销售话术自己就写好了:没有端口转发,没有域名,没有你控制不了的平台上留日志,只有一个你亲手交给该拿到的人的地址。
|
||||
|
||||
有个卖点比我预想的好使,它连着一个人人都会问的问题:地址开头能自己选吗?v3 洋葱地址是随机的,但那只是因为密钥是随机的。地址可以挖:不断生成密钥对,直到 base32 地址以你想要的前缀开头。每多一个字符,工作量乘以 32。社区挖矿工具在现代 GPU 上每秒能检查几百万个地址,这意味着 8 字符前缀是"一天到几天"的活,9 字符需要耐心地挖上几周,10 字符则是严肃的多 GPU 投入。一个以客户品牌开头的前缀,能把一行难记的 56 字符地址变成他们可以确认"这真的是你"的东西——在一个信任就是全部产品的细分市场里,这是实打实的价值。8 字符我乐意挖,9 字符为了付费客户可以,10 字符只有在对方自己租 GPU 的情况下我才会一脸平静地报价。
|
||||
有个卖点比我预想的好使,它连着一个人人都会问的问题:地址开头能自己选吗?v3 洋葱地址是随机的,但那只是因为密钥是随机的。地址可以挖:不断生成密钥对,直到 base32 地址以你想要的前缀开头。每多一个字符,工作量乘以 32。社区挖矿工具在现代 GPU 上每秒能检查几百万个地址,这意味着 8 字符前缀是"一天到几天"的活,9 字符需要耐心地挖上几周,10 字符则是严肃的多 GPU 投入。一个以客户品牌开头的前缀,能把一行难记的 56 字符地址变成他们可以确认"这真的是你"的东西——在一个信任就是全部产品的细分市场里,这是实打实的价值。8 字符我乐意挖,9 字符为了付费客户可以,10 字符只有在对方自己租 GPU 的情况下我才会一脸平静地报价。
|
||||
|
||||
所以:作为给少数特定客户的附加服务,值得;作为一条产品线,不值。市场太薄,撑不起渠道,而且支持成本不会随规模摊薄。"带洋葱地址的隐私咨询",可以;"洋葱托管"作为一个网站套餐,那是别人的生意。
|
||||
所以:作为给少数特定客户的附加服务,值得;作为一条产品线,不值。市场太薄,撑不起渠道,而且支持成本不会随规模摊薄。"带洋葱地址的隐私咨询",可以;"洋葱托管"作为一个网站套餐,那是别人的生意。
|
||||
|
||||
## 最后留下的
|
||||
|
||||
- **跑测试,别读配置文件。** 容器内探针和健康状态是真相,yaml 只是意图。
|
||||
- **跑测试,别读配置文件。** 容器内探针和健康状态是真相,yaml 只是意图。
|
||||
- **优先选择无法静默解体的机制。** `internal: true` 永远胜过 iptables 开机任务。
|
||||
- **healthcheck 很便宜。** 它几分钟就抓到了一个崩溃循环,而之前没人看着,坏了好几天。
|
||||
- **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果攻击者能直接从一个开着的端口走进来,就不会有人费劲用流量分析去匿名化你。
|
||||
- **healthcheck 很便宜。** 它几分钟就抓到了一个崩溃循环,而之前没人看着,坏了好几天。
|
||||
- **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果攻击者能直接从一个开着的端口走进来,就不会有人费劲用流量分析去匿名化你。
|
||||
|
||||
## 想要一个吗?
|
||||
## 想要一个吗?
|
||||
|
||||
我帮客户做这类东西:创建洋葱站点、托管和维护(更新、健康检查、监控),以及应要求挖一个品牌前缀的地址,让地址以名字开头而不是一串乱码。如果你需要一个"只有你选的人才能访问"的网站,这套东西做的正是这个。
|
||||
我帮客户做这类东西:创建洋葱站点、托管和维护(更新、健康检查、监控),以及应要求挖一个品牌前缀的地址,让地址以名字开头而不是一串乱码。如果你需要一个"只有你选的人才能访问"的网站,这套东西做的正是这个。
|
||||
|
||||
- WhatsApp: [wa.me/60127972969](https://wa.me/60127972969)
|
||||
- 邮箱: [[email protected]](mailto:[email protected]?subject=Onion%20service%20setup)
|
||||
|
||||
@@ -7,6 +7,7 @@ tags: [storage, smb, verification, ffmpeg, python]
|
||||
ogImage: /og/zh-it-reported-success-nothing-had-changed.jpg
|
||||
banner: /banners/it-reported-success-nothing-had-changed.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
## 一批"看起来没问题"的任务
|
||||
@@ -47,9 +48,9 @@ if hash_file(src) != hash_file(_reread(dst)):
|
||||
|
||||
第二个藏在一个你无条件信任的文件系统原语后面:`os.replace()`——原子的、同一文件系统内的、不可能做一半的。只不过这里的"文件系统"其实是一个自带元数据缓存的网络客户端。
|
||||
|
||||
当时的顺序是:上传 `show.mp4.new.mp4`、校验哈希、`os.replace()` 覆盖 `show.mp4`、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 `ok 1085 MB -> 478 MB`。
|
||||
当时的顺序是:上传 `archive-04.mp4.new.mp4`、校验哈希、`os.replace()` 覆盖 `archive-04.mp4`、然后探测结果确认。探测说 HEVC,大小看着对,日志写了 `ok 1085 MB -> 478 MB`。
|
||||
|
||||
共享盘上那个文件**是原封未动的原文件**:H.264、921,950,850 字节。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 `getsize()` 和 `mtime` 兴高采烈地描述着一个已经不存在的文件。
|
||||
共享盘上那个文件**是原封未动的原文件**:H.264、1,137,704,960 字节(1085 MiB)。替换根本没被应用;客户端为该路径返回了缓存的属性,于是 `getsize()` 和 `mtime` 兴高采烈地描述着一个已经不存在的文件。
|
||||
|
||||
唯一能在撒谎的元数据缓存下活下来的检查,是把字节读回来:
|
||||
|
||||
@@ -101,3 +102,11 @@ reported=30 landed=29 not-landed=0 wrong-codec=1 gone=0
|
||||
```
|
||||
|
||||
那里的每一个数字都是**读目标读出来的**,不是信任日志得来的。
|
||||
|
||||
---
|
||||
|
||||
## 你的批处理或数据流水线是否只靠“日志没报错”来验收?
|
||||
|
||||
我为企业设计和加固无人值守的媒体处理、数据迁移与自动化备份流水线——用端到端校验与确定性状态机替代“盲信 API 返回值”。如果你的业务流水线跑在 NAS、跨网挂载或混合存储之上,我可以帮你把静默数据损坏变成可观测、可自愈的工程闭环。
|
||||
|
||||
欢迎联系 [[email protected]](mailto:[email protected]?subject=Pipeline%20verification%20and%20storage%20reliability) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以在 [hoelee.com](https://hoelee.com) 查看我的工程交付。
|
||||
@@ -6,9 +6,11 @@ category: devops
|
||||
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
|
||||
ogImage: /og/zh-n8n-v1-to-v2-upgrade-gotchas.jpg
|
||||
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
n8n 是我整个自建技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
|
||||
n8n 是我自托管生产环境技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
|
||||
|
||||
然后我拉了 `n8nio/n8n:2.40.1` 镜像并重启容器。升级本身只花了大约九十秒。而搞清楚它*弄坏了什么*花了我剩下的一整晚——而其中几乎所有信息,n8n 在启动时就已经写在一个日志文件里了。只是我第一次读得不够仔细。
|
||||
|
||||
@@ -41,7 +43,7 @@ actions to update your configuration:
|
||||
5000 to 1000 in a future version.
|
||||
```
|
||||
|
||||
这一块就是你的迁移清单。我那七项坑里的六项都在里面。
|
||||
这一块就是你的核心迁移清单。我遇到的七项配置与废弃警告坑里,有五项直接出自这个列表,另外两项则藏在紧随其后的启动日志行里。
|
||||
|
||||
## 1. 你的环境变量值现在可能过不了 schema 校验
|
||||
|
||||
@@ -102,6 +104,8 @@ N8N_RUNNERS_MODE=external
|
||||
N8N_RUNNERS_AUTH_TOKEN=<一串足够长的随机字符串>
|
||||
```
|
||||
|
||||
(注意:开启 `external` 模式后,需要在同一编排栈里挂载配套的 `n8nio/runners` sidecar 容器并共享同一枚 token,由它接管 JS 与 Python 的隔离执行。)
|
||||
|
||||
**教训:** 「internal 模式已废弃」是标题,但真正的发现是:某一类 runner 可能已经*静默失效*好几个月了。要检查 `docker logs` 里的 runner 注册那行,而不是只看容器起没起来。
|
||||
|
||||
## 4. Task 超时默认值从 300 秒降到 60 秒
|
||||
@@ -149,7 +153,7 @@ path, update your mount configuration after migration.
|
||||
|
||||
再读一遍最后一句:*如果你有一个卷挂载在旧路径上,请在迁移后更新你的挂载配置。* 如果你设了迁移标志却保留旧的 bind mount,你现在就有两个目录,而你的二进制数据住在容器实际指向的那一个里。把改名和挂载变更放在同一个维护窗口里做——不要一个现在、一个「以后」。
|
||||
|
||||
## 那个不在日志里的:我的 AI 沙盒把自己关掉了
|
||||
## 那个不在废弃列表里的:我的 AI 沙盒把自己关掉了
|
||||
|
||||
这是跟废弃警告毫无关系的发现,也是如果我不读完整的启动序列就永远抓不到的一项:
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
|
||||
ogImage: "/og/zh-one-hostname-public-tracker-sso-dashboard.jpg"
|
||||
banner: "/banners/one-hostname-public-tracker-sso-dashboard.jpg"
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
几乎每个统计工具都长成一个尴尬的形状:负责**收集**数据的那一半,必须让陌生人的浏览器在每次打开页面时都能访问;负责**展示**数据的那一半,必须只有我能访问。
|
||||
@@ -20,11 +21,11 @@ draft: false
|
||||
- **埋点必须公开。** `script.js` 和两个收集端点由每个访客的浏览器抓取。如果它们被鉴权挡住,你就什么都收不到,而且好几天都不会发现。
|
||||
- **仪表盘绝不能公开。** 它显示每个访客的国家、城市、来源和页面路径。一个默认管理员登录挂在公网 URL 上,就是送给任何在证书透明度日志里翻到你子域的人。
|
||||
- **没有泛解析 DNS。** `*.hoelee.com` 不解析,所以每个域名都是一条显式 DNS 记录加一张证书。第二个域名是实打实的工作量,还让我要维护的攻击面翻倍。
|
||||
- **这个域名不走 Cloudflare。** 它是一条直指我家路由器的 A 记录,所以没有 WAF、没有 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠普通 nginx 头工作。
|
||||
- **这个域名不走 Cloudflare。** 它是一条直指我自托管边缘网关的 A 记录,所以没有云端 WAF、没有托管 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠标准 nginx 转发头工作。
|
||||
|
||||
## 方案一:两个域名
|
||||
|
||||
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。我的博客一天只有个位数访问。我是在抄一个解决「我没有的问题」的架构,代价是第二条 DNS 记录、第二张证书、第二个会坏的地方。
|
||||
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由全球 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。但对于独立技术站点或中小型商业项目,采集端请求量完全在单节点轻松承载的范围内。照搬那套架构等于在解决一个并不存在的问题,代价却是第二条 DNS 记录、第二张证书、第二个会坏的地方。
|
||||
|
||||
两个域名是**规模**的答案,不是**需求**。一个域名就能同时承载两者,按路径分开就行。
|
||||
|
||||
@@ -150,7 +151,7 @@ internal_host: http://umami:3000 # outpost 把请求代理到这个
|
||||
## 说清楚取舍
|
||||
|
||||
- **双重登录。** 统计应用不支持 OIDC,所以 SSO 只解决了「能不能到这个路由」,应用仍会要它自己的用户名密码。每个浏览器多一次点击。真正保护数据的是应用内可以按账号开启的两步验证。
|
||||
- **仪表盘的可用性等于 outpost 的可用性。** authentik 挂了,仪表盘就挂了;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。
|
||||
- **仪表盘的可用性等于 outpost 的可用性。** 因为我选择把路径白名单策略收敛在 authentik 单一控制面(而不是在边缘 nginx 和隧道两套入口配置里重复维护正则分流),authentik 挂了仪表盘就会挂;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。若对采集可用性有极致要求,也可以在边缘 nginx 单独把三个公开路径直通容器。
|
||||
- **我保留了一条后门。** 应用仍在局域网里发布一个端口,完全绕开 SSO。它从公网不可达(用外部主机验证过),仍然需要应用自己的凭据,但我宁可留着它,也不想因为一次 SSO 配置失误把自己锁在自己的统计之外。
|
||||
|
||||
## 结果
|
||||
|
||||
@@ -7,6 +7,8 @@ category: devops
|
||||
tags: [passbolt, docker, gpg, smtp, reverse-proxy, portainer]
|
||||
ogImage: /og/zh-passbolt-hang-three-failure-modes.jpg
|
||||
banner: /banners/passbolt-hang-three-failure-modes.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
我在 Synology NAS 上用 Docker 跑 Passbolt——一个开源的团队密码管理器,前面
|
||||
@@ -82,7 +84,7 @@ return [
|
||||
],
|
||||
'passbolt' => [
|
||||
'gpg' => [
|
||||
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
|
||||
'serverFingerprint' => env('PASSBOLT_GPG_SERVER_KEY_FINGERPRINT', 'A3DD9B762D48722C10CF88DDB5372E46A54E1419'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -163,6 +165,6 @@ Passbolt 的 SSL 强制中间件会检查请求的 scheme,而在 TLS 终止型
|
||||
反向代理和各种自托管技术栈,交付时会把一切整理成文档,让下一个人(或未来的你)
|
||||
不用靠猜。
|
||||
|
||||
联系我:[[email protected]](mailto:[email protected]) 或 WhatsApp
|
||||
联系我:[[email protected]](mailto:[email protected]?subject=Passbolt%20and%20Docker%20troubleshooting) 或 WhatsApp
|
||||
[+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
|
||||
[hoelee.com](https://hoelee.com) 做的事情。
|
||||
@@ -7,6 +7,7 @@ tags: [mysql, mariadb, workbench, debugging, electron, mysql-sh, cyberpanel, sel
|
||||
ogImage: /og/zh-patching-workbench-26-for-mariadb.jpg
|
||||
banner: /banners/patching-workbench-26-for-mariadb.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
我用一台 Windows 机器管着四个数据库服务:两个 MySQL 8.4、一个本地安装、还有一个跑在局域网 CyberPanel 后面的 MariaDB 10.11。全新的 MySQL Workbench 26.7.0 里,三个连接都正常打开,只有一个不行。
|
||||
@@ -247,7 +248,7 @@ except Exception:
|
||||
|
||||
## 怎么给 Program Files 下的文件打补丁
|
||||
|
||||
Workbench 的文件是**只读**的,而这一点以值得记录的方式坑了我两次:写入会**静默失败**。PowerShell 的 `WriteAllText` 报告成功,而磁盘上的文件毫无变化——大小一样、时间戳一样。对内容做 `.Replace()` 也报告成功却什么都没做,原因是行尾符差异。
|
||||
Workbench 安装目录下的文件带有**只读(ReadOnly)**属性且受 `Program Files` 权限保护,这在打补丁时坑了我两次:如果还没清掉只读标志就直接写,会立刻撞上权限拒绝;而清掉只读之后,用 PowerShell 的 `WriteAllText` 配合字符串 `.Replace()` 却会**静默空转**——命令不报错、毫无异常,但磁盘上的文件内容纹丝不动,原因是源码文件的 CRLF 行尾符导致多行 `.Replace()` 根本没匹配上,把原字符串原封不动又写了一遍。
|
||||
|
||||
真正有效的做法:
|
||||
|
||||
@@ -297,3 +298,11 @@ class FakeSession:
|
||||
- **保留旧客户端。** Workbench 8.0.x 没有这个探测,连 MariaDB 毫无怨言。用 26.x 管 MySQL 8.4、用 8.0.x 管 MariaDB,是一个完全合理的策略——也正因为如此,这次只是一个晚上的智力题,而不是被堵住的一天。
|
||||
|
||||
版本号是最容易比较的东西,也是最容易被比较错的东西。两个项目共用一套编号体系,并不等于它们共用一套功能集。
|
||||
|
||||
---
|
||||
|
||||
## 被数据库兼容性或生产环境疑难故障卡住了?
|
||||
|
||||
我负责管理与排查混合数据库环境(MySQL、MariaDB、PostgreSQL)及自托管后端基础设施——从跨引擎迁移、复制链路修复,到报错信息毫无头绪的底层故障定位。如果你的系统正在经历难以复现的数据库报错或升级阻碍,我可以介入诊断并交付可验证的修复方案。
|
||||
|
||||
欢迎联系 [[email protected]](mailto:[email protected]?subject=Database%20troubleshooting%20and%20migration) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以在 [hoelee.com](https://hoelee.com) 查看我的服务内容。
|
||||
@@ -9,6 +9,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
|
||||
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
|
||||
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
updatedDate: 2026-10-23
|
||||
---
|
||||
|
||||
@@ -36,7 +37,7 @@ updatedDate: 2026-10-23
|
||||
|
||||
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|
||||
|---|---|---|---|---|
|
||||
| VPS 41.216.187.102 | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
|
||||
| VPS (203.0.113.10) | 6 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(6 个站) | 有——8.3.36 |
|
||||
| VPS | mailcow `phpfpm` | **8.2.29** | 公网(它自己的 web UI) | 有,藏在 mailcow 2026-10 里 |
|
||||
| CyberPanel VM | `lsphp84`(8 个站) | **8.4.25** | 公网(8 个站) | 有——8.4.27 |
|
||||
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | 未使用 / 默认回退 | 8.1 永远没有,其余有 |
|
||||
@@ -73,9 +74,9 @@ done | sort -u
|
||||
```
|
||||
|
||||
```text
|
||||
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
client_wood -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
numer9393 -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
app_portal -> /usr/local/lsws/lsphp84/bin/lsphp
|
||||
...
|
||||
```
|
||||
|
||||
@@ -145,12 +146,12 @@ curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
|
||||
|
||||
```text
|
||||
hoelee.com 200 227390 (切换前后完全一致)
|
||||
chaijoywood.com 200 266192 (一致)
|
||||
nestz.com.my 200 222780 (一致)
|
||||
truetrading.com.my 200 134535 (一致)
|
||||
client-wood.com 200 266192 (一致)
|
||||
client-property.com.my 200 222780 (一致)
|
||||
client-trading.com.my 200 134535 (一致)
|
||||
test.hoelee.com 200 80869 (一致)
|
||||
sifumail.com 200 44861 (一致)
|
||||
numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
|
||||
client-mail.com 200 44861 (一致)
|
||||
app.client-portal.com 200 142762 (后来迁到 8.5.12,见下文)
|
||||
```
|
||||
|
||||
我还会在每个响应里 grep `Fatal error` / `Uncaught` / `Deprecated:`,并请求 `/wp-json/`——那里返回 200 说明 WordPress 真的启动、查库、序列化了一份响应,比一个可能被缓存的首页渲染强得多。全部零错误。(WordPress 6.9 与 7.x 官方声明完全支持 PHP 8.5,残余风险在插件不在核心。我的站每个装了 19–44 个插件,这正是字节数校验值得做的原因。)
|
||||
@@ -163,7 +164,7 @@ numerology.digikedai.com 200 142762 (后来迁到 8.5.12,见下文)
|
||||
|
||||
```text
|
||||
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
|
||||
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
|
||||
/usr/local/lsws/conf/vhosts/client-trading.com.my/vhost.conf:80
|
||||
```
|
||||
|
||||
我把它当噪音读了好几周。而它本身就是全部诊断。删掉那两个括号、重载之后,同样三个 URL 变成了 **403**,配置测试从两条错误变成零。
|
||||
@@ -180,7 +181,7 @@ PHP 8.3 8.3.30-0050 -> 8.3.36-0051
|
||||
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
|
||||
```
|
||||
|
||||
麻烦的是访问权。那台机器的 SSH 只认一个我手上没有的密码,管理界面要登录,而能装 `.spk` 的只有宿主机上的 `synopkg`。我手里有的是这台 NAS 的 Docker API。于是让包管理器在一个特权容器里跑,把宿主根目录挂进去,用 `chroot` 调用它:
|
||||
麻烦的是批量执行通道。为了平时收敛攻击面,那台 NAS 没开常规 SSH 密钥直连,而在 DSM 网页界面手动点三次 `.spk` 上传又慢又无法脚本化;当时我手头自动化巡检端现成打通的是这台 NAS 的 Docker API。于是我直接通过 API 起了一个一次性特权维护容器,把宿主根目录挂进去,用 `chroot` 调宿主的 `synopkg` 完成静默安装:
|
||||
|
||||
```python
|
||||
HostConfig = {
|
||||
@@ -224,7 +225,7 @@ tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月
|
||||
|
||||
## 我说不能动的那个应用
|
||||
|
||||
我原本把 `numerology.digikedai.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
|
||||
我原本把 `app.client-portal.com` 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 `composer.json` 允许 `^8.2`,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 `E_ALL` 加载干净:`spark routes`,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
|
||||
|
||||
这次切换还牵出第二个跟 CVE 毫无关系的运行时。应用的三条 cron 调的是 `/usr/bin/php`——发行版自带的 PHP 8.3.6 CLI,没有 `ext-intl`——而站点本身走的是 LiteSpeed 的构建。失败又安静又彻底:`Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189`,自动出件和发信队列两个 worker 日志里各有 429 行,每一次定时任务都在干活之前就死了。把三条 crontab 指到 `/usr/local/lsws/lsphp85/bin/php`(也就是站点自己用的那个二进制)之后才结束。
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ tags: ["nginx", "docker", "self-hosting", "seo", "branding"]
|
||||
ogImage: "/og/zh-putting-a-front-door-on-an-app-you-cant-modify.jpg"
|
||||
banner: "/banners/putting-a-front-door-on-an-app-you-cant-modify.jpg"
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
我的阅读服务,访客打开看到的第一样东西是一个中文登录框。没有说明这个站是什么,没有邀请码说明,没有品牌,链接分享出去也没有社交卡片,搜索引擎也没有任何值得收录的东西。就是一个登录弹窗,上面一首诗。
|
||||
@@ -87,6 +88,8 @@ location / { # everything else: the app, untouched
|
||||
|
||||
**不同路由用不同缓存时长。** 首页是 `no-store`(我直接在磁盘上编辑它,希望刷新即生效),社交图缓存一天,注入脚本缓存五分钟。各一行。
|
||||
|
||||
**精确区分 `/` 与 `/index.html`。** `location = /` 里特意写了 `try_files /index.html =404` 而不是依赖默认的 `index` 指令——因为 nginx 的 `index` 会触发一次到 `/index.html` 的内部重定向,那会直接跳进后面代理应用外壳的 `location = /index.html` 块。用 `try_files` 在当前块直接读出本地门面页,而门面页上的「进入」按钮则显式跳转到 `/index.html` 触发上游代理。
|
||||
|
||||
## 页面本身
|
||||
|
||||
它是一个自包含的 HTML 文件:没有网络字体、没有 CDN、没有构建步骤。这不是为了极简而极简——它意味着这扇前门没有任何第三方依赖会变慢、被墙或停止服务,而且断网时它还能从缓存里渲染出来。
|
||||
@@ -113,6 +116,9 @@ location = /index.html {
|
||||
sub_filter '</head>' '<script>(function(){try{if(!navigator.onLine)return;
|
||||
if(sessionStorage.getItem("gatePass")==="1"){sessionStorage.removeItem("gatePass");return;}
|
||||
location.replace("/"+(location.hash||""));}catch(e){}})();</script></head>';
|
||||
|
||||
# 3. inject the client-side English UI translation layer before closing body
|
||||
sub_filter '</body>' '<script src="/en-mode.js" defer></script></body>';
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
|
||||
ogImage: /og/zh-read-only-nocodb-dashboard-for-a-remote-database.jpg
|
||||
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
我的报告应用把每一条留资、每一张订单都写进一台小 VM 上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
|
||||
我的报告应用把每一条留资、每一张订单都写进一台独立虚拟机上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
|
||||
|
||||
最直觉的答案是做一个表格风格的后台界面。最糟的答案是把数据复制一份。这篇写的是我最后落地的配方:**让 NocoDB 直接指向真库,用的账号只能 `SELECT`** —— 外加三个吃掉我半下午的坑,其中一个是 Docker 网络事实,在任何「容器访问局域网」的场景里都会咬人,不只是这一次。
|
||||
|
||||
@@ -125,7 +126,7 @@ curl -s -H "xc-token: $TOKEN" \
|
||||
|
||||
**`DATETIME` 列会带上 UTC 标签。** 本地时间 01:54 写入的行,读回来是 `2026-09-27 01:54:16+00:00`。值是对的,时区后缀只是展示层的事。拿来浏览没问题,拿它做跨时区换算就错了 —— 别在它上面搭逻辑。
|
||||
|
||||
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
|
||||
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。虽然 NocoDB 的 UI 提供了数据源的 Schema Read-Only 开关,但在数据库层做纯 `GRANT SELECT` 构成了真正的纵深防御:即便 UI 误操作放开写入,存储引擎也会物理拦截。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
|
||||
|
||||
## 四个值得顺手拿走的小坑
|
||||
|
||||
@@ -151,7 +152,7 @@ curl -s -H "xc-token: $TOKEN" \
|
||||
|
||||
---
|
||||
|
||||
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在 NAS 或小 VM 上。*
|
||||
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在私有化服务器或独立虚拟机上。*
|
||||
|
||||
*需要给你的生意搭一套?[WhatsApp +60 12-797 2969](https://wa.me/60127972969)
|
||||
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
|
||||
|
||||
@@ -9,9 +9,10 @@ tags: [windows, security, rdp, ipban, rdpguard, brute-force, firewall, self-host
|
||||
ogImage: /og/zh-replacing-rdpguard-with-ipban.jpg
|
||||
banner: /banners/replacing-rdpguard-with-ipban.jpg
|
||||
draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
只要把一台开了远程桌面的 Windows 机器放到公网上,你就知道接下来会发生什么:几分钟内,日志里就塞满了来自世界各地租用服务器的登录失败记录。你必须有东西自动把它们挡掉。
|
||||
只要把一台开了远程桌面的 Windows 机器放到公网上(无论是历史遗留场景,还是在收进内网 VPN 之前的过渡期),你就知道接下来会发生什么:几分钟内,日志里就塞满了来自世界各地租用服务器的登录失败记录。你必须有东西自动把它们挡掉。
|
||||
|
||||
我用了好几年 **RDPGuard 7.8.7**。它是能用的,但它是付费闭源的,而且我已经落后了三个大版本。所以我换成了 **IPBan**——MIT 许可、从 2011 年维护至今、GitHub 上大约 2200 星,确实是目前最接近即插即用的替代品。
|
||||
|
||||
@@ -73,7 +74,7 @@ IPBan 几秒内就读到了,自己把文件删掉,然后重写了它自己
|
||||
5.181.86.60:add, 61.216.137.152:add
|
||||
```
|
||||
|
||||
这些封禁记录也值得读一读,而不是直接删。12 个里有 2 个试用的用户名是 `HOELEE01` 和 `HOELEE1`——正好是机器主人的账号名,被猜出来了。另一个把 `Administrator` → `Administrador` → `Admin` 轮了一遍。这是针对具体个人的侦察。如果先让卸载程序跑,这类细节你根本不会知道自己丢了。
|
||||
这些封禁记录也值得读一读,而不是直接删。12 个里有 2 个试用的用户名是 `HOELEE01` 和 `HOELEE1`——虽然这台机器上并没有叫这个名字的本地账户,但它直接借用了我公开域名里的品牌标识。另一个把 `Administrator` → `Administrador` → `Admin` 轮了一遍。这是结合了公开信息抓取的定向侦察,而不只是盲跑通用字典。如果先让卸载程序跑,这类情报你根本不会知道自己丢了。
|
||||
|
||||
## 坑二:`--install-service` 根本不存在
|
||||
|
||||
@@ -102,8 +103,9 @@ Commands:
|
||||
|
||||
答案是 `sc.exe`——项目自己的卸载脚本就暗示了这一点(`sc.exe stop IPBAN` / `sc.exe delete IPBAN`):
|
||||
|
||||
```
|
||||
sc.exe create IPBAN type= own start= auto binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan"
|
||||
```cmd
|
||||
sc.exe create IPBAN type= own start= auto ^
|
||||
binPath= "C:\app\IPBan\DigitalRuby.IPBan.exe" DisplayName= "IPBan Service"
|
||||
```
|
||||
|
||||
**`sc.exe` 里的空格是承重的。** `type= own`、`start= auto`、`binPath= "..."` 每一处的 `=` 后面都**必须有一个空格**。省略了,`sc` 会把参数当成一整个 token 解析然后报错。这是个真实的陷阱:它看起来像打错字,所以看起来也可以不打。
|
||||
@@ -135,7 +137,7 @@ Service Start Type: auto start
|
||||
Service Account: LocalSystem
|
||||
```
|
||||
|
||||
一个小小的瑕疵:因为 `sc create` 没给 `DisplayName=`,服务的显示名就是干巴巴的 `IPBAN`,而不是一个友好的名字。无害,但在 `services.msc` 里看起来像没装完。
|
||||
一个顺手替你省掉的小瑕疵:我第一次执行 `sc.exe create` 时没传 `DisplayName=`,结果服务名和显示名都成了干巴巴的 `IPBAN`,在 `services.msc` 里看着像没装完;上面的命令里我已经把 `DisplayName= "IPBan Service"` 补上了。
|
||||
|
||||
## 坑三:`ExpireTime` 不是封禁时长
|
||||
|
||||
@@ -202,7 +204,7 @@ IPBan 把配置拆成两份:
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
# 实时看日志
|
||||
Get-Content C:\app\IPBan\logfile.txt -Wait -Tail 20
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ Hermes 提供了三种迁移 agent 的方式,它们回答的是不同的问题
|
||||
| 整个 agent、带版本、可拉取更新 | **Profile distribution**(git 仓库 + `distribution.yaml`) | `hermes profile install` / `update` |
|
||||
| 一次性迁移 / 备份 | 导出文件(`.tar.gz`) | `hermes profile export` / `import` |
|
||||
|
||||
对于"同一 agent 在两台机器上",**profile distribution** 才是持久的答案。它把整个 agent——SOUL、配置、技能、定时任务——打包成一个 git 仓库,另一台机器用一条命令就能拉取更新。
|
||||
对于「同一 agent 在两台机器上」,**profile distribution** 才是持久的答案。它把整个 agent——SOUL、配置、技能、定时任务——打包成一个 git 仓库,另一台机器用一条命令就能拉取更新。
|
||||
|
||||
我所有东西本来就用 Gitea(git.hoelee.com),所以在那里放一个 distribution 仓库是顺理成章的。没有新工具、没有新账号,也符合我每个项目都遵循的"Gitea 优先"惯例。
|
||||
我所有东西本来就用 Gitea(git.hoelee.com),所以在那里放一个 distribution 仓库是顺理成章的。没有新工具、没有新账号,也符合我每个项目都遵循的「Gitea 优先」惯例。
|
||||
|
||||
## 我差点踩进去的坑
|
||||
|
||||
@@ -49,24 +49,24 @@ distribution 安装器有一个安全特性,*听起来*像限制,却恰恰
|
||||
|
||||
这不是麻烦。第二台电脑本来就有自己的 agent 和项目相关的技能。如果我把它*覆盖*安装上去——或者用了会覆盖现有 profile 的 `--force`——我就会删掉那台机器本地的成果。`default` 拒绝机制就是那道护栏,逼着你在现有 agent *旁边*安装,然后再合并。
|
||||
|
||||
而这正是整个练习真正的教训:**合并才是产品本身。** 同步不是"复制文件",而是调和两个都以为自己是真相来源的 agent。
|
||||
而这正是整个练习真正的教训:**合并才是产品本身。** 同步不是「复制文件」,而是调和两个都以为自己是真相来源的 agent。
|
||||
|
||||
## 那个差点让我翻车的目录事实
|
||||
|
||||
这里是微妙的部分,也是那种"看起来没事、直到出事"的坑。
|
||||
这里是微妙的部分,也是那种「看起来没事、直到出事」的坑。
|
||||
|
||||
distribution 仓库里放的是技能树的**副本**——它不是*活跃目录*。活跃技能在 `~/AppData/Local/hermes/skills/`(agent 真正写入的地方);仓库副本只是我推送的一份快照。它们是两棵会漂移的、互相独立的树。
|
||||
distribution 仓库里放的是技能树的**副本**——它不是*活跃目录*。活跃技能在 `~/.hermes/skills/`(agent 真正写入的地方);仓库副本只是我推送的一份快照。它们是两棵会漂移的、互相独立的树。
|
||||
|
||||
所以一次同步实际上涉及**三方**,而不是两方:
|
||||
|
||||
```
|
||||
活跃技能目录 ←→ 仓库副本(D:\dev\hoelee-agent-dist\skills) ←→ Gitea 远端
|
||||
活跃技能目录 ←→ 仓库副本(~/workspace/agent-dist/skills) ←→ Gitea 远端
|
||||
```
|
||||
|
||||
我在上次推送之后新建的一个技能,*只*存在于活跃目录里。如果我假设仓库副本是权威、并"从它开始同步",就会静默删掉那个技能。所以每次同步前我做的第一件事,就是比对两棵树:
|
||||
我在上次推送之后新建的一个技能,*只*存在于活跃目录里。如果我假设仓库副本是权威、并「从它开始同步」,就会静默删掉那个技能。所以每次同步前我做的第一件事,就是比对两棵树:
|
||||
|
||||
```bash
|
||||
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
|
||||
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
|
||||
```
|
||||
|
||||
漂移是*正常的*,不是错误。整套纪律在于搞清楚三方里谁领先,然后有意识地调和。
|
||||
@@ -75,16 +75,16 @@ diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
|
||||
|
||||
这是最关键的决定,而它归结为冲突策略。我想要双向同步——两台机器都会编辑技能——而当两边编辑了*同一个*技能时,我想**两边都保留、由我自己来挑**,而不是让工具静默丢掉其中一个。
|
||||
|
||||
这种策略本质上是交互式的。一个自动拉取的 cron 要么覆盖成果,要么在我没盯着的时候触发。所以我让触发变成手动:我说一句*"同步技能"*,agent 就去执行,并在**任何冲突时停下来问我**。这不是偷懒——而是合并的决定权在我手里,自动化做不了这个决定。
|
||||
这种策略本质上是交互式的。一个自动拉取的 cron 要么覆盖成果,要么在我没盯着的时候触发。所以我让触发变成手动:我说一句*「同步技能」*,agent 就去执行,并在**任何冲突时停下来问我**。这不是偷懒——而是合并的决定权在我手里,自动化做不了这个决定。
|
||||
|
||||
我记录下来的(也是现在真正在跑的)流程是:
|
||||
|
||||
```bash
|
||||
# 1. 把远端拉进仓库副本,只快进——绝不 force
|
||||
cd D:\dev\hoelee-agent-dist && git pull --ff-only origin main
|
||||
cd ~/workspace/agent-dist && git pull --ff-only origin main
|
||||
|
||||
# 2. 调和活跃目录 ↔ 仓库副本
|
||||
diff -rq ~/AppData/Local/hermes/skills D:/dev/hoelee-agent-dist/skills
|
||||
diff -rq ~/.hermes/skills ~/workspace/agent-dist/skills
|
||||
|
||||
# 3. 只暂存真正的技能内容(绝不包含 curator 垃圾)
|
||||
# 4. 提交 + 推送,然后验证远端确实有了
|
||||
@@ -112,13 +112,13 @@ skills/.bundled_manifest
|
||||
|
||||
## 我会做得不一样的地方
|
||||
|
||||
- **先想清楚触发方式,再选机制。** 我花了不少功夫纠结 distribution 还是 export,却还没回答"我要手动还是自动"——而这个答案反过来会*改变*机制。如果我一开始就知道要的是手动的、冲突感知的同步,第一天就会照这个方式去设计脚本。
|
||||
- **把"副本 vs 活跃目录"的区分当作一条有名字的不变量。** 我是靠 diff 才发现的;它本该从第一次 `cp -r` 起就写进我的笔记里。
|
||||
- **先想清楚触发方式,再选机制。** 我花了不少功夫纠结 distribution 还是 export,却还没回答「我要手动还是自动」——而这个答案反过来会*改变*机制。如果我一开始就知道要的是手动的、冲突感知的同步,第一天就会照这个方式去设计脚本。
|
||||
- **把「副本 vs 活跃目录」的区分当作一条有名字的不变量。** 我是靠 diff 才发现的;它本该从第一次 `cp -r` 起就写进我的笔记里。
|
||||
- **提前决定每台机器的 `user_id`。** 技能走 git 同步,但情节记忆(mem0)是另一个后端——两台机器要么共享一个记忆桶、要么各留各的,这是一个应该早点明确的单行配置选择。
|
||||
|
||||
## 结果
|
||||
|
||||
现在我有一个 git 仓库——`git.hoelee.com/hoelee/hoelee-agent-dist`——装着 172 项技能、我的配置、SOUL 和定时任务。另一台机器把它装进一个命名 profile,再把自己本地的技能合并进去,我们靠一个"每次冲突都问我"的手动触发器保持同步。第一次真正的同步推送了一个只存在于活跃机器上的技能,验证它落地了(两边都是 `edbf7ac`),并且没有动任何现有的技能。
|
||||
现在我有一个 git 仓库——`git.hoelee.com/ops/agent-dist`——装着 172 项技能、我的配置、SOUL 和定时任务。另一台机器把它装进一个命名 profile,再把自己本地的技能合并进去,我靠一个「每次冲突都问我」的手动触发器保持同步。第一次真正的同步推送了一个只存在于活跃机器上的技能,验证它落地了(两边都是 `edbf7ac`),并且没有动任何现有的技能。
|
||||
|
||||
给任何想同步 agent 的人的一句实在话:*文件*是简单的——git 干了大部分重活。难的是那些护栏:那个你绝不能跑的 `--force`,那个你绝不能误当成真相来源的副本,还有那个你绝不能让工具替你静默解决的冲突。
|
||||
|
||||
|
||||
@@ -134,11 +134,11 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
|
||||
|
||||
### 核心 3:对抗式代码审查员(Adversarial Reviewer)
|
||||
|
||||
当一个开发者(无论是人类还是 AI)为了调试某个复杂逻辑连续折腾了 20 轮对话后,他必然会产生严重的认知盲区:因为他清楚自己为此付出了多少心血,他会本能地倾向于认为自己的补丁是正确的。
|
||||
当一个开发者(无论是人类还是 AI)为了调试某个复杂逻辑连续迭代了 20 轮对话后,他必然会产生严重的认知盲区:因为他清楚自己为此付出了多少心血,他会本能地倾向于认为自己的补丁是正确的。
|
||||
|
||||
当主写 Agent 验证本地测试全部通过后,它会将生成的 `git diff` 提交给**对抗式审查员**。
|
||||
|
||||
审查员调用顶层推理模型(如 Gemini 4 Argon 或 Claude 3.5 Sonnet),且拥有**绝对空白纯净的上下文**。它只接收两样东西:
|
||||
审查员调用顶层推理模型(如 Claude 3.5 Sonnet 或顶级思维链推理模型),且拥有**绝对空白纯净的上下文**。它只接收两样东西:
|
||||
1. 原始任务目标。
|
||||
2. 统一的标准 Git 补丁(Diff)。
|
||||
|
||||
@@ -154,7 +154,7 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
|
||||
|
||||
## 生产实测指标对比
|
||||
|
||||
我们在相同的全栈功能开发任务下,针对传统单 Agent、蜂群多 Agent 以及本套架构进行了实测对比:
|
||||
我们在约 50,000 行代码的 TypeScript/Python 生产代码库中,针对 5 项全栈功能开发任务进行了实测对比(取单任务平均值):
|
||||
|
||||
| 评估指标 | 自由多 Agent 蜂群 | 传统单一 Agent | 单写多审 + 图谱索引架构 |
|
||||
|---|---|---|---|
|
||||
@@ -179,7 +179,7 @@ Anthropic 发布研究表明,在复杂的研究型任务中,一组协同的
|
||||
|
||||
## 想为您的业务落地这套高可靠 AI 自动化方案?
|
||||
|
||||
我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统,帮助业务摆脱繁琐的人工重复劳动与失控的 AI 玩具演示。
|
||||
我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统,帮助业务摆脱繁琐的人工重复劳动与脆弱的实验室级 AI 概念验证(PoC)。
|
||||
|
||||
无论您是希望将日常软件开发自动化、构建坚如磐石的企业级 Agent 协同流水线,还是定制自托管自动化服务:
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ draft: false
|
||||
如果没有现成的网络 API,第二优先级是寻找命令行工具。代码修改、文件处理、日志分析或视频压制,完全可以通过 `terminal`、`git`、`ffmpeg` 或本地脚本静默执行。模型无需移动鼠标,不仅速度快出几十倍,而且不会因界面卡顿而中断。
|
||||
|
||||
### 第三梯队:后台无感无障碍驱动(CUA)
|
||||
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
|
||||
当目标软件是一个图形界面且没有 CLI 时,首选是**后台无感自动化**(例如我们生产 Agent 运行时 Hermes 内置基于 `cua-driver` 的 `computer-use`)。
|
||||
- 指令通过系统底层辅助功能框架(Windows UIA / macOS AX)直接派发给目标控件。
|
||||
- **真人的物理鼠标光标完全不会被移动。**
|
||||
- 窗口不需要前置聚焦。你可以在前台敲代码或写邮件,而 Agent 在后台最小化或未激活的窗口里完成点击。
|
||||
|
||||
@@ -31,7 +31,7 @@ draft: false
|
||||
|
||||
## 2. 架构设计与工程取舍
|
||||
|
||||
在设计这套系统时,我结合现有的家庭基础设施,确立了四项核心原则:
|
||||
在设计这套系统时,我结合我管理的私有基础设施(自托管 GPU 算力节点),确立了四项核心原则:
|
||||
|
||||
```
|
||||
[ 本地收集视频 / 收件箱 ]
|
||||
@@ -44,7 +44,7 @@ draft: false
|
||||
▼
|
||||
[ 严格解耦的数据层 ]
|
||||
├── src/content/videos/{slug}.md (元数据 + AI 结构化精读总结)
|
||||
└── src/data/transcripts/{slug}.json (毫秒级分句逐字稿 [start, end, text])
|
||||
└── src/data/transcripts/{slug}.json (亚秒级分句时间轴 [start, end, text])
|
||||
│
|
||||
▼
|
||||
[ Astro 5 纯静态门户 ] ────► Pagefind 客户端 WASM 离线检索 (<20ms 响应)
|
||||
@@ -61,8 +61,8 @@ draft: false
|
||||
### 本地算力闭环,零云端转写成本
|
||||
不用云端按分钟计费的语音转写 API,流水线直接运行在宿主机的 NVIDIA RTX 3060 显卡上。通过 `faster-whisper` 与 `Silero-VAD` 结合,自动切除无声段,并按标点符号聚合成 3 到 8 秒的自然语义分句。
|
||||
|
||||
### 严禁家庭 NAS 直出公网视频流
|
||||
用自建 NAS 直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,宽带的上行带宽会被瞬间打满,甚至触发运营商对于 PCDN 的封禁。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
|
||||
### 严禁源站存储节点直出公网视频流
|
||||
用自建源站存储节点直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,源站节点的上行带宽会被瞬间打满,甚至触发运营商对于异常流量的管控限制。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
|
||||
|
||||
### 基于 Pagefind 的毫秒级离线搜索
|
||||
前台不维护重型的 Meilisearch 或 Elasticsearch 数据库,直接编译为 Astro 静态站点并利用 Pagefind 构建分片索引。读者在浏览器端搜索时,通过 WebAssembly 按需拉取几十 KB 的分片,零服务器内存常驻,中文关键词检索延迟在 20ms 以内。
|
||||
@@ -85,6 +85,6 @@ draft: false
|
||||
|
||||
如果你的企业或团队也积累了大量内部培训录屏、会议回放或知识资产,需要将其转化为可检索、支持时间戳精读的企业内部知识库:
|
||||
|
||||
- **WhatsApp 直接沟通**:[点击打开 WhatsApp (012-7972969)](https://wa.me/60127972969)
|
||||
- **WhatsApp 直接沟通**:[点击打开 WhatsApp (+60 12-797 2969)](https://wa.me/60127972969)
|
||||
- **电子邮箱**:[[email protected]](mailto:[email protected]?subject=关于视频知识库与内训检索系统的合作咨询)
|
||||
- **官方主页**:[hoelee.com](https://hoelee.com)
|
||||
@@ -14,7 +14,7 @@ banner: /banners/why-your-headless-browser-cant-scrape-everything.jpg
|
||||
|
||||
在 **Carousell** 上,一个无头浏览器把新上架的商品连价格、成色、图片链接一起
|
||||
抓进数据库——毫无问题,稳定跑了几个月。在**闲鱼**上,一模一样的设置,每次都
|
||||
只返回同样三个词:
|
||||
只返回同一句拦截提示:
|
||||
|
||||
```
|
||||
为了保障您的体验,请使用正常浏览器访问闲鱼~
|
||||
@@ -123,7 +123,7 @@ JSON 烤进了 HTML(类似 `__PRELOADED_STATE__` 这种结构)。JavaScript
|
||||
这组对照沉淀成了一条我现在每个项目都会用的规则:**在选工具之前,先判断目标是
|
||||
服务端渲染还是 API 门槛。** 在 Carousell(服务端渲染)上,一个普通的 HTTP 解析器
|
||||
几个月来归档了几千条带价格和图片的商品。在闲鱼(签名 API)上,我诚实地评估了
|
||||
工作量和维护成本之后选择了停下——对一个偶尔手动搜一下就够用的需求来说,这本身
|
||||
工作量和维护成本之后选择了停下——对于低频抽检即可满足的业务需求来说,这本身
|
||||
就是正确的结局。
|
||||
|
||||
无头浏览器**有时候**是对的工具——截图、转 PDF、接管一个人工会话,或者目标本身
|
||||
|
||||
Reference in new issue
Block a user