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/setup.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
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 wrapped = `do shell script ${appleScriptQuote(script)} with administrator privileges`;
|
|
359
|
+
return (
|
|
360
|
+
spawnSync("osascript", ["-e", wrapped], { stdio: "inherit" }).status === 0
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function shellQuote(s: string): string {
|
|
365
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function appleScriptQuote(s: string): string {
|
|
369
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
370
|
+
}
|