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.
@@ -0,0 +1,152 @@
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 prompt =
126
+ `fbi-proxy needs administrator access to install a pf port-forward ` +
127
+ `(:${argv.from} → :${argv.to}) and its boot LaunchDaemon.`;
128
+ const osascript = `do shell script ${appleScriptQuote(script)} with prompt ${appleScriptQuote(prompt)} with administrator privileges`;
129
+ const result = spawnSync("osascript", ["-e", osascript], {
130
+ stdio: "inherit",
131
+ });
132
+ return result.status ?? 1;
133
+ }
134
+
135
+ function alreadyInstalledFor(from: number, to: number): boolean {
136
+ if (!existsSync(ANCHOR_FILE) || !existsSync(PLIST_FILE)) return false;
137
+ // -s nat needs root to read the runtime rule table on most setups
138
+ const probe = spawnSync(
139
+ "sudo",
140
+ ["-n", "/sbin/pfctl", "-a", ANCHOR_NAME, "-s", "nat"],
141
+ {
142
+ stdio: ["ignore", "pipe", "ignore"],
143
+ },
144
+ );
145
+ if (probe.status !== 0) return false;
146
+ const out = probe.stdout?.toString() ?? "";
147
+ return out.includes(`port = ${from}`) && out.includes(`port ${to}`);
148
+ }
149
+
150
+ function appleScriptQuote(s: string): string {
151
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
152
+ }
package/ts/routes.ts CHANGED
@@ -27,6 +27,12 @@ export type RouteConfig = {
27
27
  * `{name:multi}` (one or more dot-segments — for DNS-passthrough).
28
28
  */
29
29
  match: string;
30
+ /**
31
+ * Optional path-prefix matcher. When set, the rule only matches
32
+ * requests whose path falls under this prefix; among host-matching
33
+ * rules the longest matching prefix wins. Forwarded upstream as-is.
34
+ */
35
+ path?: string;
30
36
  /**
31
37
  * Target template. Expanded with placeholder captures from `match`.
32
38
  * E.g. `"127.0.0.1:{port}"`.
@@ -46,6 +52,15 @@ export type RoutesFile = {
46
52
  routes: RouteConfig[];
47
53
  };
48
54
 
55
+ /**
56
+ * Compose-style per-project config (`fbi-proxy.yaml`). The top-level
57
+ * `name` is the namespace (defaults to the directory name when omitted).
58
+ */
59
+ export type ComposeFile = {
60
+ name?: string;
61
+ routes: RouteConfig[];
62
+ };
63
+
49
64
  /** Result type for `validateRoute`. */
50
65
  export type ValidationResult =
51
66
  | { valid: true }
@@ -108,9 +123,19 @@ export function parseRoutesYaml(yaml: string): RoutesFile {
108
123
  headers[hk] = hv;
109
124
  }
110
125
  }
126
+ let path: string | undefined;
127
+ if (e.path != null) {
128
+ if (typeof e.path !== "string") {
129
+ throw new Error(
130
+ `routes.yaml: entry '${e.name}': \`path\` must be a string`,
131
+ );
132
+ }
133
+ path = e.path;
134
+ }
111
135
  routes.push({
112
136
  name: e.name,
113
137
  match: e.match,
138
+ ...(path != null ? { path } : {}),
114
139
  target: e.target,
115
140
  headers,
116
141
  });
@@ -118,6 +143,28 @@ export function parseRoutesYaml(yaml: string): RoutesFile {
118
143
  return { version: 1, routes };
119
144
  }
120
145
 
