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/README.md +17 -4
- package/dist/cli.js +2158 -1692
- package/package.json +3 -1
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +128 -20
- package/rs/lib.rs +1 -0
- package/rs/tls.rs +243 -0
- package/ts/auth/authConfig.ts +19 -1
- package/ts/cli.ts +148 -2
- package/ts/install-port-forward.ts +149 -0
- package/ts/setup.ts +370 -0
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
|
-
|
|
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 ||
|
|
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
|
+
}
|