fbi-proxy 1.15.0 → 1.16.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/cli.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
2
4
  import getPort from "get-port";
3
5
  import hotMemo from "hot-memo";
4
6
  import path from "path";
@@ -29,6 +31,41 @@ import {
29
31
  } from "./auth/spawnCaddy";
30
32
 
31
33
  const originalCwd = process.cwd();
34
+
35
+ // Subcommand routing & default behavior
36
+ //
37
+ // `fbi-proxy setup` (or `fbi-proxy` with no escape-hatch flags) runs the
38
+ // one-shot setup orchestrator: registers an oxmgr daemon, generates+trusts
39
+ // a TLS cert, installs a pf :443→:8443 forward, and verifies https://<domain>.
40
+ //
41
+ // Old foreground modes (Caddy, dev, raw TLS, auth wizard) are preserved by
42
+ // flags below — explicit opt-in keeps the existing surface intact for users
43
+ // who rely on it.
44
+ {
45
+ const rawArgs = hideBin(process.argv);
46
+ const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
47
+ const FOREGROUND_FLAGS = [
48
+ "--dev",
49
+ "--with-caddy",
50
+ "--with-auth",
51
+ "--tls",
52
+ "--reconfigure",
53
+ ];
54
+ const wantsForeground = rawArgs.some((a) =>
55
+ FOREGROUND_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)),
56
+ );
57
+ const hasExplicitPortEnv = !!process.env.FBI_PROXY_PORT;
58
+ const isSetupCmd = firstPositional === "setup";
59
+ const isDefault = !firstPositional && !wantsForeground && !hasExplicitPortEnv;
60
+
61
+ if (isSetupCmd || isDefault) {
62
+ const { runSetup } = await import("./setup");
63
+ const passArgs = rawArgs.filter((a) => a !== "setup");
64
+ await runSetup(passArgs, { originalCwd });
65
+ process.exit(0);
66
+ }
67
+ }
68
+
32
69
  process.chdir(path.resolve(import.meta.dir, ".."));
33
70
 
34
71
  const argv = await yargs(hideBin(process.argv))
@@ -71,12 +108,33 @@ const argv = await yargs(hideBin(process.argv))
71
108
  description:
72
109
  "TLS strategy for --with-caddy. 'auto' uses ACME (Let's Encrypt); 'internal' uses Caddy's local CA. Defaults to 'internal' for fbi.com, 'auto' otherwise.",
73
110
  })
111
+ .option("tls", {
112
+ type: "boolean",
113
+ default: false,
114
+ description:
115
+ "Terminate TLS in the Rust proxy using a self-signed cert (no Caddy). Browser warning expected (Phase 1 — no system trust install). Use with --port 443 + sudo to serve standard HTTPS.",
116
+ })
74
117
  .help().argv;
75
118
 
76
- console.log("Preparing Binaries");
119
+ if (argv.tls && argv["with-caddy"]) {
120
+ console.error(
121
+ "[fbi-proxy] --tls and --with-caddy are mutually exclusive (Caddy already terminates TLS).",
122
+ );
123
+ process.exit(2);
124
+ }
77
125
 
78
126
  const FBI_PROXY_PORT =
79
- process.env.FBI_PROXY_PORT || String(await getPort({ port: 2432 }));
127
+ process.env.FBI_PROXY_PORT ||
128
+ (argv.tls ? "443" : String(await getPort({ port: 2432 })));
129
+
130
+ if (argv.tls) {
131
+ await ensureRootIfTlsNeedsIt({
132
+ domain: argv.domain,
133
+ port: Number(FBI_PROXY_PORT),
134
+ });
135
+ }
136
+
137
+ console.log("Preparing Binaries");
80
138
 
