baa-conductor

git clone 

commit
14ef5c1
parent
4c63a12
author
im_wower
date
2026-03-22 01:24:35 +0800 CST
Merge remote-tracking branch 'origin/feat/T-022-ops-automation' into integration/fourth-wave-20260322
10 files changed,  +1227, -114
M coordination/tasks/T-022-ops-automation.md
+34, -7
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-022
 4 title: Nginx 与 Cloudflare DNS 自动化
 5-status: todo
 6+status: review
 7 branch: feat/T-022-ops-automation
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-008
13 write_scope:
14@@ -64,23 +64,50 @@ updated_at: 2026-03-22
15 
16 ## files_changed
17 
18-- 待填写
19+- `coordination/tasks/T-022-ops-automation.md`
20+- `docs/ops/README.md`
21+- `ops/nginx/templates/baa-conductor.conf.template`
22+- `ops/nginx/templates/includes/direct-node-auth.conf.template`
23+- `scripts/ops/baa-conductor.env.example`
24+- `scripts/ops/cloudflare-dns-plan.mjs`
25+- `scripts/ops/cloudflare-dns-plan.sh`
26+- `scripts/ops/lib/ops-config.mjs`
27+- `scripts/ops/nginx-sync-plan.mjs`
28+- `scripts/ops/nginx-sync-plan.sh`
29 
30 ## commands_run
31 
32-- 待填写
33+- `git worktree add /Users/george/code/baa-conductor-T022 -b feat/T-022-ops-automation 458d7cf`
34+- `npx --yes pnpm install`
35+- `chmod +x scripts/ops/*.sh scripts/ops/*.mjs`
36+- `bash -n scripts/ops/*.sh`
37+- `node --check scripts/ops/cloudflare-dns-plan.mjs`
38+- `node --check scripts/ops/nginx-sync-plan.mjs`
39+- `node --check scripts/ops/lib/ops-config.mjs`
40+- `scripts/ops/cloudflare-dns-plan.sh --env scripts/ops/baa-conductor.env.example`
41+- `scripts/ops/nginx-sync-plan.sh --env scripts/ops/baa-conductor.env.example --check-repo --bundle-dir .tmp/ops/baa-conductor-nginx`
42+- `git diff --check`
43+- `git commit -m "feat(ops): add nginx and cloudflare dns automation helpers"`
44+- `git push -u origin feat/T-022-ops-automation`
45 
46 ## result
47 
48-- 待填写
49+- 新增 `scripts/ops/baa-conductor.env.example`,把公网域名、VPS 公网 IP、Tailscale `100.x` 回源和 Nginx 安装路径收口到一份可执行 inventory 模板
50+- 新增 `scripts/ops/cloudflare-dns-plan.{mjs,sh}`,可渲染目标 DNS 记录、可选用 Cloudflare GET API 对比现网,并输出预览用的 `curl` shell 脚本,但默认不写线上 DNS
51+- 新增 `scripts/ops/nginx-sync-plan.{mjs,sh}` 与 `ops/nginx/templates/**`,可按 inventory 渲染 Nginx 配置、检查仓库默认配置是否漂移,并打出包含 `deploy-on-vps.sh` 的部署 bundle
52+- `docs/ops/README.md` 已改为 inventory 驱动的运维流程说明,覆盖 DNS 计划、Nginx bundle、VPS 分发、证书与验证步骤
53 
54 ## risks
55 
56-- 待填写
57+- 本次没有连接真实 Cloudflare zone;`--fetch-current` 和预览 shell 仍需在填入真实 Zone ID / token 后做一次人工核对
58+- 本次没有在真实 VPS 上执行 `deploy-on-vps.sh`、`nginx -t` 或 `systemctl reload nginx`;证书路径、权限和 systemd 服务名仍需线上确认
59+- 真实 inventory 预计放在仓库外,若运维人员绕开 inventory 手工改 Nginx 或 DNS,模板与线上状态仍可能漂移
60 
61 ## next_handoff
62 
63-- 待填写
64+- 在仓库外复制并填写 `scripts/ops/baa-conductor.env.example`,补上真实 VPS 公网 IP、Cloudflare Zone ID 与证书路径
65+- 导出 `CLOUDFLARE_API_TOKEN` 后运行 `scripts/ops/cloudflare-dns-plan.sh --fetch-current --emit-shell ...`,审阅差异和预览脚本
66+- 运行 `scripts/ops/nginx-sync-plan.sh --env <real-env> --bundle-dir <dir>`,把 bundle 分发到 VPS,先执行 `sudo ./deploy-on-vps.sh`,确认 `nginx -t` 后再显式执行 `sudo ./deploy-on-vps.sh --reload`
67 
68 开始时建议直接把 `status` 改为 `in_progress`。
69 
M docs/ops/README.md
+134, -107
  1@@ -1,142 +1,181 @@
  2-# VPS 与 Nginx 运维说明
  3+# VPS、Nginx 与 Cloudflare DNS 运维
  4 
  5-本目录记录 `baa-conductor` 在 VPS 上的公网入口、Nginx 转发和启用步骤。
  6+本目录收口第四波公网入口的运维步骤。当前方案固定遵守这几个约束:
  7 
  8-## 域名与转发关系
  9+- 公网只暴露 VPS 的 `80/tcp` 与 `443/tcp`
 10+- `mini` / `mac` 回源固定走 Tailscale `100.x` 地址
 11+- 不依赖 `*.ts.net` 的 MagicDNS 名称
 12+- 仓库脚本默认只做 DNS 计划、Nginx 渲染和部署分发,不会直接改线上 DNS
 13+- `deploy-on-vps.sh` 只有显式传 `--reload` 时才会重载 Nginx
 14 
 15-- `conductor.makefile.so`
 16-  - DNS 指向 VPS 公网 IP
 17-  - `Nginx -> upstream conductor_primary`
 18-  - `conductor_primary` 先打到 `mini` 的 `100.71.210.78:4317`
 19-  - `mini` 不可达时回退到 `mac` 的 `100.112.239.13:4317`
 20-- `mini-conductor.makefile.so`
 21-  - DNS 指向同一台 VPS
 22-  - `Nginx -> upstream mini_conductor_direct -> 100.71.210.78:4317`
 23-  - 用于直连 `mini` 做调试、健康检查和手工运维
 24-- `mac-conductor.makefile.so`
 25-  - DNS 指向同一台 VPS
 26-  - `Nginx -> upstream mac_conductor_direct -> 100.112.239.13:4317`
 27-  - 用于直连 `mac` 做调试、健康检查和手工运维
 28-
 29-统一原则:
 30+`control-api.makefile.so` 的 Worker 自定义域仍由 Cloudflare Worker / D1 相关任务管理,不在这里的脚本覆盖范围内。
 31 
 32-- 只有 VPS 对公网暴露 `80/tcp` 与 `443/tcp`
 33-- `mini` 与 `mac` 的 `4317` 只应通过 Tailscale 或 WireGuard 被 VPS 访问
 34-- `conductor.makefile.so` 不加 Basic Auth,留给统一入口使用
 35-- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 默认启用 Basic Auth
 36+## 单一来源 inventory
 37 
 38-## 仓库文件与 VPS 目标路径
 39+本任务把公网域名、VPS 公网 IP、内网 Tailscale `100.x` 和 Nginx 安装路径收口到一份 inventory:
 40 
 41-仓库中的配置与 VPS 上建议路径一一对应:
 42+- [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
 43+- [`scripts/ops/cloudflare-dns-plan.sh`](../../scripts/ops/cloudflare-dns-plan.sh)
 44+- [`scripts/ops/nginx-sync-plan.sh`](../../scripts/ops/nginx-sync-plan.sh)
 45+- [`ops/nginx/templates/baa-conductor.conf.template`](../../ops/nginx/templates/baa-conductor.conf.template)
 46+- [`ops/nginx/templates/includes/direct-node-auth.conf.template`](../../ops/nginx/templates/includes/direct-node-auth.conf.template)
 47 
 48-```text
 49-ops/nginx/baa-conductor.conf                    -> /etc/nginx/sites-available/baa-conductor.conf
 50-ops/nginx/includes/common-proxy.conf           -> /etc/nginx/includes/baa-conductor/common-proxy.conf
 51-ops/nginx/includes/direct-node-auth.conf       -> /etc/nginx/includes/baa-conductor/direct-node-auth.conf
 52-/etc/nginx/sites-enabled/baa-conductor.conf    -> symlink to /etc/nginx/sites-available/baa-conductor.conf
 53-/etc/nginx/.htpasswd-baa-conductor             -> direct-node Basic Auth 凭据
 54-```
 55+推荐做法:
 56 
 57-`ops/nginx/baa-conductor.conf` 里写的是实际 VPS 路径,部署时应直接按这个目录结构放置。
 58+1. 把 `baa-conductor.env.example` 复制到仓库外的私有路径
 59+2. 在那份私有 inventory 里填写真实 VPS 公网 IP、Cloudflare Zone ID 和证书路径
 60+3. 用同一份 inventory 先出 DNS 计划,再渲染 Nginx bundle
 61 
 62-## 前置条件
 63+## 当前域名与内网关系
 64 
 65-部署前确认:
 66+| 公网域名 | Cloudflare DNS 目标 | VPS 上的 Nginx upstream | 内网实际回源 |
 67+| --- | --- | --- | --- |
 68+| `conductor.makefile.so` | VPS 公网 IP | `conductor_primary` | `mini 100.71.210.78:4317` 主,`mac 100.112.239.13:4317` 备 |
 69+| `mini-conductor.makefile.so` | VPS 公网 IP | `mini_conductor_direct` | `100.71.210.78:4317` |
 70+| `mac-conductor.makefile.so` | VPS 公网 IP | `mac_conductor_direct` | `100.112.239.13:4317` |
 71 
 72-1. VPS 已安装 `nginx`
 73-2. VPS 能通过 Tailscale 或 WireGuard 访问:
 74-   - `100.71.210.78:4317`
 75-   - `100.112.239.13:4317`
 76-3. 三个域名都已解析到 VPS 公网 IP
 77-4. TLS 证书已经准备好,或已经决定使用哪种签发方式:
 78-   - Let’s Encrypt:默认路径 `/etc/letsencrypt/live/<hostname>/`
 79-   - Cloudflare Origin Cert:把配置中的证书路径改成实际落盘位置
 80-5. 若要启用 Basic Auth,VPS 已安装 `htpasswd` 所在包:
 81-   - Debian/Ubuntu 通常是 `apache2-utils`
 82+统一原则:
 83 
 84-## 部署与启用步骤
 85+- 三个公网 host 都解析到同一台 VPS
 86+- 只有 VPS 对公网暴露 `80/443`
 87+- `4317` 只允许 VPS 通过 Tailscale 或 WireGuard 访问
 88+- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 默认保留 Basic Auth
 89 
 90-以下步骤假设系统使用 Debian/Ubuntu 风格的 Nginx 目录。
 91+## Cloudflare DNS 计划
 92 
 93-### 1. 安装依赖
 94+### 1. 准备 inventory
 95 
 96 ```bash
 97-sudo apt-get update
 98-sudo apt-get install -y nginx apache2-utils certbot
 99+cp scripts/ops/baa-conductor.env.example ../baa-conductor.ops.env
100+$EDITOR ../baa-conductor.ops.env
101+export CLOUDFLARE_API_TOKEN=...your token...
102 ```
103 
104-如果证书通过 Cloudflare DNS challenge 或其他方式获取,按你的现有流程安装对应插件。
105+注意:
106+
107+- 真实 inventory 建议放仓库外,避免把公网 IP、Zone ID 或 token 变量名混进提交
108+- token 不写进文件,脚本从 `BAA_CF_API_TOKEN_ENV` 指定的环境变量读取
109 
110-### 2. 验证 VPS 到 mini/mac 的内网连通性
111+### 2. 只看目标记录
112 
113 ```bash
114-curl --fail --max-time 3 http://100.71.210.78:4317/healthz
115-curl --fail --max-time 3 http://100.112.239.13:4317/healthz
116+scripts/ops/cloudflare-dns-plan.sh --env ../baa-conductor.ops.env
117 ```
118 
119-任一命令失败时,不要继续启用公网入口,先修好 VPS 到节点的链路。
120+这一步只渲染期望记录,不访问 Cloudflare API。
121 
122-### 3. 准备目录并安装配置
123+### 3. 读取现网并生成变更预览
124 
125 ```bash
126-sudo install -d -m 0755 /etc/nginx/includes/baa-conductor
127-sudo install -m 0644 ops/nginx/includes/common-proxy.conf /etc/nginx/includes/baa-conductor/common-proxy.conf
128-sudo install -m 0644 ops/nginx/includes/direct-node-auth.conf /etc/nginx/includes/baa-conductor/direct-node-auth.conf
129-sudo install -m 0644 ops/nginx/baa-conductor.conf /etc/nginx/sites-available/baa-conductor.conf
130-sudo ln -sfn /etc/nginx/sites-available/baa-conductor.conf /etc/nginx/sites-enabled/baa-conductor.conf
131+scripts/ops/cloudflare-dns-plan.sh \
132+  --env ../baa-conductor.ops.env \
133+  --fetch-current \
134+  --emit-shell .tmp/ops/cloudflare-dns-preview.sh \
135+  --output .tmp/ops/cloudflare-dns-plan.json
136 ```
137 
138-如果默认站点会抢占 `80/443`,按你的发行版习惯移除或禁用默认站点。
139+这一步会:
140+
141+- 用 Cloudflare DNS GET API 读取当前记录
142+- 输出 create / update / delete 计划
143+- 生成一个 `curl` 预览脚本,供人工审阅后再决定是否执行
144+
145+安全边界:
146+
147+- 脚本本身不会发 `POST` / `PATCH` / `DELETE`
148+- `--emit-shell` 只是把预览命令写到文件里,不会自动执行
149+
150+## Nginx 渲染与部署 bundle
151 
152-### 4. 准备直连域名的 Basic Auth
153+### 1. 渲染并打包
154 
155 ```bash
156-sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
157+scripts/ops/nginx-sync-plan.sh \
158+  --env ../baa-conductor.ops.env \
159+  --bundle-dir .tmp/ops/baa-conductor-nginx
160 ```
161 
162-建议:
163+bundle 内会包含:
164+
165+- 渲染后的 `etc/nginx/sites-available/baa-conductor.conf`
166+- 渲染后的 `etc/nginx/includes/baa-conductor/direct-node-auth.conf`
167+- 当前的 `common-proxy.conf`
168+- `inventory-summary.json`
169+- `DEPLOY_COMMANDS.txt`
170+- `deploy-on-vps.sh`
171+
172+### 2. 校验模板是否仍与仓库默认配置一致
173 
174-- 至少给 `mini-conductor.makefile.so` 和 `mac-conductor.makefile.so` 打 Basic Auth
175-- 如果运维来源 IP 固定,再把 allowlist 打开,形成“双保险”
176-- 如果域名经过 Cloudflare 代理,先配置真实 IP 恢复,再使用 `allow/deny`
177+```bash
178+scripts/ops/nginx-sync-plan.sh \
179+  --env scripts/ops/baa-conductor.env.example \
180+  --check-repo
181+```
182 
183-### 5. 准备 TLS 证书
184+这一步用于维护仓库里的默认模板和默认配置,不是线上部署必需步骤。
185 
186-仓库配置默认引用:
187+### 3. 分发到 VPS
188 
189-```text
190-/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem
191-/etc/letsencrypt/live/conductor.makefile.so/privkey.pem
192-/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem
193-/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem
194-/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem
195-/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem
196+```bash
197+rsync -av .tmp/ops/baa-conductor-nginx/ root@YOUR_VPS:/tmp/baa-conductor-nginx/
198+ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh'
199+ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
200 ```
201 
202-两种常见做法:
203+行为说明:
204 
205-- 直接 Let’s Encrypt:在 DNS 已生效且 `80/tcp` 可达时,用 `certbot` 申请三张证书,或一张覆盖三个 SAN 的证书
206-- Cloudflare 代理:改用 Cloudflare Origin Cert,并把 `baa-conductor.conf` 中的路径改成你实际存放的位置
207+- 第一个 `deploy-on-vps.sh` 会安装文件并执行 `nginx -t`,但不会 reload
208+- 只有显式传 `--reload` 时才会执行 `systemctl reload nginx`
209 
210-如果证书文件还不存在,`nginx -t` 会失败;先准备好证书,再启用 `443` 配置。
211+## 前置条件
212+
213+部署前确认:
214+
215+1. VPS 已安装 `nginx`
216+2. VPS 能访问:
217+   - `100.71.210.78:4317`
218+   - `100.112.239.13:4317`
219+3. 三个公网域名都已准备好 DNS 记录
220+4. 证书路径已经与 inventory 一致
221+5. 若启用 Basic Auth,VPS 已安装 `htpasswd`
222 
223-### 6. 做语法检查并启用站点
224+常见依赖:
225 
226 ```bash
227-sudo nginx -t
228-sudo systemctl enable nginx
229-sudo systemctl reload nginx
230+sudo apt-get update
231+sudo apt-get install -y nginx apache2-utils certbot
232 ```
233 
234-首次部署且 Nginx 尚未运行时,把最后一条替换为:
235+若证书通过 Cloudflare DNS challenge 或其他流程签发,按现有方式安装对应插件。
236+
237+## Basic Auth 与证书
238+
239+直连域名默认引用:
240+
241+- `auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor`
242+- `mini-conductor.makefile.so`
243+- `mac-conductor.makefile.so`
244+
245+准备 Basic Auth:
246 
247 ```bash
248-sudo systemctl start nginx
249+sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
250 ```
251 
252+准备证书时,仓库默认按 Let’s Encrypt 路径渲染:
253+
254+- `/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem`
255+- `/etc/letsencrypt/live/conductor.makefile.so/privkey.pem`
256+- `/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem`
257+- `/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem`
258+- `/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem`
259+- `/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem`
260+
261+若改用 Cloudflare Origin Cert,就把 inventory 里的证书根目录改成实际落盘位置,然后重新生成 bundle。
262+
263 ## 上线后验证
264 
265-### 入口与跳转
266+入口与跳转:
267 
268 ```bash
269 curl -I http://conductor.makefile.so
270@@ -145,10 +184,10 @@ curl -I https://conductor.makefile.so/healthz
271 
272 预期:
273 
274-- `http://conductor.makefile.so` 返回 `301` 到 `https://...`
275-- HTTPS 健康检查返回 `200`
276+- `http://conductor.makefile.so` 返回 `301`
277+- `https://conductor.makefile.so/healthz` 返回 `200`
278 
279-### 直连 mini/mac
280+直连 mini/mac:
281 
282 ```bash
283 curl -I https://mini-conductor.makefile.so/healthz
284@@ -158,24 +197,12 @@ curl -u conductor-ops:YOUR_PASSWORD https://mac-conductor.makefile.so/healthz
285 
286 预期:
287 
288-- 未带认证访问 `mini-conductor.makefile.so` 或 `mac-conductor.makefile.so` 返回 `401`
289+- 未带认证访问直连域名返回 `401`
290 - 带正确认证后返回 `200`
291 
292-### 主备切换边界
293-
294-`conductor.makefile.so` 只做入口层 failover:
295-
296-- 能处理 `mini` 网络不可达、连接超时、`502/503/504`
297-- 不能替代 D1 lease 判断
298-- 不能解决 split-brain
299-
300-也就是说,公网入口切到 `mac` 不代表 `mac` 自动获得合法 leader 身份;真正的写权限仍由 conductor 自己校验。
301-
302-## 建议的运维加固
303+## 运维加固
304 
305-- 只对公网开放 `80/tcp` 与 `443/tcp`
306-- 在主机防火墙里明确拒绝公网访问 `4317`
307+- 主机防火墙明确拒绝公网访问 `4317`
308 - `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 至少保留 Basic Auth
309-- 有固定办公出口时,把 allowlist 叠加到 `direct-node-auth.conf`
310-- 域名走 Cloudflare 代理时,限制直连 origin 的来源
311-- 上线后定期执行 `sudo nginx -t` 和一次带认证的健康检查
312+- 有固定办公出口时,在 [`ops/nginx/includes/direct-node-auth.conf`](../../ops/nginx/includes/direct-node-auth.conf) 基础上叠加 allowlist
313+- 域名走 Cloudflare 代理时,先恢复真实客户端 IP,再启用 `allow/deny`
A ops/nginx/templates/baa-conductor.conf.template
+120, -0
  1@@ -0,0 +1,120 @@
  2+# 部署目标:
  3+# - __NGINX_SITE_INSTALL_PATH__
  4+# - __NGINX_SITE_ENABLED_PATH__ -> symlink to sites-available
  5+# - __NGINX_INCLUDE_GLOB__ 由仓库里的 ops/nginx/includes/* 同步过去
  6+#
  7+# 说明:
  8+# - __CONDUCTOR_HOST__ 作为统一入口,走 mini 主、mac 备的 upstream
  9+# - __MINI_DIRECT_HOST__ 与 __MAC_DIRECT_HOST__ 直连单节点 upstream
 10+# - 所有 upstream 都直接写 Tailscale 100.x 地址
 11+# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
 12+# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
 13+# - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
 14+
 15+map $http_upgrade $connection_upgrade {
 16+    default upgrade;
 17+    ''      '';
 18+}
 19+
 20+upstream conductor_primary {
 21+    # mini 主节点,使用 Tailscale IPv4 私网地址回源
 22+    server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__ max_fails=2 fail_timeout=5s;
 23+    # mac 备用节点,使用 Tailscale IPv4 私网地址回源
 24+    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__ backup;
 25+    keepalive 32;
 26+}
 27+
 28+upstream mini_conductor_direct {
 29+    server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__;
 30+    keepalive 16;
 31+}
 32+
 33+upstream mac_conductor_direct {
 34+    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__;
 35+    keepalive 16;
 36+}
 37+
 38+server {
 39+    listen 80;
 40+    listen [::]:80;
 41+    server_name __CONDUCTOR_HOST__ __MINI_DIRECT_HOST__ __MAC_DIRECT_HOST__;
 42+
 43+    return 301 https://$host$request_uri;
 44+}
 45+
 46+server {
 47+    listen 443 ssl http2;
 48+    listen [::]:443 ssl http2;
 49+    server_name __CONDUCTOR_HOST__;
 50+
 51+    ssl_certificate     __CONDUCTOR_CERT_FULLCHAIN__;
 52+    ssl_certificate_key __CONDUCTOR_CERT_KEY__;
 53+    ssl_protocols       TLSv1.2 TLSv1.3;
 54+    ssl_session_cache   shared:BAAConductorTLS:10m;
 55+    ssl_session_timeout 1d;
 56+
 57+    access_log /var/log/nginx/baa-conductor.access.log;
 58+    error_log  /var/log/nginx/baa-conductor.error.log warn;
 59+
 60+    location = /healthz {
 61+        proxy_pass http://conductor_primary/healthz;
 62+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 63+    }
 64+
 65+    location = /readyz {
 66+        proxy_pass http://conductor_primary/readyz;
 67+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 68+    }
 69+
 70+    location = /rolez {
 71+        proxy_pass http://conductor_primary/rolez;
 72+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 73+    }
 74+
 75+    location / {
 76+        proxy_pass http://conductor_primary;
 77+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 78+    }
 79+}
 80+
 81+server {
 82+    listen 443 ssl http2;
 83+    listen [::]:443 ssl http2;
 84+    server_name __MINI_DIRECT_HOST__;
 85+
 86+    ssl_certificate     __MINI_CERT_FULLCHAIN__;
 87+    ssl_certificate_key __MINI_CERT_KEY__;
 88+    ssl_protocols       TLSv1.2 TLSv1.3;
 89+    ssl_session_cache   shared:BAAConductorTLS:10m;
 90+    ssl_session_timeout 1d;
 91+
 92+    access_log /var/log/nginx/baa-conductor-mini.access.log;
 93+    error_log  /var/log/nginx/baa-conductor-mini.error.log warn;
 94+
 95+    location / {
 96+        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
 97+        proxy_pass http://mini_conductor_direct;
 98+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 99+    }
100+}
101+
102+server {
103+    listen 443 ssl http2;
104+    listen [::]:443 ssl http2;
105+    server_name __MAC_DIRECT_HOST__;
106+
107+    ssl_certificate     __MAC_CERT_FULLCHAIN__;
108+    ssl_certificate_key __MAC_CERT_KEY__;
109+    ssl_protocols       TLSv1.2 TLSv1.3;
110+    ssl_session_cache   shared:BAAConductorTLS:10m;
111+    ssl_session_timeout 1d;
112+
113+    access_log /var/log/nginx/baa-conductor-mac.access.log;
114+    error_log  /var/log/nginx/baa-conductor-mac.error.log warn;
115+
116+    location / {
117+        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
118+        proxy_pass http://mac_conductor_direct;
119+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
120+    }
121+}
A ops/nginx/templates/includes/direct-node-auth.conf.template
+10, -0
 1@@ -0,0 +1,10 @@
 2+# 直连 mini/mac 的调试入口默认启用 Basic Auth。
 3+# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
 4+# satisfy any;
 5+# allow 203.0.113.10;
 6+# allow 2001:db8::/48;
 7+# deny all;
 8+# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
 9+
10+auth_basic "baa-conductor direct node";
11+auth_basic_user_file __NGINX_HTPASSWD_PATH__;
A scripts/ops/baa-conductor.env.example
+33, -0
 1@@ -0,0 +1,33 @@
 2+# Copy this file to a private path outside the repo, then fill the real values.
 3+# The committed example keeps the current fourth-wave hostnames and Tailscale 100.x
 4+# upstreams, but uses placeholder public IP and Cloudflare metadata.
 5+
 6+BAA_APP_NAME=baa-conductor
 7+
 8+BAA_CF_ZONE_NAME=makefile.so
 9+BAA_CF_ZONE_ID=REPLACE_WITH_CLOUDFLARE_ZONE_ID
10+BAA_CF_API_TOKEN_ENV=CLOUDFLARE_API_TOKEN
11+BAA_CF_TTL=1
12+BAA_CF_PROXY_CONDUCTOR=true
13+BAA_CF_PROXY_MINI=true
14+BAA_CF_PROXY_MAC=true
15+
16+BAA_PUBLIC_IPV4=203.0.113.10
17+BAA_PUBLIC_IPV6=
18+
19+BAA_CONDUCTOR_HOST=conductor.makefile.so
20+BAA_MINI_DIRECT_HOST=mini-conductor.makefile.so
21+BAA_MAC_DIRECT_HOST=mac-conductor.makefile.so
22+
23+BAA_MINI_TAILSCALE_IP=100.71.210.78
24+BAA_MAC_TAILSCALE_IP=100.112.239.13
25+BAA_CONDUCTOR_PORT=4317
26+
27+BAA_NGINX_SITE_NAME=baa-conductor.conf
28+BAA_NGINX_SITE_INSTALL_DIR=/etc/nginx/sites-available
29+BAA_NGINX_SITE_ENABLED_DIR=/etc/nginx/sites-enabled
30+BAA_NGINX_INCLUDE_DIR=/etc/nginx/includes/baa-conductor
31+BAA_NGINX_HTPASSWD_PATH=/etc/nginx/.htpasswd-baa-conductor
32+BAA_TLS_CERT_ROOT=/etc/letsencrypt/live
33+
34+BAA_BUNDLE_DIR=.tmp/ops/baa-conductor-nginx
A scripts/ops/cloudflare-dns-plan.mjs
+362, -0
  1@@ -0,0 +1,362 @@
  2+#!/usr/bin/env node
  3+
  4+import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
  5+import { dirname, resolve } from "node:path";
  6+import {
  7+  buildDesiredDnsRecords,
  8+  buildHostInventory,
  9+  loadOpsConfig,
 10+  parseCliArgs,
 11+  resolvePath,
 12+} from "./lib/ops-config.mjs";
 13+
 14+const usage = `Usage:
 15+  node scripts/ops/cloudflare-dns-plan.mjs [--env PATH] [--fetch-current] [--format text|json]
 16+                                           [--output PATH] [--emit-shell PATH]
 17+
 18+Behavior:
 19+  - Without --fetch-current, only render the desired DNS records.
 20+  - With --fetch-current, call Cloudflare GET endpoints and diff against the desired records.
 21+  - This tool never sends POST/PATCH/DELETE requests. --emit-shell only writes a preview shell script.`;
 22+
 23+async function main() {
 24+  const args = parseCliArgs(process.argv.slice(2));
 25+
 26+  if (args.help) {
 27+    console.log(usage);
 28+    return;
 29+  }
 30+
 31+  const config = loadOpsConfig(args.env);
 32+  const desiredRecords = buildDesiredDnsRecords(config);
 33+  const hostInventory = buildHostInventory(config);
 34+
 35+  let currentRecords = [];
 36+  let plan = buildDesiredOnlyPlan(desiredRecords);
 37+
 38+  if (args.fetchCurrent) {
 39+    currentRecords = await fetchCurrentRecords(config, hostInventory.map((host) => host.hostname));
 40+    plan = buildDiffPlan(desiredRecords, currentRecords);
 41+  }
 42+
 43+  if (args.emitShell) {
 44+    const shellPath = resolvePath(args.emitShell);
 45+    writePreviewShell(shellPath, config, plan);
 46+  }
 47+
 48+  const outputPayload = {
 49+    env_path: config.envPath,
 50+    zone_name: config.cloudflare.zoneName,
 51+    zone_id: config.cloudflare.zoneId,
 52+    fetch_current: Boolean(args.fetchCurrent),
 53+    desired_records: desiredRecords,
 54+    current_records: currentRecords,
 55+    plan,
 56+  };
 57+
 58+  if (args.output) {
 59+    const outputPath = resolvePath(args.output);
 60+    mkdirSync(resolve(outputPath, ".."), { recursive: true });
 61+    writeFileSync(outputPath, `${JSON.stringify(outputPayload, null, 2)}\n`, "utf8");
 62+  }
 63+
 64+  if (args.format === "json") {
 65+    console.log(JSON.stringify(outputPayload, null, 2));
 66+    return;
 67+  }
 68+
 69+  console.log(formatTextOutput(config, desiredRecords, plan, Boolean(args.fetchCurrent), args.emitShell));
 70+}
 71+
 72+function buildDesiredOnlyPlan(desiredRecords) {
 73+  return desiredRecords.map((record) => ({
 74+    action: "desired",
 75+    target: record,
 76+    reason: "Desired state preview only. Re-run with --fetch-current to diff against Cloudflare.",
 77+  }));
 78+}
 79+
 80+function buildDiffPlan(desiredRecords, currentRecords) {
 81+  const recordsByKey = new Map();
 82+
 83+  for (const record of currentRecords) {
 84+    const key = `${record.type}:${record.name}`;
 85+    const existing = recordsByKey.get(key) ?? [];
 86+    existing.push(record);
 87+    recordsByKey.set(key, existing);
 88+  }
 89+
 90+  const plan = [];
 91+  const handledRecordIds = new Set();
 92+
 93+  for (const desired of desiredRecords) {
 94+    const key = `${desired.type}:${desired.hostname}`;
 95+    const candidates = [...(recordsByKey.get(key) ?? [])];
 96+
 97+    if (candidates.length === 0) {
 98+      plan.push({
 99+        action: "create",
100+        target: desired,
101+        reason: "No existing record matched this hostname and type.",
102+      });
103+      continue;
104+    }
105+
106+    const exactMatchIndex = candidates.findIndex((candidate) => recordMatches(candidate, desired));
107+
108+    if (exactMatchIndex >= 0) {
109+      const [exactMatch] = candidates.splice(exactMatchIndex, 1);
110+      handledRecordIds.add(exactMatch.id);
111+      plan.push({
112+        action: "noop",
113+        current: exactMatch,
114+        target: desired,
115+        reason: "Existing record already matches the desired state.",
116+      });
117+
118+      for (const duplicate of candidates) {
119+        handledRecordIds.add(duplicate.id);
120+        plan.push({
121+          action: "delete",
122+          current: duplicate,
123+          reason: "Duplicate record for the same hostname and type.",
124+        });
125+      }
126+
127+      continue;
128+    }
129+
130+    const [current, ...duplicates] = candidates;
131+    handledRecordIds.add(current.id);
132+
133+    plan.push({
134+      action: "update",
135+      current,
136+      target: desired,
137+      reason: "Hostname and type exist, but content/proxied/ttl differ.",
138+    });
139+
140+    for (const duplicate of duplicates) {
141+      handledRecordIds.add(duplicate.id);
142+      plan.push({
143+        action: "delete",
144+        current: duplicate,
145+        reason: "Duplicate record for the same hostname and type.",
146+      });
147+    }
148+  }
149+
150+  for (const current of currentRecords) {
151+    if (handledRecordIds.has(current.id)) {
152+      continue;
153+    }
154+
155+    plan.push({
156+      action: "delete",
157+      current,
158+      reason: "Managed hostname has no desired record for this type.",
159+    });
160+  }
161+
162+  return plan;
163+}
164+
165+async function fetchCurrentRecords(config, hostnames) {
166+  const token = process.env[config.cloudflare.apiTokenEnv];
167+
168+  if (!token) {
169+    throw new Error(`Missing Cloudflare token in env var ${config.cloudflare.apiTokenEnv}`);
170+  }
171+
172+  if (!config.cloudflare.zoneId || config.cloudflare.zoneId.startsWith("REPLACE_WITH_")) {
173+    throw new Error("Set BAA_CF_ZONE_ID before using --fetch-current.");
174+  }
175+
176+  const uniqueHostnames = Array.from(new Set(hostnames));
177+  const currentRecords = [];
178+
179+  for (const hostname of uniqueHostnames) {
180+    const url = new URL(`https://api.cloudflare.com/client/v4/zones/${config.cloudflare.zoneId}/dns_records`);
181+    url.searchParams.set("name", hostname);
182+    url.searchParams.set("per_page", "100");
183+
184+    const response = await fetch(url, {
185+      headers: {
186+        Authorization: `Bearer ${token}`,
187+        "Content-Type": "application/json",
188+      },
189+    });
190+
191+    if (!response.ok) {
192+      throw new Error(`Cloudflare GET failed for ${hostname}: ${response.status} ${response.statusText}`);
193+    }
194+
195+    const payload = await response.json();
196+
197+    if (!payload.success) {
198+      throw new Error(`Cloudflare GET returned an error for ${hostname}: ${JSON.stringify(payload.errors)}`);
199+    }
200+
201+    for (const result of payload.result) {
202+      if (result.type === "A" || result.type === "AAAA") {
203+        currentRecords.push(result);
204+      }
205+    }
206+  }
207+
208+  return currentRecords;
209+}
210+
211+function recordMatches(current, desired) {
212+  return (
213+    current.type === desired.type &&
214+    current.name === desired.hostname &&
215+    current.content === desired.content &&
216+    Boolean(current.proxied) === Boolean(desired.proxied) &&
217+    Number(current.ttl) === Number(desired.ttl)
218+  );
219+}
220+
221+function writePreviewShell(shellPath, config, plan) {
222+  mkdirSync(dirname(shellPath), { recursive: true });
223+
224+  const lines = [
225+    "#!/usr/bin/env bash",
226+    "set -euo pipefail",
227+    "",
228+    `: "\${${config.cloudflare.apiTokenEnv}:?export ${config.cloudflare.apiTokenEnv} first}"`,
229+    `ZONE_ID=${shellQuote(config.cloudflare.zoneId)}`,
230+    "",
231+    "# Preview script only. Review before running any curl command.",
232+  ];
233+
234+  let emittedCommand = false;
235+
236+  for (const item of plan) {
237+    if (item.action === "create") {
238+      emittedCommand = true;
239+      lines.push(`# ${item.reason}`);
240+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "POST", "$ZONE_ID", null, toPayload(item.target)));
241+      lines.push("");
242+      continue;
243+    }
244+
245+    if (item.action === "update") {
246+      emittedCommand = true;
247+      lines.push(`# ${item.reason}`);
248+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "PATCH", "$ZONE_ID", item.current.id, toPayload(item.target)));
249+      lines.push("");
250+      continue;
251+    }
252+
253+    if (item.action === "delete") {
254+      emittedCommand = true;
255+      lines.push(`# ${item.reason}`);
256+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "DELETE", "$ZONE_ID", item.current.id, null));
257+      lines.push("");
258+    }
259+  }
260+
261+  if (!emittedCommand) {
262+    lines.push("echo 'No create/update/delete operations were generated.'");
263+  }
264+
265+  writeFileSync(shellPath, `${lines.join("\n")}\n`, "utf8");
266+  chmodSync(shellPath, 0o755);
267+}
268+
269+function buildCurlCommand(tokenEnvName, method, zoneIdExpression, recordId, payload) {
270+  const endpoint = recordId
271+    ? `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records/${recordId}`
272+    : `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records`;
273+  const parts = [
274+    `curl -sS -X ${method}`,
275+    `  -H "Authorization: Bearer $${tokenEnvName}"`,
276+    '  -H "Content-Type: application/json"',
277+    `  "${endpoint}"`,
278+  ];
279+
280+  if (payload) {
281+    parts.push(`  --data ${shellQuote(JSON.stringify(payload))}`);
282+  }
283+
284+  return parts.join(" \\\n");
285+}
286+
287+function toPayload(record) {
288+  return {
289+    type: record.type,
290+    name: record.hostname,
291+    content: record.content,
292+    proxied: record.proxied,
293+    ttl: record.ttl,
294+    comment: record.comment,
295+  };
296+}
297+
298+function shellQuote(value) {
299+  return `'${String(value).replace(/'/g, `'\\''`)}'`;
300+}
301+
302+function formatTextOutput(config, desiredRecords, plan, fetchedCurrent, emitShellPath) {
303+  const lines = [];
304+
305+  lines.push(`Zone: ${config.cloudflare.zoneName}`);
306+  lines.push(`Inventory: ${config.envPath}`);
307+  lines.push("");
308+  lines.push("Desired DNS records:");
309+
310+  for (const record of desiredRecords) {
311+    lines.push(`- ${record.type} ${record.hostname} -> ${record.content} proxied=${record.proxied} ttl=${record.ttl}`);
312+  }
313+
314+  lines.push("");
315+  lines.push(fetchedCurrent ? "Diff plan:" : "Plan preview:");
316+
317+  for (const item of plan) {
318+    if (item.action === "desired") {
319+      lines.push(`- desired ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
320+      continue;
321+    }
322+
323+    if (item.action === "noop") {
324+      lines.push(`- noop ${item.target.type} ${item.target.hostname} already ${item.target.content}`);
325+      continue;
326+    }
327+
328+    if (item.action === "create") {
329+      lines.push(`- create ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
330+      continue;
331+    }
332+
333+    if (item.action === "update") {
334+      lines.push(`- update ${item.target.type} ${item.target.hostname}: ${item.current.content} -> ${item.target.content}`);
335+      continue;
336+    }
337+
338+    if (item.action === "delete") {
339+      lines.push(`- delete ${item.current.type} ${item.current.name} -> ${item.current.content}`);
340+    }
341+  }
342+
343+  lines.push("");
344+  lines.push("Safety:");
345+  lines.push("- This tool never writes DNS records by itself.");
346+
347+  if (fetchedCurrent) {
348+    lines.push("- Cloudflare API access was read-only GET for the compared records.");
349+  } else {
350+    lines.push("- Re-run with --fetch-current after exporting the Cloudflare token to compare against live records.");
351+  }
352+
353+  if (emitShellPath) {
354+    lines.push(`- Preview curl script written to ${resolvePath(emitShellPath)}.`);
355+  }
356+
357+  return lines.join("\n");
358+}
359+
360+main().catch((error) => {
361+  console.error(`cloudflare-dns-plan failed: ${error.message}`);
362+  process.exitCode = 1;
363+});
A scripts/ops/cloudflare-dns-plan.sh
+5, -0
1@@ -0,0 +1,5 @@
2+#!/usr/bin/env bash
3+set -euo pipefail
4+
5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+exec node "$SCRIPT_DIR/cloudflare-dns-plan.mjs" "$@"
A scripts/ops/lib/ops-config.mjs
+315, -0
  1@@ -0,0 +1,315 @@
  2+import { existsSync, readFileSync } from "node:fs";
  3+import { dirname, resolve } from "node:path";
  4+import { fileURLToPath } from "node:url";
  5+
  6+const moduleDir = dirname(fileURLToPath(import.meta.url));
  7+export const repoRoot = resolve(moduleDir, "..", "..", "..");
  8+
  9+const defaultEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env");
 10+const exampleEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env.example");
 11+
 12+export function parseCliArgs(argv) {
 13+  const args = { _: [] };
 14+
 15+  for (let index = 0; index < argv.length; index += 1) {
 16+    const token = argv[index];
 17+
 18+    if (!token.startsWith("--")) {
 19+      args._.push(token);
 20+      continue;
 21+    }
 22+
 23+    const trimmed = token.slice(2);
 24+    const separatorIndex = trimmed.indexOf("=");
 25+
 26+    if (separatorIndex >= 0) {
 27+      const key = toCamelCase(trimmed.slice(0, separatorIndex));
 28+      const value = trimmed.slice(separatorIndex + 1);
 29+      args[key] = value;
 30+      continue;
 31+    }
 32+
 33+    const key = toCamelCase(trimmed);
 34+    const nextToken = argv[index + 1];
 35+
 36+    if (nextToken && !nextToken.startsWith("--")) {
 37+      args[key] = nextToken;
 38+      index += 1;
 39+      continue;
 40+    }
 41+
 42+    args[key] = true;
 43+  }
 44+
 45+  return args;
 46+}
 47+
 48+export function resolveOpsEnvPath(inputPath) {
 49+  if (inputPath) {
 50+    return resolvePath(inputPath);
 51+  }
 52+
 53+  if (existsSync(defaultEnvPath)) {
 54+    return defaultEnvPath;
 55+  }
 56+
 57+  return exampleEnvPath;
 58+}
 59+
 60+export function loadOpsConfig(inputPath) {
 61+  const envPath = resolveOpsEnvPath(inputPath);
 62+  const envSource = readFileSync(envPath, "utf8");
 63+  const env = parseEnvFile(envSource);
 64+
 65+  const config = {
 66+    envPath,
 67+    appName: env.BAA_APP_NAME ?? "baa-conductor",
 68+    cloudflare: {
 69+      zoneName: requiredValue(env, "BAA_CF_ZONE_NAME"),
 70+      zoneId: env.BAA_CF_ZONE_ID ?? "",
 71+      apiTokenEnv: env.BAA_CF_API_TOKEN_ENV ?? "CLOUDFLARE_API_TOKEN",
 72+      ttl: parseNumber(env.BAA_CF_TTL ?? "1", "BAA_CF_TTL"),
 73+      proxied: {
 74+        conductor: parseBoolean(env.BAA_CF_PROXY_CONDUCTOR ?? "true", "BAA_CF_PROXY_CONDUCTOR"),
 75+        mini: parseBoolean(env.BAA_CF_PROXY_MINI ?? "true", "BAA_CF_PROXY_MINI"),
 76+        mac: parseBoolean(env.BAA_CF_PROXY_MAC ?? "true", "BAA_CF_PROXY_MAC"),
 77+      },
 78+    },
 79+    vps: {
 80+      publicIpv4: env.BAA_PUBLIC_IPV4 ?? "",
 81+      publicIpv6: env.BAA_PUBLIC_IPV6 ?? "",
 82+    },
 83+    hosts: {
 84+      conductor: requiredValue(env, "BAA_CONDUCTOR_HOST"),
 85+      mini: requiredValue(env, "BAA_MINI_DIRECT_HOST"),
 86+      mac: requiredValue(env, "BAA_MAC_DIRECT_HOST"),
 87+    },
 88+    tailscale: {
 89+      mini: requiredValue(env, "BAA_MINI_TAILSCALE_IP"),
 90+      mac: requiredValue(env, "BAA_MAC_TAILSCALE_IP"),
 91+      port: parseNumber(env.BAA_CONDUCTOR_PORT ?? "4317", "BAA_CONDUCTOR_PORT"),
 92+    },
 93+    nginx: {
 94+      siteName: env.BAA_NGINX_SITE_NAME ?? "baa-conductor.conf",
 95+      siteInstallDir: env.BAA_NGINX_SITE_INSTALL_DIR ?? "/etc/nginx/sites-available",
 96+      siteEnabledDir: env.BAA_NGINX_SITE_ENABLED_DIR ?? "/etc/nginx/sites-enabled",
 97+      includeDir: env.BAA_NGINX_INCLUDE_DIR ?? "/etc/nginx/includes/baa-conductor",
 98+      htpasswdPath: env.BAA_NGINX_HTPASSWD_PATH ?? "/etc/nginx/.htpasswd-baa-conductor",
 99+      tlsCertRoot: env.BAA_TLS_CERT_ROOT ?? "/etc/letsencrypt/live",
100+      bundleDir: env.BAA_BUNDLE_DIR ?? ".tmp/ops/baa-conductor-nginx",
101+    },
102+  };
103+
104+  validateConfig(config);
105+  return config;
106+}
107+
108+export function buildHostInventory(config) {
109+  return [
110+    {
111+      key: "conductor",
112+      hostname: config.hosts.conductor,
113+      proxied: config.cloudflare.proxied.conductor,
114+      description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}, backup mac ${config.tailscale.mac}:${config.tailscale.port}`,
115+    },
116+    {
117+      key: "mini",
118+      hostname: config.hosts.mini,
119+      proxied: config.cloudflare.proxied.mini,
120+      description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}`,
121+    },
122+    {
123+      key: "mac",
124+      hostname: config.hosts.mac,
125+      proxied: config.cloudflare.proxied.mac,
126+      description: `public ingress via VPS -> mac ${config.tailscale.mac}:${config.tailscale.port}`,
127+    },
128+  ];
129+}
130+
131+export function buildDesiredDnsRecords(config) {
132+  const hostInventory = buildHostInventory(config);
133+  const records = [];
134+
135+  for (const host of hostInventory) {
136+    if (config.vps.publicIpv4) {
137+      records.push({
138+        hostname: host.hostname,
139+        type: "A",
140+        content: config.vps.publicIpv4,
141+        proxied: host.proxied,
142+        ttl: config.cloudflare.ttl,
143+        comment: `${config.appName} ${host.description}`,
144+      });
145+    }
146+
147+    if (config.vps.publicIpv6) {
148+      records.push({
149+        hostname: host.hostname,
150+        type: "AAAA",
151+        content: config.vps.publicIpv6,
152+        proxied: host.proxied,
153+        ttl: config.cloudflare.ttl,
154+        comment: `${config.appName} ${host.description}`,
155+      });
156+    }
157+  }
158+
159+  if (records.length === 0) {
160+    throw new Error("No desired DNS records were generated. Set BAA_PUBLIC_IPV4 or BAA_PUBLIC_IPV6.");
161+  }
162+
163+  return records;
164+}
165+
166+export function getNginxTemplateTokens(config) {
167+  return {
168+    "__NGINX_SITE_INSTALL_PATH__": `${config.nginx.siteInstallDir}/${config.nginx.siteName}`,
169+    "__NGINX_SITE_ENABLED_PATH__": `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`,
170+    "__NGINX_INCLUDE_GLOB__": `${config.nginx.includeDir}/*.conf`,
171+    "__CONDUCTOR_HOST__": config.hosts.conductor,
172+    "__MINI_DIRECT_HOST__": config.hosts.mini,
173+    "__MAC_DIRECT_HOST__": config.hosts.mac,
174+    "__MINI_TAILSCALE_IP__": config.tailscale.mini,
175+    "__MAC_TAILSCALE_IP__": config.tailscale.mac,
176+    "__CONDUCTOR_PORT__": String(config.tailscale.port),
177+    "__NGINX_INCLUDE_DIR__": config.nginx.includeDir,
178+    "__NGINX_HTPASSWD_PATH__": config.nginx.htpasswdPath,
179+    "__CONDUCTOR_CERT_FULLCHAIN__": certificatePath(config, config.hosts.conductor, "fullchain.pem"),
180+    "__CONDUCTOR_CERT_KEY__": certificatePath(config, config.hosts.conductor, "privkey.pem"),
181+    "__MINI_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mini, "fullchain.pem"),
182+    "__MINI_CERT_KEY__": certificatePath(config, config.hosts.mini, "privkey.pem"),
183+    "__MAC_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mac, "fullchain.pem"),
184+    "__MAC_CERT_KEY__": certificatePath(config, config.hosts.mac, "privkey.pem"),
185+  };
186+}
187+
188+export function renderTemplate(template, replacements) {
189+  let rendered = template;
190+
191+  for (const [token, value] of Object.entries(replacements)) {
192+    rendered = rendered.replaceAll(token, value);
193+  }
194+
195+  const unresolvedMatches = rendered.match(/__[A-Z0-9_]+__/g);
196+
197+  if (unresolvedMatches) {
198+    throw new Error(`Unresolved template tokens: ${Array.from(new Set(unresolvedMatches)).join(", ")}`);
199+  }
200+
201+  return rendered;
202+}
203+
204+export function buildRenderedNginxArtifacts(config, templates) {
205+  const tokens = getNginxTemplateTokens(config);
206+
207+  return {
208+    siteConf: renderTemplate(templates.siteConf, tokens),
209+    directNodeAuth: renderTemplate(templates.directNodeAuth, tokens),
210+    commonProxy: templates.commonProxy,
211+  };
212+}
213+
214+export function resolvePath(inputPath) {
215+  if (inputPath.startsWith("/")) {
216+    return inputPath;
217+  }
218+
219+  return resolve(process.cwd(), inputPath);
220+}
221+
222+function toCamelCase(value) {
223+  return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
224+}
225+
226+function parseEnvFile(source) {
227+  const env = {};
228+  const lines = source.split(/\r?\n/u);
229+
230+  for (const line of lines) {
231+    const trimmed = line.trim();
232+
233+    if (!trimmed || trimmed.startsWith("#")) {
234+      continue;
235+    }
236+
237+    const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
238+
239+    if (!match) {
240+      throw new Error(`Invalid env line: ${line}`);
241+    }
242+
243+    const [, key, rawValue] = match;
244+    env[key] = stripWrappingQuotes(rawValue.trim());
245+  }
246+
247+  return env;
248+}
249+
250+function stripWrappingQuotes(value) {
251+  if (value.length >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")))) {
252+    return value.slice(1, -1);
253+  }
254+
255+  return value;
256+}
257+
258+function requiredValue(env, key) {
259+  const value = env[key];
260+
261+  if (!value) {
262+    throw new Error(`Missing required env value: ${key}`);
263+  }
264+
265+  return value;
266+}
267+
268+function validateConfig(config) {
269+  validatePublicHostname(config.hosts.conductor, "BAA_CONDUCTOR_HOST");
270+  validatePublicHostname(config.hosts.mini, "BAA_MINI_DIRECT_HOST");
271+  validatePublicHostname(config.hosts.mac, "BAA_MAC_DIRECT_HOST");
272+  validateTailscaleIpv4(config.tailscale.mini, "BAA_MINI_TAILSCALE_IP");
273+  validateTailscaleIpv4(config.tailscale.mac, "BAA_MAC_TAILSCALE_IP");
274+}
275+
276+function parseBoolean(value, key) {
277+  if (value === "true") {
278+    return true;
279+  }
280+
281+  if (value === "false") {
282+    return false;
283+  }
284+
285+  throw new Error(`Invalid boolean for ${key}: ${value}`);
286+}
287+
288+function parseNumber(value, key) {
289+  const parsed = Number(value);
290+
291+  if (!Number.isFinite(parsed)) {
292+    throw new Error(`Invalid number for ${key}: ${value}`);
293+  }
294+
295+  return parsed;
296+}
297+
298+function certificatePath(config, hostname, leafName) {
299+  return `${config.nginx.tlsCertRoot}/${hostname}/${leafName}`;
300+}
301+
302+function validatePublicHostname(value, key) {
303+  if (value.includes(".ts.net")) {
304+    throw new Error(`${key} must be a public hostname, not a MagicDNS name: ${value}`);
305+  }
306+}
307+
308+function validateTailscaleIpv4(value, key) {
309+  if (value.includes(".ts.net")) {
310+    throw new Error(`${key} must use a Tailscale 100.x IPv4 address, not a MagicDNS name: ${value}`);
311+  }
312+
313+  if (!/^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value)) {
314+    throw new Error(`${key} must be a Tailscale 100.x IPv4 address: ${value}`);
315+  }
316+}
A scripts/ops/nginx-sync-plan.mjs
+209, -0
  1@@ -0,0 +1,209 @@
  2+#!/usr/bin/env node
  3+
  4+import {
  5+  chmodSync,
  6+  mkdirSync,
  7+  readFileSync,
  8+  writeFileSync,
  9+} from "node:fs";
 10+import { basename, resolve } from "node:path";
 11+import {
 12+  buildDesiredDnsRecords,
 13+  buildHostInventory,
 14+  buildRenderedNginxArtifacts,
 15+  loadOpsConfig,
 16+  parseCliArgs,
 17+  repoRoot,
 18+  resolvePath,
 19+} from "./lib/ops-config.mjs";
 20+
 21+const usage = `Usage:
 22+  node scripts/ops/nginx-sync-plan.mjs [--env PATH] [--bundle-dir PATH] [--check-repo]
 23+
 24+Behavior:
 25+  - Render the committed Nginx templates from the inventory file.
 26+  - Stage a deploy bundle with deploy-on-vps.sh.
 27+  - --check-repo compares the rendered output against ops/nginx/*.conf in the repo.`;
 28+
 29+function main() {
 30+  const args = parseCliArgs(process.argv.slice(2));
 31+
 32+  if (args.help) {
 33+    console.log(usage);
 34+    return;
 35+  }
 36+
 37+  const config = loadOpsConfig(args.env);
 38+  const bundleDir = resolvePath(args.bundleDir ?? config.nginx.bundleDir);
 39+  const templates = loadTemplates();
 40+  const rendered = buildRenderedNginxArtifacts(config, templates);
 41+  const repoCheck = compareWithRepo(rendered);
 42+
 43+  if (args.checkRepo && !repoCheck.clean) {
 44+    console.error("nginx-sync-plan failed: rendered config drifted from committed ops/nginx artifacts.");
 45+    for (const mismatch of repoCheck.mismatches) {
 46+      console.error(`- ${mismatch}`);
 47+    }
 48+    process.exitCode = 1;
 49+    return;
 50+  }
 51+
 52+  writeBundle(bundleDir, config, rendered);
 53+  printSummary(config, bundleDir, repoCheck);
 54+}
 55+
 56+function loadTemplates() {
 57+  return {
 58+    siteConf: readFileSync(resolve(repoRoot, "ops/nginx/templates/baa-conductor.conf.template"), "utf8"),
 59+    directNodeAuth: readFileSync(resolve(repoRoot, "ops/nginx/templates/includes/direct-node-auth.conf.template"), "utf8"),
 60+    commonProxy: readFileSync(resolve(repoRoot, "ops/nginx/includes/common-proxy.conf"), "utf8"),
 61+  };
 62+}
 63+
 64+function compareWithRepo(rendered) {
 65+  const mismatches = [];
 66+  const repoSiteConf = readFileSync(resolve(repoRoot, "ops/nginx/baa-conductor.conf"), "utf8");
 67+  const repoDirectNodeAuth = readFileSync(resolve(repoRoot, "ops/nginx/includes/direct-node-auth.conf"), "utf8");
 68+
 69+  if (repoSiteConf !== rendered.siteConf) {
 70+    mismatches.push("ops/nginx/baa-conductor.conf");
 71+  }
 72+
 73+  if (repoDirectNodeAuth !== rendered.directNodeAuth) {
 74+    mismatches.push("ops/nginx/includes/direct-node-auth.conf");
 75+  }
 76+
 77+  return {
 78+    clean: mismatches.length === 0,
 79+    mismatches,
 80+  };
 81+}
 82+
 83+function writeBundle(bundleDir, config, rendered) {
 84+  const siteTargetPath = resolve(bundleDir, stripLeadingSlash(config.nginx.siteInstallDir), config.nginx.siteName);
 85+  const includeRoot = resolve(bundleDir, stripLeadingSlash(config.nginx.includeDir));
 86+  const commonProxyPath = resolve(includeRoot, "common-proxy.conf");
 87+  const directNodeAuthPath = resolve(includeRoot, "direct-node-auth.conf");
 88+  const deployScriptPath = resolve(bundleDir, "deploy-on-vps.sh");
 89+  const summaryPath = resolve(bundleDir, "inventory-summary.json");
 90+  const deployCommandsPath = resolve(bundleDir, "DEPLOY_COMMANDS.txt");
 91+
 92+  mkdirSync(resolve(siteTargetPath, ".."), { recursive: true });
 93+  mkdirSync(includeRoot, { recursive: true });
 94+
 95+  writeFileSync(siteTargetPath, rendered.siteConf, "utf8");
 96+  writeFileSync(commonProxyPath, rendered.commonProxy, "utf8");
 97+  writeFileSync(directNodeAuthPath, rendered.directNodeAuth, "utf8");
 98+  writeFileSync(summaryPath, `${JSON.stringify(buildSummary(config), null, 2)}\n`, "utf8");
 99+  writeFileSync(deployCommandsPath, `${buildDeployCommands(bundleDir)}\n`, "utf8");
100+  writeFileSync(deployScriptPath, buildDeployScript(config), "utf8");
101+  chmodSync(deployScriptPath, 0o755);
102+}
103+
104+function buildSummary(config) {
105+  let desiredDnsRecords = [];
106+
107+  try {
108+    desiredDnsRecords = buildDesiredDnsRecords(config);
109+  } catch (error) {
110+    desiredDnsRecords = [{ warning: error.message }];
111+  }
112+
113+  return {
114+    env_path: config.envPath,
115+    public_hosts: buildHostInventory(config),
116+    desired_dns_records: desiredDnsRecords,
117+    tailscale: {
118+      mini: `${config.tailscale.mini}:${config.tailscale.port}`,
119+      mac: `${config.tailscale.mac}:${config.tailscale.port}`,
120+    },
121+    nginx: config.nginx,
122+  };
123+}
124+
125+function buildDeployCommands(bundleDir) {
126+  const bundleName = basename(bundleDir);
127+  return [
128+    "Copy the bundle to the VPS, then run:",
129+    `  rsync -av ${bundleDir}/ root@YOUR_VPS:/tmp/${bundleName}/`,
130+    `  ssh root@YOUR_VPS 'cd /tmp/${bundleName} && sudo ./deploy-on-vps.sh'`,
131+    `  ssh root@YOUR_VPS 'cd /tmp/${bundleName} && sudo ./deploy-on-vps.sh --reload'`,
132+    "",
133+    "The first command installs files and runs nginx -t, but skips reload.",
134+    "The second command is the explicit reload step.",
135+  ].join("\n");
136+}
137+
138+function buildDeployScript(config) {
139+  const siteSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.siteInstallDir)}/${config.nginx.siteName}"`;
140+  const commonProxySource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/common-proxy.conf"`;
141+  const directAuthSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/direct-node-auth.conf"`;
142+  const siteTarget = `${config.nginx.siteInstallDir}/${config.nginx.siteName}`;
143+  const siteEnabledTarget = `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`;
144+  const commonProxyTarget = `${config.nginx.includeDir}/common-proxy.conf`;
145+  const directAuthTarget = `${config.nginx.includeDir}/direct-node-auth.conf`;
146+
147+  return `#!/usr/bin/env bash
148+set -euo pipefail
149+
150+BUNDLE_ROOT="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
151+DO_RELOAD=0
152+
153+if [[ "\${1:-}" == "--reload" ]]; then
154+  DO_RELOAD=1
155+fi
156+
157+install -d -m 0755 ${config.nginx.siteInstallDir}
158+install -d -m 0755 ${config.nginx.siteEnabledDir}
159+install -d -m 0755 ${config.nginx.includeDir}
160+
161+install -m 0644 ${siteSource} "${siteTarget}"
162+install -m 0644 ${commonProxySource} "${commonProxyTarget}"
163+install -m 0644 ${directAuthSource} "${directAuthTarget}"
164+ln -sfn "${siteTarget}" "${siteEnabledTarget}"
165+
166+nginx -t
167+
168+if [[ "$DO_RELOAD" -eq 1 ]]; then
169+  systemctl reload nginx
170+else
171+  echo "Installed bundle and nginx -t passed. Reload skipped."
172+  echo "Run again with: sudo ./deploy-on-vps.sh --reload"
173+fi
174+`;
175+}
176+
177+function printSummary(config, bundleDir, repoCheck) {
178+  const lines = [];
179+
180+  lines.push(`Inventory: ${config.envPath}`);
181+  lines.push(`Bundle: ${bundleDir}`);
182+  lines.push("");
183+  lines.push("Public host mapping:");
184+
185+  for (const host of buildHostInventory(config)) {
186+    lines.push(`- ${host.hostname}: ${host.description}`);
187+  }
188+
189+  lines.push("");
190+  lines.push("Repo drift:");
191+  lines.push(repoCheck.clean ? "- none" : `- mismatches: ${repoCheck.mismatches.join(", ")}`);
192+  lines.push("");
193+  lines.push("Next:");
194+  lines.push(`- Review ${resolve(bundleDir, "inventory-summary.json")}`);
195+  lines.push(`- Copy the bundle to the VPS and run ${resolve(bundleDir, "deploy-on-vps.sh")} there`);
196+  lines.push("- deploy-on-vps.sh only reloads nginx when passed --reload");
197+
198+  console.log(lines.join("\n"));
199+}
200+
201+function stripLeadingSlash(value) {
202+  return value.replace(/^\/+/u, "");
203+}
204+
205+try {
206+  main();
207+} catch (error) {
208+  console.error(`nginx-sync-plan failed: ${error.message}`);
209+  process.exitCode = 1;
210+}
A scripts/ops/nginx-sync-plan.sh
+5, -0
1@@ -0,0 +1,5 @@
2+#!/usr/bin/env bash
3+set -euo pipefail
4+
5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+exec node "$SCRIPT_DIR/nginx-sync-plan.mjs" "$@"