146
+ /**
147
+ * Parse a compose-style `fbi-proxy.yaml`. Reuses the route validation in
148
+ * `parseRoutesYaml`. Returns the namespace (`name`, possibly undefined)
149
+ * and the parsed routes.
150
+ */
151
+ export function parseComposeYaml(yaml: string): ComposeFile {
152
+ const raw = YAML.parse(yaml);
153
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
154
+ throw new Error("fbi-proxy.yaml must be a YAML mapping at the top level");
155
+ }
156
+ const obj = raw as Record<string, unknown>;
157
+ if (obj.name != null && typeof obj.name !== "string") {
158
+ throw new Error("fbi-proxy.yaml: `name` must be a string");
159
+ }
160
+ // parseRoutesYaml validates the `routes` list + each entry; version is
161
+ // optional in a compose file so default it in.
162
+ const { routes } = parseRoutesYaml(
163
+ YAML.stringify({ version: 1, routes: obj.routes ?? [] }),
164
+ );
165
+ return { name: obj.name as string | undefined, routes };
166
+ }
167
+
121
168
  const PLACEHOLDER_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
122
169
  const VALID_KINDS = new Set(["", "int", "slug", "multi"]);
123
170
 
@@ -164,6 +211,9 @@ export function validateRoute(r: RouteConfig): ValidationResult {
164
211
  if (!r.match) return { valid: false, reason: "route `match` is required" };
165
212
  if (!r.target) return { valid: false, reason: "route `target` is required" };
166
213
 
214
+ if (r.path != null && !r.path.startsWith("/"))
215
+ return { valid: false, reason: "route `path` must start with '/'" };
216
+
167
217
  if (!bracesBalanced(r.match))
168
218
  return { valid: false, reason: "unbalanced braces in `match`" };
169
219
  if (!bracesBalanced(r.target))
package/ts/rulesCli.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `fbi-proxy up | down | ps | config` — compose-style management of
3
+ * runtime routing rules. Each project ships an `fbi-proxy.yaml` whose
4
+ * top-level `name` is the namespace; rules are stored as conf.d
5
+ * fragments and applied live via the loopback admin API.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import yargs from "yargs";
11
+ import { hideBin } from "yargs/helpers";
12
+ import YAML from "yaml";
13
+ import { parseComposeYaml, validateRoute, type RouteConfig } from "./routes";
14
+ import {
15
+ applyRules,
16
+ deleteRules,
17
+ listRules,
18
+ type RuleInfo,
19
+ } from "./adminClient";
20
+
21
+ const DEFAULT_FILE = "fbi-proxy.yaml";
22
+
23
+ export const RULES_SUBCOMMANDS = new Set(["up", "down", "ps", "config"]);
24
+
25
+ /** Derive the namespace: explicit `-p` > compose `name` > directory name. */
26
+ function resolveNamespace(
27
+ explicit: string | undefined,
28
+ composeName: string | undefined,
29
+ filePath: string,
30
+ ): string {
31
+ if (explicit) return explicit;
32
+ if (composeName) return composeName;
33
+ // directory of the compose file (or CWD for stdin) — like docker compose.
34
+ return path.basename(path.dirname(path.resolve(filePath)));
35
+ }
36
+
37
+ function loadCompose(file: string): { name?: string; routes: RouteConfig[] } {
38
+ if (!existsSync(file)) {
39
+ throw new Error(
40
+ `[fbi-proxy] ${file} not found. Create one (compose-style):\n` +
41
+ ` name: my-app\n routes:\n - name: web\n match: fbi.com\n path: /\n target: localhost:3000`,
42
+ );
43
+ }
44
+ const compose = parseComposeYaml(readFileSync(file, "utf8"));
45
+ for (const r of compose.routes) {
46
+ const v = validateRoute(r);
47
+ if (!v.valid) {
48
+ throw new Error(`[fbi-proxy] ${file}: rule '${r.name}': ${v.reason}`);
49
+ }
50
+ }
51
+ return compose;
52
+ }
53
+
54
+ function printRulesTable(rules: RuleInfo[]): void {
55
+ if (rules.length === 0) {
56
+ console.log("(no rules)");
57
+ return;
58
+ }
59
+ const rows = rules.map((r) => ({
60
+ NAMESPACE: r.namespace,
61
+ NAME: r.name,
62
+ MATCH: r.match,
63
+ PATH: r.path ?? "*",
64
+ TARGET: r.target,
65
+ }));
66
+ const cols = ["NAMESPACE", "NAME", "MATCH", "PATH", "TARGET"] as const;
67
+ const widths = Object.fromEntries(
68
+ cols.map((c) => [
69
+ c,
70
+ Math.max(c.length, ...rows.map((row) => String(row[c]).length)),
71
+ ]),
72
+ ) as Record<(typeof cols)[number], number>;
73
+ const fmt = (row: Record<string, string>) =>
74
+ cols.map((c) => String(row[c]).padEnd(widths[c])).join(" ");
75
+ console.log(fmt(Object.fromEntries(cols.map((c) => [c, c]))));
76
+ for (const row of rows) console.log(fmt(row));
77
+ }
78
+
79
+ /**
80
+ * Entry point dispatched from cli.ts when the first positional arg is one
81
+ * of `up|down|ps|config`. Returns the process exit code.
82
+ */
83
+ export async function runRulesCli(rawArgs: string[]): Promise<number> {
84
+ const argv = await yargs(rawArgs)
85
+ .scriptName("fbi-proxy")
86
+ .command("up", "Apply this project's fbi-proxy.yaml to the running proxy")
87
+ .command("down", "Remove this project's rules from the running proxy")
88
+ .command("ps", "List active rules across all namespaces")
89
+ .command("config", "Print the merged resolved routing table")
90
+ .option("file", {
91
+ alias: "f",
92
+ type: "string",
93
+ default: DEFAULT_FILE,
94
+ description: "Path to the compose file",
95
+ })
96
+ .option("project", {
97
+ alias: "p",
98
+ type: "string",
99
+ description:
100
+ "Override the namespace (defaults to compose `name` or dir name)",
101
+ })
102
+ .option("output", {
103
+ alias: "o",
104
+ type: "string",
105
+ choices: ["table", "json", "yaml"] as const,
106
+ default: "table",
107
+ description: "Output format for ps/config",
108
+ })
109
+ .help().argv;
110
+
111
+ const cmd = String(argv._[0]);
112
+
113
+ try {
114
+ switch (cmd) {
115
+ case "up": {
116
+ const compose = loadCompose(argv.file);
117
+ const ns = resolveNamespace(argv.project, compose.name, argv.file);
118
+ const body = YAML.stringify({ version: 1, routes: compose.routes });
119
+ const applied = await applyRules(ns, body);
120
+ console.log(
121
+ `[fbi-proxy] up: namespace '${ns}' (${compose.routes.length} rule(s))`,
122
+ );
123
+ printRulesTable(applied.filter((r) => r.namespace === ns));
124
+ return 0;
125
+ }
126
+ case "down": {
127
+ // Namespace can come from -p, or the compose file if present.
128
+ let ns = argv.project;
129
+ if (!ns) {
130
+ const compose = existsSync(argv.file) ? loadCompose(argv.file) : null;
131
+ ns = resolveNamespace(undefined, compose?.name, argv.file);
132
+ }
133
+ const res = await deleteRules(ns);
134
+ console.log(
135
+ res.removed
136
+ ? `[fbi-proxy] down: removed namespace '${ns}'`
137
+ : `[fbi-proxy] down: namespace '${ns}' was not present`,
138
+ );
139
+ return 0;
140
+ }
141
+ case "ps":
142
+ case "config": {
143
+ const rules = await listRules();
144
+ if (argv.output === "json") {
145
+ console.log(JSON.stringify(rules, null, 2));
146
+ } else if (argv.output === "yaml") {
147
+ console.log(YAML.stringify(rules));
148
+ } else {
149
+ printRulesTable(rules);
150
+ }
151
+ return 0;
152
+ }
153
+ default:
154
+ console.error(`[fbi-proxy] unknown command '${cmd}'`);
155
+ return 2;
156
+ }
157
+ } catch (e) {
158
+ console.error(e instanceof Error ? e.message : String(e));
159
+ return 1;
160
+ }
161
+ }
162
+
163
+ /** Convenience for standalone invocation/testing. */
164
+ if (import.meta.main) {
165
+ runRulesCli(hideBin(process.argv)).then((code) => process.exit(code));
166
+ }