fbi-proxy 1.16.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/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
+ }
package/ts/setup.ts CHANGED
@@ -355,7 +355,11 @@ function runAsRoot(script: string): boolean {
355
355
  );
356
356
  }
357
357
  console.log("[setup] (opening macOS auth dialog — enter password)");
358
- const wrapped = `do shell script ${appleScriptQuote(script)} with administrator privileges`;
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`;
359
363
  return (
360
364
  spawnSync("osascript", ["-e", wrapped], { stdio: "inherit" }).status === 0
361
365
  );