im_wower
·
2026-03-22
ops-config.mjs
1import { existsSync, readFileSync } from "node:fs";
2import { dirname, resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4
5const moduleDir = dirname(fileURLToPath(import.meta.url));
6export const repoRoot = resolve(moduleDir, "..", "..", "..");
7
8const defaultEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env");
9const exampleEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env.example");
10
11export function parseCliArgs(argv) {
12 const args = { _: [] };
13
14 for (let index = 0; index < argv.length; index += 1) {
15 const token = argv[index];
16
17 if (!token.startsWith("--")) {
18 args._.push(token);
19 continue;
20 }
21
22 const trimmed = token.slice(2);
23 const separatorIndex = trimmed.indexOf("=");
24
25 if (separatorIndex >= 0) {
26 const key = toCamelCase(trimmed.slice(0, separatorIndex));
27 const value = trimmed.slice(separatorIndex + 1);
28 args[key] = value;
29 continue;
30 }
31
32 const key = toCamelCase(trimmed);
33 const nextToken = argv[index + 1];
34
35 if (nextToken && !nextToken.startsWith("--")) {
36 args[key] = nextToken;
37 index += 1;
38 continue;
39 }
40
41 args[key] = true;
42 }
43
44 return args;
45}
46
47export function resolveOpsEnvPath(inputPath) {
48 if (inputPath) {
49 return resolvePath(inputPath);
50 }
51
52 if (existsSync(defaultEnvPath)) {
53 return defaultEnvPath;
54 }
55
56 return exampleEnvPath;
57}
58
59export function loadOpsConfig(inputPath) {
60 const envPath = resolveOpsEnvPath(inputPath);
61 const envSource = readFileSync(envPath, "utf8");
62 const env = parseEnvFile(envSource);
63
64 const config = {
65 envPath,
66 appName: env.BAA_APP_NAME ?? "baa-conductor",
67 cloudflare: {
68 zoneName: requiredValue(env, "BAA_CF_ZONE_NAME"),
69 zoneId: env.BAA_CF_ZONE_ID ?? "",
70 apiTokenEnv: env.BAA_CF_API_TOKEN_ENV ?? "CLOUDFLARE_API_TOKEN",
71 ttl: parseNumber(env.BAA_CF_TTL ?? "1", "BAA_CF_TTL"),
72 proxied: {
73 conductor: parseBoolean(env.BAA_CF_PROXY_CONDUCTOR ?? "true", "BAA_CF_PROXY_CONDUCTOR"),
74 },
75 },
76 vps: {
77 publicIpv4: env.BAA_PUBLIC_IPV4 ?? "",
78 publicIpv6: env.BAA_PUBLIC_IPV6 ?? "",
79 },
80 hosts: {
81 conductor: requiredValue(env, "BAA_CONDUCTOR_HOST"),
82 },
83 tailscale: {
84 mini: requiredValue(env, "BAA_MINI_TAILSCALE_IP"),
85 port: parseNumber(env.BAA_CONDUCTOR_PORT ?? "4317", "BAA_CONDUCTOR_PORT"),
86 },
87 nginx: {
88 siteName: env.BAA_NGINX_SITE_NAME ?? "baa-conductor.conf",
89 siteInstallDir: env.BAA_NGINX_SITE_INSTALL_DIR ?? "/etc/nginx/sites-available",
90 siteEnabledDir: env.BAA_NGINX_SITE_ENABLED_DIR ?? "/etc/nginx/sites-enabled",
91 includeDir: env.BAA_NGINX_INCLUDE_DIR ?? "/etc/nginx/includes/baa-conductor",
92 htpasswdPath: env.BAA_NGINX_HTPASSWD_PATH ?? "/etc/nginx/.htpasswd-baa-conductor",
93 tlsCertRoot: env.BAA_TLS_CERT_ROOT ?? "/etc/letsencrypt/live",
94 bundleDir: env.BAA_BUNDLE_DIR ?? ".tmp/ops/baa-conductor-nginx",
95 },
96 };
97
98 validateConfig(config);
99 return config;
100}
101
102export function buildHostInventory(config) {
103 return [
104 {
105 key: "conductor",
106 hostname: config.hosts.conductor,
107 proxied: config.cloudflare.proxied.conductor,
108 description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}`,
109 },
110 ];
111}
112
113export function buildDesiredDnsRecords(config) {
114 const hostInventory = buildHostInventory(config);
115 const records = [];
116
117 for (const host of hostInventory) {
118 if (config.vps.publicIpv4) {
119 records.push({
120 hostname: host.hostname,
121 type: "A",
122 content: config.vps.publicIpv4,
123 proxied: host.proxied,
124 ttl: config.cloudflare.ttl,
125 comment: `${config.appName} ${host.description}`,
126 });
127 }
128
129 if (config.vps.publicIpv6) {
130 records.push({
131 hostname: host.hostname,
132 type: "AAAA",
133 content: config.vps.publicIpv6,
134 proxied: host.proxied,
135 ttl: config.cloudflare.ttl,
136 comment: `${config.appName} ${host.description}`,
137 });
138 }
139 }
140
141 if (records.length === 0) {
142 throw new Error("No desired DNS records were generated. Set BAA_PUBLIC_IPV4 or BAA_PUBLIC_IPV6.");
143 }
144
145 return records;
146}
147
148export function getNginxTemplateTokens(config) {
149 return {
150 "__NGINX_SITE_INSTALL_PATH__": `${config.nginx.siteInstallDir}/${config.nginx.siteName}`,
151 "__NGINX_SITE_ENABLED_PATH__": `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`,
152 "__NGINX_INCLUDE_GLOB__": `${config.nginx.includeDir}/*.conf`,
153 "__CONDUCTOR_HOST__": config.hosts.conductor,
154 "__MINI_TAILSCALE_IP__": config.tailscale.mini,
155 "__CONDUCTOR_PORT__": String(config.tailscale.port),
156 "__NGINX_INCLUDE_DIR__": config.nginx.includeDir,
157 "__CONDUCTOR_CERT_FULLCHAIN__": certificatePath(config, config.hosts.conductor, "fullchain.pem"),
158 "__CONDUCTOR_CERT_KEY__": certificatePath(config, config.hosts.conductor, "privkey.pem"),
159 };
160}
161
162export function renderTemplate(template, replacements) {
163 let rendered = template;
164
165 for (const [token, value] of Object.entries(replacements)) {
166 rendered = rendered.replaceAll(token, value);
167 }
168
169 const unresolvedMatches = rendered.match(/__[A-Z0-9_]+__/g);
170
171 if (unresolvedMatches) {
172 throw new Error(`Unresolved template tokens: ${Array.from(new Set(unresolvedMatches)).join(", ")}`);
173 }
174
175 return rendered;
176}
177
178export function buildRenderedNginxArtifacts(config, templates) {
179 const tokens = getNginxTemplateTokens(config);
180
181 return {
182 siteConf: renderTemplate(templates.siteConf, tokens),
183 commonProxy: templates.commonProxy,
184 };
185}
186
187export function resolvePath(inputPath) {
188 if (inputPath.startsWith("/")) {
189 return inputPath;
190 }
191
192 return resolve(process.cwd(), inputPath);
193}
194
195function toCamelCase(value) {
196 return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
197}
198
199function parseEnvFile(source) {
200 const env = {};
201 const lines = source.split(/\r?\n/u);
202
203 for (const line of lines) {
204 const trimmed = line.trim();
205
206 if (!trimmed || trimmed.startsWith("#")) {
207 continue;
208 }
209
210 const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
211
212 if (!match) {
213 throw new Error(`Invalid env line: ${line}`);
214 }
215
216 const [, key, rawValue] = match;
217 env[key] = stripWrappingQuotes(rawValue.trim());
218 }
219
220 return env;
221}
222
223function stripWrappingQuotes(value) {
224 if (value.length >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")))) {
225 return value.slice(1, -1);
226 }
227
228 return value;
229}
230
231function requiredValue(env, key) {
232 const value = env[key];
233
234 if (!value) {
235 throw new Error(`Missing required env value: ${key}`);
236 }
237
238 return value;
239}
240
241function validateConfig(config) {
242 validatePublicHostname(config.hosts.conductor, "BAA_CONDUCTOR_HOST");
243 validateTailscaleIpv4(config.tailscale.mini, "BAA_MINI_TAILSCALE_IP");
244}
245
246function parseBoolean(value, key) {
247 if (value === "true") {
248 return true;
249 }
250
251 if (value === "false") {
252 return false;
253 }
254
255 throw new Error(`Invalid boolean for ${key}: ${value}`);
256}
257
258function parseNumber(value, key) {
259 const parsed = Number(value);
260
261 if (!Number.isFinite(parsed)) {
262 throw new Error(`Invalid number for ${key}: ${value}`);
263 }
264
265 return parsed;
266}
267
268function certificatePath(config, hostname, leafName) {
269 return `${config.nginx.tlsCertRoot}/${hostname}/${leafName}`;
270}
271
272function validatePublicHostname(value, key) {
273 if (value.includes(".ts.net")) {
274 throw new Error(`${key} must be a public hostname, not a MagicDNS name: ${value}`);
275 }
276}
277
278function validateTailscaleIpv4(value, key) {
279 if (value.includes(".ts.net")) {
280 throw new Error(`${key} must use a Tailscale 100.x IPv4 address, not a MagicDNS name: ${value}`);
281 }
282
283 if (!/^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value)) {
284 throw new Error(`${key} must be a Tailscale 100.x IPv4 address: ${value}`);
285 }
286}