diff --git a/docs/content-guide.md b/docs/content-guide.md index 4f08dd3..1d7056f 100644 --- a/docs/content-guide.md +++ b/docs/content-guide.md @@ -103,7 +103,7 @@ draft: false ## 8. i18n policy - **English is primary and non-negotiable** — the whole SEO strategy targets English queries. -- **Chinese (zh):** translate only the 2–3 best case studies. Cheap differentiation, opens zh-SG/zh-MY search. +- **Chinese (zh): translate every post.** Same filename in `posts/zh/` (auto language-switch). Opens zh-SG/zh-MY search and covers bilingual recruiters. - **Malay: skip for v1** — no dev-audience demand (the main site already has a dangling Malay config; don't repeat it). ### File layout (enforced by code) diff --git a/docs/post-guideline.md b/docs/post-guideline.md index fb4f55f..6715527 100644 --- a/docs/post-guideline.md +++ b/docs/post-guideline.md @@ -96,9 +96,9 @@ When you finish a difficult piece of work, use this shape — it's simultaneousl ## 8. i18n sync — Chinese translation (do this automatically, every post) -The DigiKedai case study established the standard: **flagship `case-studies` posts get a Chinese translation automatically.** When writing or updating a post, apply this flow without being asked: +Every post gets a Chinese translation, **every time**, without being asked: -1. **Translate flagship case studies.** Any `category: case-studies` post gets a Chinese version at `src/content/posts/zh/.md` — **same filename**, so the language switcher auto-links the two (no `translation:` field needed). Do NOT translate `notes`, quick tutorials, or tool roundups unless the user asks. +1. **Translate every post.** Any new post gets a Chinese version at `src/content/posts/zh/.md` — **same filename**, so the language switcher auto-links the two (no `translation:` field needed). 2. **Keep EN and ZH in sync.** If you edit the English post (add a section, fix a fact, update a link), make the **same edit** to the Chinese version in the same commit. Never let the two versions drift. 3. **Translate the *frontmatter* too** — title and description go to Chinese, but `category`, `tags`, and `pubDate` stay identical to the English post (they're data, not prose). 4. **Code/commands stay in English** inside the Chinese post (see §6) — only the prose around them is translated. diff --git a/src/content/posts/zh/authentik-major-upgrade-gotchas.md b/src/content/posts/zh/authentik-major-upgrade-gotchas.md new file mode 100644 index 0000000..5dbc43f --- /dev/null +++ b/src/content/posts/zh/authentik-major-upgrade-gotchas.md @@ -0,0 +1,169 @@ +--- +title: "Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分" +description: "一年份的 authentik 大版本升级踩坑记录:存储挂载变更、RBAC 会话清理、可信代理,以及 authorization_flow 与 authentication_flow 搞混导致 SSO 彻底瘫痪。" +pubDate: 2026-09-09 +category: devops +tags: [authentik, sso, docker, portainer, self-hosting, oidc, upgrade] +--- + +我把 authentik 当作自托管体系前面的单点登录闸门——邮箱、仪表盘、Synology +应用、远程访问 outpost 都在它后面。很长一段时间它停在 **2025.8.3**,一年的 +版本更新堆积成山。这篇文章记录我是怎么一口气把它升到 **2026.8.1** 的,以及 +沿途每一步坏掉的地方——尤其是我犯的那个让单点登录彻底瘫痪、逼每个内部 +应用都重新要密码的错误。 + +## 为什么要升级 + +2025.8.3 本身没坏。但它落后太久,后面的版本里已经累积了一堆 CVE 漏洞修复, +而且我正准备做按应用区分品牌。authentik 自己的策略是不允许跨大版本跳级—— +它强制要求逐级走。所以计划是: + +``` +2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8 +``` + +六跳,一次一步,每步之间跑一次迁移 + 一次健康检查。动手之前,有一个绝对 +不能省的步骤:**备份数据库**。authentik 不支持降级。如果迁移只跑了一半, +你是从备份恢复,而不是回滚镜像 tag。 + +```bash +sudo docker exec authentik-postgres pg_dump -U authentik authentik > authentik-backup.sql +``` + +## 坑 1:Portainer 才是真相源,不是 compose 文件 + +我的第一反应是编辑磁盘上的 `docker-compose.yml` 然后 `up`。错的。这个 stack +是由 **Portainer**(stack 143)管理的,真正的 compose 和环境变量都保存在它 +自己的存储里。磁盘上的 `.env` 早就过时了——它的 `PG_PASS` 跟 Portainer 实际 +运行的对不上。 + +正确的更新路径是走 Portainer API,而不是文件系统: + +1. 更新 compose 内容里的镜像 tag。 +2. **先** `docker pull` 新镜像(这样 API 调用不会在拉取中途超时)。 +3. `docker stop` + `docker rm` 正在运行的容器(否则固定的 `container_name` + 会在重新部署时冲突)。 +4. 用新的 `StackFileContent` + `Env` 调 `PUT /api/stacks/143?endpointId=2`。 + +我撞上了容器名冲突错误、拉取超时错误,还有一个网络连接问题——重建后的 +`authentik-server` 只接入了它两个网络里的一个,导致无法解析 `postgres-server`。 +一旦你看懂了门道,每个问题都是五分钟的修复;但凑在一起,耗掉了我大半个晚上。 + +## 坑 2:存储挂载迁移了(2025.12) + +在 2025.12 之前,品牌资源放在 `/media/` 下,通过 `/media/...` 提供服务。 +2025.12 之后存储结构变了:文件迁移到 `/data/media` 结构,通过一个新的 +`/files/media/public/?token=...` URL(带 JWT 签名)提供服务。我的容器 +还在挂载 `./media:/media`,所以我一跨过那个版本,每个 logo、favicon、背景图 +全部 404。 + +修复方式就是官方文档里的迁移: + +```bash +mkdir -p data && mv media data/media +``` + +……然后把挂载改成 `./data:/data`。新的文件后端还要求 `/data` 必须是一个真正的 +挂载点才肯工作——我最开始的修复用的是符号链接,结果后端 `is_mount()` 检查 +直接报错 `No file management backend configured`。 + +## 坑 3:RBAC 迁移留下一张被污染的表 + +2025.12 为了 RBAC 重构,删除了旧的 `authentik_core.User_groups` 模型。迁移 +本身跑得很干净,但**旧会话**仍然引用着这个已删除的模型。结果:登录页一直抛 +`LookupError: App 'authentik_core' doesn't have a 'User_groups' model`。 + +不是 `django_session`——那张表是空的。真正的元凶是 authentik 自己的 +`authentik_core_session` 表。清空它(以及其他 session 表)强制所有人重新登录, +错误也随之消失: + +```sql +TRUNCATE authentik_core_session; +``` + +有个副作用值得知道:这也会让你各应用持有的所有 OIDC refresh token 失效。它们 +会把你弹去重新登录一次,然后恢复。这是一次性的麻烦,不是 bug。 + +## 坑 4:可信代理现在变成了显式选择(2026.8) + +2026.8 收紧了默认的转发头处理。之前 authentik 信任所有内网段;现在它只信任 +你显式列出的那些。在转发到 `localhost` 的 Synology 反向代理后面,意味着: + +```yaml +environment: + AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: 127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128 +``` + +跳过这一步,代理头就会被拒绝,表现出来的认证失败症状,跟你实际上遇到的问题 +长得几乎完全不像。 + +## 坑 5:那个搞坏 SSO 的——authorization_flow 和 authentication_flow 搞混 + +这是最贵的一个错误,而且如果你要做按应用区分品牌,特别容易搞混。 + +authentik 里的 provider 有**两个** flow 字段,含义完全不同: + +```python +# "未登录用户访问关联应用时用的认证 flow" ← 登录页 +authentication_flow = models.ForeignKey(...) + +# "授权这个 provider 时用的 flow" ← OAuth 授权确认页 +authorization_flow = models.ForeignKey(...) +``` + +- `authentication_flow` 是**登录页**。你要给每个应用定制标题和背景,应该把 + 各自独立的 flow 放在这里。 +- `authorization_flow` 是**已登录**用户的授权确认(consent)步骤。 + +我想要每个应用不同的背景图,于是给每个应用建了一个 flow,然后把 provider 的 +`authorization_flow` 指向它。瞬间就坏了:一个已经认证的用户,每次打开另一个 +应用,还是会被拖过完整的识别 → 密码 → MFA 流程。单点登录,实际上等于没了。 + +修复就是一条 UPDATE,把两个字段放回它们该在的位置: + +```sql +UPDATE authentik_core_provider +SET authorization_flow_id = '1d85b1b1-...', -- explicit-consent flow + authentication_flow_id = 'aa##-per-app-flow' +WHERE ...; +``` + +而品牌本身——按应用区分的背景和标题——是放在 **flow 自身**的 `background` 和 +`title` 字段上(2026.8 起可写),而不是放在 brand 的域名匹配上。 + +这个坑后面还藏着一个相关的坑。我创建那 18 个按应用区分的 flow 时,它们全都 +落成 `designation=authentication`,而我的 `auth.hoelee.com` brand 的 +`flow_authentication` 是 `NULL`。authentik 在一个 brand 没有显式认证 flow 时的 +回退逻辑,是**按 slug 字母序**选第一个 authentication flow——碰巧就是 +`auth-agent`,而不是 `default-authentication-flow`。于是根登录页开始显示我 +agent 的背景图。把 brand 的 `flow_authentication` 设成真正的默认 flow 才修好。 + +还有一个跟 RBAC 坑叠加的问题:创建 flow 会设置它的 `background`,但**不会设置 +它的 stages**——`stages` 在 flow 对象上是只读的。一个没有任何 stage 绑定的空 +flow,正是迁移早期登录页无限重定向循环的罪魁祸首。stage 绑定是单独创建的: + +``` +POST /api/v3/flows/bindings/ # { target: "", stage: "", order: N } +``` + +## 我会怎么做得不一样 + +整场折腾归结为三个本可避免的模式: + +1. **绝不要靠猜字段的语义**——我把 `authorization_flow` 当成了「登录 flow」, + 而真相源是模型定义,字段自己的 docstring 里就把区别写得明明白白。 +2. **跟 API 表面保持距离**——每次服务器重启,API token 都中途过期,结果关键 + 修复我直接用 `psql` 对着数据库做了。可靠,但应该在集群着火**之前**就脚本化, + 而不是着火的时候。 +3. **每次重启只处理一个破坏性变更**——我试图一口气推理存储、RBAC、代理的 + 全部变化。每一个如果单独隔离、独立验证,都会很简单。 + +## 结果 + +authentik 现在跑着 **2026.8.1**——最新、已打补丁、所有容器健康——18 个应用各自 +在登录页显示自己的背景和标题,SSO 在每个子域都正常工作。我日常用的 17 个应用, +从「每次切换应用都要重新登录」回到了「登录一次,畅通无阻」。 + +值得记住的教训:认证基础设施的大版本升级,大概 10% 是「改镜像 tag」,90% 是 +「数据模型、存储结构、代理规则在你脚下全都挪了位」。做好备份,一次走一个版本, +当某个东西表现得完全不合理时,伸手去抓下一份配置之前,先把字段名再念一遍。 \ No newline at end of file