81
139
  const proxyProcess = await hotMemo(async () => {
82
140
  const proxy = await getFbiProxyBinary({ originalCwd });
@@ -85,6 +143,17 @@ const proxyProcess = await hotMemo(async () => {
85
143
  env: {
86
144
  ...process.env,
87
145
  FBI_PROXY_PORT,
146
+ ...(argv.tls
147
+ ? {
148
+ FBI_PROXY_TLS: "true",
149
+ FBI_PROXY_DOMAIN: argv.domain,
150
+ // Forward CERT_DIR if the sudo wrapper set it (so the elevated
151
+ // Rust binary writes to the original user's $HOME, not /var/root)
152
+ ...(process.env.FBI_PROXY_CERT_DIR
153
+ ? { FBI_PROXY_CERT_DIR: process.env.FBI_PROXY_CERT_DIR }
154
+ : {}),
155
+ }
156
+ : {}),
88
157
  },
89
158
  })`${proxy}`.process;
90
159
 
@@ -136,6 +205,83 @@ process.on("uncaughtException", (err) => {
136
205
  exit();
137
206
  });
138
207
 
208
+ async function ensureRootIfTlsNeedsIt(opts: {
209
+ domain: string;
210
+ port: number;
211
+ }): Promise<void> {
212
+ if (process.platform !== "darwin") {
213
+ // Linux/Windows trust install is a follow-up. The Rust side prints a
214
+ // friendly fallback message if untrusted.
215
+ return;
216
+ }
217
+ if (process.getuid?.() === 0) return;
218
+
219
+ const home = process.env.HOME ?? "";
220
+ const certDir =
221
+ process.env.FBI_PROXY_CERT_DIR ??
222
+ path.join(home, ".config/fbi-proxy/certs");
223
+ const slug = opts.domain || "localhost";
224
+ const certPath = path.join(certDir, `${slug}.pem`);
225
+
226
+ const needsPortBind = opts.port < 1024;
227
+ const certMissing = !existsSync(certPath);
228
+ const certUntrusted = !certMissing && !isMacosCertTrusted(certPath);
229
+ const needsTrustInstall = certMissing || certUntrusted;
230
+
231
+ if (!needsPortBind && !needsTrustInstall) return;
232
+
233
+ const reasons = [
234
+ needsPortBind && `bind :${opts.port}`,
235
+ needsTrustInstall && "install cert to system trust",
236
+ ]
237
+ .filter(Boolean)
238
+ .join(" + ");
239
+ console.log(`[fbi-proxy] --tls needs root to: ${reasons}`);
240
+
241
+ // Preserve HOME and CERT_DIR so the elevated process writes cert/auth
242
+ // files into the original user's directory, not /var/root.
243
+ const sudoArgs = [
244
+ `HOME=${home}`,
245
+ `FBI_PROXY_CERT_DIR=${certDir}`,
246
+ process.execPath,
247
+ ...process.argv.slice(1),
248
+ ];
249
+
250
+ // Prefer terminal sudo when a TTY is attached; otherwise fall back to the
251
+ // macOS GUI authentication dialog via osascript so non-TTY contexts (agent
252
+ // shells, oxmgr-spawned children) can still escalate with a single password
253
+ // prompt instead of erroring out with "a terminal is required".
254
+ const hasTty = !!process.stdin.isTTY;
255
+ if (hasTty) {
256
+ console.log(
257
+ `[fbi-proxy] re-launching via sudo (terminal password prompt)…`,
258
+ );
259
+ const result = spawnSync("sudo", sudoArgs, { stdio: "inherit" });
260
+ process.exit(result.status ?? 1);
261
+ }
262
+
263
+ console.log(`[fbi-proxy] no TTY — opening macOS authentication dialog…`);
264
+ const shellCmd = sudoArgs.map(shellQuote).join(" ");
265
+ const script = `do shell script ${appleScriptQuote(shellCmd)} with administrator privileges`;
266
+ const result = spawnSync("osascript", ["-e", script], { stdio: "inherit" });
267
+ process.exit(result.status ?? 1);
268
+ }
269
+
270
+ function shellQuote(s: string): string {
271
+ return `'${s.replace(/'/g, `'\\''`)}'`;
272
+ }
273
+
274
+ function appleScriptQuote(s: string): string {
275
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
276
+ }
277
+
278
+ function isMacosCertTrusted(certPath: string): boolean {
279
+ const result = spawnSync("security", ["verify-cert", "-c", certPath], {
280
+ stdio: "ignore",
281
+ });
282
+ return result.status === 0;
283
+ }
284
+
139
285
  async function startFbiAuth(opts: {
140
286
  domain: string;
141
287
  reconfigure: boolean;
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import yargs from "yargs";
5
+ import { hideBin } from "yargs/helpers";
6
+
7
+ const ANCHOR_NAME = "com.snomiao.fbi-proxy";
8
+ const ANCHOR_FILE = `/etc/pf.anchors/${ANCHOR_NAME}`;
9
+ const PLIST_FILE = `/Library/LaunchDaemons/${ANCHOR_NAME}-pf.plist`;
10
+
11
+ const argv = await yargs(hideBin(process.argv))
12
+ .option("from", {
13
+ type: "number",
14
+ default: 443,
15
+ description: "Public-facing port to redirect (privileged)",
16
+ })
17
+ .option("to", {
18
+ type: "number",
19
+ default: 8443,
20
+ description: "Backend port the oxmgr-managed proxy listens on",
21
+ })
22
+ .option("uninstall", {
23
+ type: "boolean",
24
+ default: false,
25
+ description: "Remove the pf rule and LaunchDaemon",
26
+ })
27
+ .help().argv;
28
+
29
+ if (process.platform !== "darwin") {
30
+ console.error("install-port-forward: macOS only (pf rules)");
31
+ process.exit(2);
32
+ }
33
+
34
+ if (argv.uninstall) {
35
+ const script = [
36
+ `launchctl unload "${PLIST_FILE}" 2>/dev/null`,
37
+ `rm -f "${PLIST_FILE}" "${ANCHOR_FILE}"`,
38
+ `pfctl -a ${ANCHOR_NAME} -F all 2>/dev/null`,
39
+ "echo uninstalled",
40
+ ].join("\n");
41
+ runAsRoot(script);
42
+ process.exit(0);
43
+ }
44
+
45
+ // pf-rdr is loopback-only here because fbi.com resolves to 127.0.0.1 via local
46
+ // DNS. If you ever expose this on a real interface, widen the rule.
47
+ const anchorContent = `rdr pass on lo0 inet proto tcp from any to any port ${argv.from} -> 127.0.0.1 port ${argv.to}\n`;
48
+
49
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
50
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
+ <plist version="1.0">
52
+ <dict>
53
+ <key>Label</key><string>${ANCHOR_NAME}-pf</string>
54
+ <key>ProgramArguments</key>
55
+ <array>
56
+ <string>/bin/sh</string>
57
+ <string>-c</string>
58
+ <string>/sbin/pfctl -E 2>/dev/null; /sbin/pfctl -a ${ANCHOR_NAME} -f ${ANCHOR_FILE}</string>
59
+ </array>
60
+ <key>RunAtLoad</key><true/>
61
+ <key>StandardOutPath</key><string>/var/log/${ANCHOR_NAME}-pf.out.log</string>
62
+ <key>StandardErrorPath</key><string>/var/log/${ANCHOR_NAME}-pf.err.log</string>
63
+ </dict>
64
+ </plist>
65
+ `;
66
+
67
+ if (alreadyInstalledFor(argv.from, argv.to)) {
68
+ console.log(
69
+ `pf forward :${argv.from} -> :${argv.to} already installed and active`,
70
+ );
71
+ process.exit(0);
72
+ }
73
+
74
+ console.log(
75
+ `installing pf forward :${argv.from} -> :${argv.to} via macOS auth dialog…`,
76
+ );
77
+
78
+ // Single elevated shell — writes both files, loads the LaunchDaemon, and
79
+ // applies the rule immediately so we don't wait for a reboot.
80
+ const heredoc = (path: string, body: string) =>
81
+ `cat > "${path}" <<'__FBI_PROXY_PF_EOF__'\n${body}__FBI_PROXY_PF_EOF__`;
82
+
83
+ const script = [
84
+ heredoc(ANCHOR_FILE, anchorContent),
85
+ `chmod 644 "${ANCHOR_FILE}"`,
86
+ heredoc(PLIST_FILE, plistContent),
87
+ `chown root:wheel "${PLIST_FILE}"`,
88
+ `chmod 644 "${PLIST_FILE}"`,
89
+ `launchctl unload "${PLIST_FILE}" 2>/dev/null || true`,
90
+ `launchctl load -w "${PLIST_FILE}"`,
91
+ `/sbin/pfctl -E 2>/dev/null || true`,
92
+ `/sbin/pfctl -a ${ANCHOR_NAME} -f "${ANCHOR_FILE}"`,
93
+ `echo OK`,
94
+ ].join("\n");
95
+
96
+ const status = runAsRoot(script);
97
+ if (status !== 0) {
98
+ console.error(`pf forward install failed (exit ${status})`);
99
+ process.exit(1);
100
+ }
101
+
102
+ if (alreadyInstalledFor(argv.from, argv.to)) {
103
+ console.log(
104
+ `pf forward :${argv.from} -> :${argv.to} active. LaunchDaemon: ${PLIST_FILE}`,
105
+ );
106
+ } else {
107
+ console.warn(
108
+ `pf forward installed but verification failed — check 'sudo pfctl -a ${ANCHOR_NAME} -s nat'`,
109
+ );
110
+ }
111
+
112
+ function runAsRoot(script: string): number {
113
+ const hasTty = !!process.stdin.isTTY;
114
+ if (hasTty && process.getuid?.() !== 0) {
115
+ const result = spawnSync("sudo", ["sh", "-c", script], {
116
+ stdio: "inherit",
117
+ });
118
+ return result.status ?? 1;
119
+ }
120
+ if (process.getuid?.() === 0) {
121
+ const result = spawnSync("sh", ["-c", script], { stdio: "inherit" });
122
+ return result.status ?? 1;
123
+ }
124
+ // GUI password dialog — works without TTY (Claude Code, oxmgr children, etc.)
125
+ const osascript = `do shell script ${appleScriptQuote(script)} with administrator privileges`;
126
+ const result = spawnSync("osascript", ["-e", osascript], {
127
+ stdio: "inherit",
128
+ });
129
+ return result.status ?? 1;
130
+ }
131
+
132
+ function alreadyInstalledFor(from: number, to: number): boolean {
133
+ if (!existsSync(ANCHOR_FILE) || !existsSync(PLIST_FILE)) return false;
134
+ // -s nat needs root to read the runtime rule table on most setups
135
+ const probe = spawnSync(
136
+ "sudo",
137
+ ["-n", "/sbin/pfctl", "-a", ANCHOR_NAME, "-s", "nat"],
138
+ {
139
+ stdio: ["ignore", "pipe", "ignore"],
140
+ },
141
+ );
142
+ if (probe.status !== 0) return false;
143
+ const out = probe.stdout?.toString() ?? "";
144
+ return out.includes(`port = ${from}`) && out.includes(`port ${to}`);
145
+ }
146
+
147
+ function appleScriptQuote(s: string): string {
148
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
149
+ }