fbi-proxy 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/setup.ts ADDED
@@ -0,0 +1,374 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import path from "node:path";
5
+ import yargs from "yargs";
6
+ import { getFbiProxyBinary } from "./buildFbiProxy";
7
+
8
+ const ANCHOR_NAME = "com.snomiao.fbi-proxy";
9
+ const ANCHOR_FILE = `/etc/pf.anchors/${ANCHOR_NAME}`;
10
+ const PF_PLIST = `/Library/LaunchDaemons/${ANCHOR_NAME}-pf.plist`;
11
+ const PF_CONF = "/etc/pf.conf";
12
+ const PF_CONF_MARKER_BEGIN = `# >>> ${ANCHOR_NAME} (managed by fbi-proxy setup) >>>`;
13
+ const PF_CONF_MARKER_END = `# <<< ${ANCHOR_NAME} <<<`;
14
+ const OXMGR_NAME = "fbi-proxy";
15
+
16
+ export interface SetupContext {
17
+ originalCwd: string;
18
+ }
19
+
20
+ export async function runSetup(
21
+ args: string[],
22
+ ctx: SetupContext,
23
+ ): Promise<void> {
24
+ if (process.platform !== "darwin") {
25
+ console.error(
26
+ "[setup] macOS only. On other OSes run `fbi-proxy --tls --domain <x>` directly.",
27
+ );
28
+ process.exit(2);
29
+ }
30
+
31
+ const argv = await yargs(args)
32
+ .scriptName("fbi-proxy setup")
33
+ .usage(
34
+ "$0 [options]\n\nConfigure fbi-proxy so https://<domain>/ works naturally.",
35
+ )
36
+ .option("domain", { type: "string", default: "fbi.com" })
37
+ .option("port", {
38
+ type: "number",
39
+ default: 8443,
40
+ description: "Backend port (oxmgr-managed daemon)",
41
+ })
42
+ .option("public-port", {
43
+ type: "number",
44
+ default: 443,
45
+ description: "Public port pf redirects from",
46
+ })
47
+ .option("uninstall", {
48
+ type: "boolean",
49
+ default: false,
50
+ description: "Tear down everything this command set up",
51
+ })
52
+ .help().argv;
53
+
54
+ if (argv.uninstall) return uninstall();
55
+
56
+ const domain = argv.domain;
57
+ const port = argv.port;
58
+ const publicPort = argv["public-port"];
59
+
60
+ console.log(
61
+ `[setup] target: https://${domain}/ → oxmgr proxy :${port}, pf :${publicPort}→:${port}`,
62
+ );
63
+
64
+ const binary = await getFbiProxyBinary({ originalCwd: ctx.originalCwd });
65
+ const absBinary = path.isAbsolute(binary) ? binary : path.resolve(binary);
66
+ console.log(`[setup] binary: ${absBinary}`);
67
+
68
+ const home = process.env.HOME!;
69
+ const certDir =
70
+ process.env.FBI_PROXY_CERT_DIR ??
71
+ path.join(home, ".config/fbi-proxy/certs");
72
+ const certPath = path.join(certDir, `${domain}.pem`);
73
+
74
+ // 1. Reinstall oxmgr daemon (idempotent — delete is best-effort)
75
+ spawnSync("oxmgr", ["delete", OXMGR_NAME], { stdio: "ignore" });
76
+ const startResult = spawnSync(
77
+ "oxmgr",
78
+ [
79
+ "start",
80
+ "--name",
81
+ OXMGR_NAME,
82
+ "--restart",
83
+ "always",
84
+ "--cwd",
85
+ path.dirname(absBinary),
86
+ "--env",
87
+ `HOME=${home}`,
88
+ "--env",
89
+ `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
90
+ "--env",
91
+ `FBI_PROXY_CERT_DIR=${certDir}`,
92
+ `${absBinary} --tls --domain ${domain} --port ${port}`,
93
+ ],
94
+ { stdio: "inherit" },
95
+ );
96
+ if (startResult.status !== 0) {
97
+ console.error(
98
+ "[setup] oxmgr start failed — is `oxmgr` installed? (`npm i -g oxmgr` or `brew install oxmgr`)",
99
+ );
100
+ process.exit(startResult.status ?? 1);
101
+ }
102
+ // Persist oxmgr across reboots (writes the LaunchAgent if not already)
103
+ spawnSync("oxmgr", ["service", "install"], { stdio: "ignore" });
104
+
105
+ // 2. Wait for daemon listen + cert generation
106
+ process.stdout.write("[setup] waiting for daemon");
107
+ let listening = false;
108
+ for (let i = 0; i < 30; i++) {
109
+ await sleep(500);
110
+ const probe = spawnSync(
111
+ "sh",
112
+ [
113
+ "-c",
114
+ `curl -sk --max-time 1 https://127.0.0.1:${port}/ -o /dev/null && echo ok`,
115
+ ],
116
+ { stdio: ["ignore", "pipe", "ignore"] },
117
+ );
118
+ if (probe.stdout?.toString().includes("ok") && existsSync(certPath)) {
119
+ listening = true;
120
+ break;
121
+ }
122
+ process.stdout.write(".");
123
+ }
124
+ process.stdout.write("\n");
125
+ if (!listening) {
126
+ console.error(
127
+ `[setup] daemon did not come up on :${port} — check \`oxmgr logs ${OXMGR_NAME}\``,
128
+ );
129
+ process.exit(1);
130
+ }
131
+
132
+ // 3. Single root batch for cert trust + pf forward (osascript GUI prompt)
133
+ const certTrusted = isMacosCertTrusted(certPath);
134
+ const pfActive = isPfRuleActive(publicPort, port);
135
+ if (certTrusted && pfActive) {
136
+ console.log("[setup] cert already trusted, pf forward already active");
137
+ } else {
138
+ const todo = [
139
+ !certTrusted && "install cert to system trust",
140
+ !pfActive && `install pf forward :${publicPort}→:${port}`,
141
+ ]
142
+ .filter(Boolean)
143
+ .join(" + ");
144
+ console.log(`[setup] root needs to: ${todo}`);
145
+ const script = buildRootBatch({
146
+ certTrusted,
147
+ pfActive,
148
+ certPath,
149
+ publicPort,
150
+ port,
151
+ });
152
+ if (!runAsRoot(script)) {
153
+ console.error("[setup] root step failed (cert / pf install)");
154
+ process.exit(1);
155
+ }
156
+ }
157
+
158
+ // 4. DNS check (warn, don't fix — /etc/hosts vs dnsmasq vs resolver is local choice)
159
+ if (!resolvesToLoopback(domain)) {
160
+ console.log(`[setup] WARNING: ${domain} does not resolve to 127.0.0.1.`);
161
+ console.log(
162
+ " Quickfix: echo '127.0.0.1 " + domain + "' | sudo tee -a /etc/hosts",
163
+ );
164
+ }
165
+
166
+ // 5. End-to-end check
167
+ const e2e = spawnSync(
168
+ "sh",
169
+ ["-c", `curl -sf --max-time 5 https://${domain}/ -o /dev/null && echo ok`],
170
+ { stdio: ["ignore", "pipe", "ignore"] },
171
+ );
172
+ if (e2e.stdout?.toString().includes("ok")) {
173
+ console.log(`[setup] ✓ https://${domain}/ reachable with trusted cert`);
174
+ } else {
175
+ console.log(
176
+ `[setup] daemon up on :${port}, cert trusted, pf rule installed.`,
177
+ );
178
+ console.log(
179
+ `[setup] https://${domain}/ end-to-end check failed — most likely DNS (see warning above).`,
180
+ );
181
+ }
182
+ }
183
+
184
+ async function uninstall(): Promise<void> {
185
+ console.log("[setup] uninstalling fbi-proxy daemon + pf forward…");
186
+ spawnSync("oxmgr", ["delete", OXMGR_NAME], { stdio: "inherit" });
187
+ // sed deletes from BEGIN marker through END marker inclusive (BSD sed: -i ''
188
+ // for in-place). pfctl reload picks up the change.
189
+ const script = [
190
+ `launchctl unload ${PF_PLIST} 2>/dev/null || true`,
191
+ `rm -f ${PF_PLIST} ${ANCHOR_FILE}`,
192
+ `/usr/bin/sed -i '' '/${escSed(PF_CONF_MARKER_BEGIN)}/,/${escSed(PF_CONF_MARKER_END)}/d' ${PF_CONF}`,
193
+ `/sbin/pfctl -f ${PF_CONF} 2>/dev/null || true`,
194
+ "echo uninstalled",
195
+ ].join("\n");
196
+ runAsRoot(script);
197
+ console.log(
198
+ "[setup] done. Cert remains trusted (remove via Keychain Access if desired).",
199
+ );
200
+ }
201
+
202
+ function escSed(s: string): string {
203
+ return s.replace(/[\\/.*[\]^$]/g, "\\$&");
204
+ }
205
+
206
+ function buildRootBatch(opts: {
207
+ certTrusted: boolean;
208
+ pfActive: boolean;
209
+ certPath: string;
210
+ publicPort: number;
211
+ port: number;
212
+ }): string {
213
+ const parts: string[] = [];
214
+ if (!opts.certTrusted) {
215
+ parts.push(
216
+ `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${shellQuote(opts.certPath)}`,
217
+ );
218
+ }
219
+ if (!opts.pfActive) {
220
+ const anchorBody = `rdr pass on lo0 inet proto tcp from any to any port ${opts.publicPort} -> 127.0.0.1 port ${opts.port}\n`;
221
+ const plistBody = pfLaunchDaemonPlist();
222
+ parts.push(heredoc(ANCHOR_FILE, anchorBody));
223
+ parts.push(`chmod 644 ${ANCHOR_FILE}`);
224
+ parts.push(heredoc(PF_PLIST, plistBody));
225
+ parts.push(`chown root:wheel ${PF_PLIST}`);
226
+ parts.push(`chmod 644 ${PF_PLIST}`);
227
+ // Reference our anchor from /etc/pf.conf — without this the rules in the
228
+ // anchor file are dormant. pf is strict about ordering: translation
229
+ // anchors (rdr-anchor) MUST appear before filtering anchors. Apple's
230
+ // default pf.conf already has `rdr-anchor "com.apple/*"` followed later
231
+ // by `anchor "com.apple/*"`, so we insert ours immediately after Apple's
232
+ // rdr-anchor line. Always strip any prior fbi-proxy block first so a
233
+ // re-run repairs a wrong-position entry from older setup versions.
234
+ parts.push(
235
+ `/usr/bin/sed -i '' '/^# >>> ${escSed(ANCHOR_NAME)}/,/^# <<< ${escSed(ANCHOR_NAME)}/d' ${PF_CONF}`,
236
+ );
237
+ parts.push(
238
+ `cat > /tmp/fbi-proxy-pf.awk <<'__FBI_SETUP_EOF__'\n` +
239
+ `/^rdr-anchor "com\\.apple/ && !done {\n` +
240
+ ` print\n` +
241
+ ` print "${PF_CONF_MARKER_BEGIN}"\n` +
242
+ ` print "rdr-anchor \\"${ANCHOR_NAME}\\""\n` +
243
+ ` print "load anchor \\"${ANCHOR_NAME}\\" from \\"${ANCHOR_FILE}\\""\n` +
244
+ ` print "${PF_CONF_MARKER_END}"\n` +
245
+ ` done = 1\n` +
246
+ ` next\n` +
247
+ `}\n` +
248
+ `{ print }\n` +
249
+ `__FBI_SETUP_EOF__`,
250
+ );
251
+ parts.push(
252
+ `/usr/bin/awk -f /tmp/fbi-proxy-pf.awk ${PF_CONF} > /tmp/pf.conf.fbi-proxy.new`,
253
+ );
254
+ parts.push(`mv /tmp/pf.conf.fbi-proxy.new ${PF_CONF}`);
255
+ parts.push(`rm -f /tmp/fbi-proxy-pf.awk`);
256
+ // Sanity-check the new pf.conf before applying (dry-run parse). If it
257
+ // fails, leave pf in its prior state rather than wiping the ruleset.
258
+ parts.push(`/sbin/pfctl -nf ${PF_CONF}`);
259
+ parts.push(`launchctl unload ${PF_PLIST} 2>/dev/null || true`);
260
+ parts.push(`launchctl load -w ${PF_PLIST}`);
261
+ // -E enables pf (idempotent, returns a token if it flips state), then
262
+ // reload the main ruleset so our newly-added anchor reference is picked
263
+ // up. The LaunchDaemon does the same at boot.
264
+ parts.push(`/sbin/pfctl -E 2>/dev/null || true`);
265
+ parts.push(`/sbin/pfctl -f ${PF_CONF}`);
266
+ }
267
+ parts.push("echo OK");
268
+ return parts.join("\n");
269
+ }
270
+
271
+ function heredoc(filePath: string, body: string): string {
272
+ // Single-quoted heredoc tag → body is literal (no shell expansion)
273
+ return `cat > ${filePath} <<'__FBI_SETUP_EOF__'\n${body}__FBI_SETUP_EOF__`;
274
+ }
275
+
276
+ function pfLaunchDaemonPlist(): string {
277
+ return `<?xml version="1.0" encoding="UTF-8"?>
278
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
279
+ <plist version="1.0">
280
+ <dict>
281
+ <key>Label</key><string>${ANCHOR_NAME}-pf</string>
282
+ <key>ProgramArguments</key>
283
+ <array>
284
+ <string>/bin/sh</string>
285
+ <string>-c</string>
286
+ <string>/sbin/pfctl -E 2>/dev/null; /sbin/pfctl -f ${PF_CONF}</string>
287
+ </array>
288
+ <key>RunAtLoad</key><true/>
289
+ <key>StandardOutPath</key><string>/var/log/${ANCHOR_NAME}-pf.out.log</string>
290
+ <key>StandardErrorPath</key><string>/var/log/${ANCHOR_NAME}-pf.err.log</string>
291
+ </dict>
292
+ </plist>
293
+ `;
294
+ }
295
+
296
+ function isMacosCertTrusted(certPath: string): boolean {
297
+ if (!existsSync(certPath)) return false;
298
+ const result = spawnSync("security", ["verify-cert", "-c", certPath], {
299
+ stdio: "ignore",
300
+ });
301
+ return result.status === 0;
302
+ }
303
+
304
+ function isPfRuleActive(from: number, to: number): boolean {
305
+ if (!existsSync(ANCHOR_FILE) || !existsSync(PF_PLIST)) return false;
306
+ let conf = "";
307
+ try {
308
+ conf = readFileSync(PF_CONF, "utf8");
309
+ } catch {
310
+ return false;
311
+ }
312
+ const markerIdx = conf.indexOf(PF_CONF_MARKER_BEGIN);
313
+ if (markerIdx < 0) return false;
314
+ // pf requires translation anchors (rdr-anchor) to precede filter anchors
315
+ // (anchor). If our block was appended at the end of pf.conf (older setup
316
+ // version, or hand-edit) it sits after `anchor "com.apple/*"` and the
317
+ // entire ruleset fails to parse. Treat that as "not installed" so the
318
+ // install path repairs it.
319
+ // Match the filter anchor at line-start so we don't accidentally hit
320
+ // `scrub-anchor "com.apple/*"` or `rdr-anchor "com.apple/*"` (substring of
321
+ // which is `anchor "com.apple/*"`).
322
+ const filterMatch = conf.match(/^anchor "com\.apple\/\*"$/m);
323
+ const filterIdx = filterMatch?.index ?? -1;
324
+ if (filterIdx >= 0 && markerIdx > filterIdx) return false;
325
+ // Best-effort live-ruleset check; if sudo needs a password we trust the
326
+ // on-disk state since the LaunchDaemon should have loaded it.
327
+ const probe = spawnSync(
328
+ "sudo",
329
+ ["-n", "/sbin/pfctl", "-a", ANCHOR_NAME, "-s", "nat"],
330
+ {
331
+ stdio: ["ignore", "pipe", "ignore"],
332
+ },
333
+ );
334
+ if (probe.status !== 0) return true;
335
+ const out = probe.stdout?.toString() ?? "";
336
+ return out.includes(`port = ${from}`) && out.includes(`port ${to}`);
337
+ }
338
+
339
+ function resolvesToLoopback(domain: string): boolean {
340
+ const result = spawnSync("dig", ["+short", "+time=1", "+tries=1", domain], {
341
+ stdio: ["ignore", "pipe", "ignore"],
342
+ });
343
+ return (result.stdout?.toString() ?? "").trim().startsWith("127.0.0.1");
344
+ }
345
+
346
+ function runAsRoot(script: string): boolean {
347
+ if (process.getuid?.() === 0) {
348
+ return spawnSync("sh", ["-c", script], { stdio: "inherit" }).status === 0;
349
+ }
350
+ const hasTty = !!process.stdin.isTTY;
351
+ if (hasTty) {
352
+ console.log("[setup] (terminal sudo — enter password)");
353
+ return (
354
+ spawnSync("sudo", ["sh", "-c", script], { stdio: "inherit" }).status === 0
355
+ );
356
+ }
357
+ console.log("[setup] (opening macOS auth dialog — enter password)");
358
+ const prompt =
359
+ "fbi-proxy setup needs administrator access to install its HTTPS " +
360
+ "certificate into the system trust store and add a pf rule forwarding " +
361
+ "port 443 to the local proxy.";
362
+ const wrapped = `do shell script ${appleScriptQuote(script)} with prompt ${appleScriptQuote(prompt)} with administrator privileges`;
363
+ return (
364
+ spawnSync("osascript", ["-e", wrapped], { stdio: "inherit" }).status === 0
365
+ );
366
+ }
367
+
368
+ function shellQuote(s: string): string {
369
+ return `'${s.replace(/'/g, `'\\''`)}'`;
370
+ }
371
+
372
+ function appleScriptQuote(s: string): string {
373
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
374
+ }