- commit
- ce46648
- parent
- 458d7cf
- author
- im_wower
- date
- 2026-03-22 01:15:25 +0800 CST
feat(ops): add nginx and cloudflare dns automation helpers
10 files changed,
+1225,
-114
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,48 @@ 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
44 ## result
45
46-- 待填写
47+- 新增 `scripts/ops/baa-conductor.env.example`,把公网域名、VPS 公网 IP、Tailscale `100.x` 回源和 Nginx 安装路径收口到一份可执行 inventory 模板
48+- 新增 `scripts/ops/cloudflare-dns-plan.{mjs,sh}`,可渲染目标 DNS 记录、可选用 Cloudflare GET API 对比现网,并输出预览用的 `curl` shell 脚本,但默认不写线上 DNS
49+- 新增 `scripts/ops/nginx-sync-plan.{mjs,sh}` 与 `ops/nginx/templates/**`,可按 inventory 渲染 Nginx 配置、检查仓库默认配置是否漂移,并打出包含 `deploy-on-vps.sh` 的部署 bundle
50+- `docs/ops/README.md` 已改为 inventory 驱动的运维流程说明,覆盖 DNS 计划、Nginx bundle、VPS 分发、证书与验证步骤
51
52 ## risks
53
54-- 待填写
55+- 本次没有连接真实 Cloudflare zone;`--fetch-current` 和预览 shell 仍需在填入真实 Zone ID / token 后做一次人工核对
56+- 本次没有在真实 VPS 上执行 `deploy-on-vps.sh`、`nginx -t` 或 `systemctl reload nginx`;证书路径、权限和 systemd 服务名仍需线上确认
57+- 真实 inventory 预计放在仓库外,若运维人员绕开 inventory 手工改 Nginx 或 DNS,模板与线上状态仍可能漂移
58
59 ## next_handoff
60
61-- 待填写
62+- 在仓库外复制并填写 `scripts/ops/baa-conductor.env.example`,补上真实 VPS 公网 IP、Cloudflare Zone ID 与证书路径
63+- 导出 `CLOUDFLARE_API_TOKEN` 后运行 `scripts/ops/cloudflare-dns-plan.sh --fetch-current --emit-shell ...`,审阅差异和预览脚本
64+- 运行 `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`
65
66 开始时建议直接把 `status` 改为 `in_progress`。
67
+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`
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+}
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__;
+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
+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+});
+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" "$@"
+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+}
+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+}
+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" "$@"