baa-conductor


baa-conductor / scripts / ops / lib
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}