fbi-proxy 1.16.0 → 1.18.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/dist/cli.js +7542 -161
- package/package.json +2 -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 +520 -100
- package/rs/routes.rs +226 -31
- package/ts/adminClient.ts +124 -0
- package/ts/cli.ts +11 -1
- package/ts/install-port-forward.ts +4 -1
- package/ts/routes.ts +50 -0
- package/ts/rulesCli.ts +166 -0
- package/ts/setup.ts +5 -1
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
|
|
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
|
);
|