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/rs/routes.rs CHANGED
@@ -58,7 +58,7 @@
58
58
  //! capture for template expansion.
59
59
 
60
60
  use regex::Regex;
61
- use serde::Deserialize;
61
+ use serde::{Deserialize, Serialize};
62
62
  use std::collections::HashMap;
63
63
  use std::fmt;
64
64
 
@@ -112,23 +112,29 @@ pub struct Placeholder {
112
112
  }
113
113
 
114
114
  /// User-supplied route configuration (e.g. from `routes.yaml`).
115
- #[derive(Debug, Clone, Deserialize)]
115
+ #[derive(Debug, Clone, Default, Deserialize, Serialize)]
116
116
  pub struct RouteConfig {
117
117
  pub name: String,
118
118
  /// Pattern matched against the Host header (without port).
119
119
  /// E.g. `"{port:int}.{domain}"`.
120
120
  #[serde(rename = "match")]
121
121
  pub r#match: String,
122
+ /// Optional path-prefix matcher. When set, the rule only matches
123
+ /// requests whose path falls under this prefix; among host-matching
124
+ /// rules, the longest matching prefix wins. The path is forwarded
125
+ /// upstream as-is (never stripped).
126
+ #[serde(default, skip_serializing_if = "Option::is_none")]
127
+ pub path: Option<String>,
122
128
  /// Target template, e.g. `"127.0.0.1:{port}"`.
123
129
  pub target: String,
124
130
  /// Header templates. The special key `"Host"` (case-insensitive)
125
131
  /// is surfaced separately on `RouteHit::host_header`.
126
- #[serde(default)]
132
+ #[serde(default, skip_serializing_if = "Option::is_none")]
127
133
  pub headers: Option<HashMap<String, String>>,
128
134
  }
129
135
 
130
136
  /// Top-level shape of `routes.yaml`.
131
- #[derive(Debug, Clone, Deserialize)]
137
+ #[derive(Debug, Clone, Deserialize, Serialize)]
132
138
  pub struct RoutesFile {
133
139
  #[serde(default = "default_version")]
134
140
  pub version: u32,
@@ -152,6 +158,15 @@ pub struct CompiledRoute {
152
158
  pub placeholders: Vec<Placeholder>,
153
159
  pub target_template: String,
154
160
  pub header_templates: HashMap<String, String>,
161
+ /// Original (uncompiled) `match` pattern, retained so the admin API
162
+ /// can report and round-trip the source rule.
163
+ pub match_pattern: String,
164
+ /// Normalized optional path prefix (e.g. `"/_vscode/"`). `None`
165
+ /// matches any path (lowest path priority).
166
+ pub path_prefix: Option<String>,
167
+ /// Namespace this route belongs to — the conf.d fragment stem, or
168
+ /// `"default"` for the bundled defaults. Used for `ps` grouping.
169
+ pub namespace: String,
155
170
  }
156
171
 
157
172
  /// Result of a successful match.
@@ -320,19 +335,40 @@ fn validate_name(route: &str, raw_spec: &str, name: &str) -> Result<(), CompileE
320
335
  // Compile
321
336
  // ---------------------------------------------------------------------------
322
337
 
323
- /// Compile a list of `RouteConfig`s into ready-to-use `CompiledRoute`s.
338
+ /// Compile a list of `RouteConfig`s into ready-to-use `CompiledRoute`s
339
+ /// under the `"default"` namespace.
324
340
  ///
325
341
  /// Returns the first error encountered.
326
342
  pub fn compile(routes: Vec<RouteConfig>) -> Result<Vec<CompiledRoute>, CompileError> {
343
+ compile_in_namespace(routes, "default")
344
+ }
345
+
346
+ /// Like [`compile`], but tags every produced route with `namespace`
347
+ /// (the conf.d fragment stem). Used when merging multiple fragments.
348
+ pub fn compile_in_namespace(
349
+ routes: Vec<RouteConfig>,
350
+ namespace: &str,
351
+ ) -> Result<Vec<CompiledRoute>, CompileError> {
327
352
  let mut out = Vec::with_capacity(routes.len());
328
353
  for r in routes {
329
- out.push(compile_one(r)?);
354
+ out.push(compile_one(r, namespace)?);
330
355
  }
331
356
  Ok(out)
332
357
  }
333
358
 
334
- fn compile_one(cfg: RouteConfig) -> Result<CompiledRoute, CompileError> {
359
+ /// Normalize a path prefix: guarantee a leading `/`. Trailing slash is
360
+ /// left as the author wrote it (it affects boundary matching).
361
+ fn normalize_path_prefix(p: &str) -> String {
362
+ if p.starts_with('/') {
363
+ p.to_string()
364
+ } else {
365
+ format!("/{}", p)
366
+ }
367
+ }
368
+
369
+ fn compile_one(cfg: RouteConfig, namespace: &str) -> Result<CompiledRoute, CompileError> {
335
370
  let route_name = cfg.name.clone();
371
+ let match_pattern = cfg.r#match.clone();
336
372
  let tokens = tokenize(&cfg.r#match, &route_name, "match pattern")?;
337
373
 
338
374
  let mut declared: Vec<Placeholder> = Vec::new();
@@ -420,12 +456,17 @@ fn compile_one(cfg: RouteConfig) -> Result<CompiledRoute, CompileError> {
420
456
  }
421
457
  }
422
458
 
459
+ let path_prefix = cfg.path.as_deref().map(normalize_path_prefix);
460
+
423
461
  Ok(CompiledRoute {
424
462
  name: route_name,
425
463
  pattern,
426
464
  placeholders: declared,
427
465
  target_template: cfg.target,
428
466
  header_templates,
467
+ match_pattern,
468
+ path_prefix,
469
+ namespace: namespace.to_string(),
429
470
  })
430
471
  }
431
472
 
@@ -486,6 +527,20 @@ fn expand(template: &str, captures: &HashMap<String, String>) -> String {
486
527
  out
487
528
  }
488
529
 
530
+ /// Does `req_path` fall under `prefix`? `prefix` is normalized (leading
531
+ /// `/`). Boundary-aware: `"/_vscode/"` matches `/_vscode` and
532
+ /// `/_vscode/...` but NOT `/_vscodex`.
533
+ fn path_matches(prefix: &str, req_path: &str) -> bool {
534
+ if prefix == "/" {
535
+ return true;
536
+ }
537
+ if let Some(stripped) = prefix.strip_suffix('/') {
538
+ req_path == stripped || req_path.starts_with(prefix)
539
+ } else {
540
+ req_path == prefix || req_path.starts_with(&format!("{}/", prefix))
541
+ }
542
+ }
543
+
489
544
  /// Try to match a host against the compiled routes. Returns the first
490
545
  /// match (top-to-bottom order in the config).
491
546
  pub fn match_host(routes: &[CompiledRoute], host: &str) -> Option<RouteHit> {
@@ -504,6 +559,23 @@ pub fn match_host_with_domain(
504
559
  routes: &[CompiledRoute],
505
560
  host: &str,
506
561
  default_domain: Option<&str>,
562
+ ) -> Option<RouteHit> {
563
+ match_request(routes, host, "/", default_domain)
564
+ }
565
+
566
+ /// Match a host **and** request path against the compiled routes.
567
+ ///
568
+ /// Among all routes whose host pattern matches (and whose `path_prefix`
569
+ /// matches `req_path`, if any), the one with the **longest matching path
570
+ /// prefix** wins; ties are broken by declaration order (earliest wins).
571
+ /// A route with no `path_prefix` has the lowest path priority, so an
572
+ /// explicit `path: /` rule still beats a path-less rule for the same
573
+ /// host.
574
+ pub fn match_request(
575
+ routes: &[CompiledRoute],
576
+ host: &str,
577
+ req_path: &str,
578
+ default_domain: Option<&str>,
507
579
  ) -> Option<RouteHit> {
508
580
  let host = normalize(host);
509
581
 
@@ -516,37 +588,59 @@ pub fn match_host_with_domain(
516
588
  }
517
589
  }
518
590
 
519
- for route in routes {
520
- if let Some(caps) = route.pattern.captures(&host) {
521
- let mut values: HashMap<String, String> = HashMap::new();
522
- for p in &route.placeholders {
523
- if let Some(m) = caps.name(&p.name) {
524
- values.insert(p.name.clone(), m.as_str().to_string());
591
+ // Select the best candidate by path-prefix length. `priority` is the
592
+ // prefix byte length, or 0 for a path-less route. We require a
593
+ // strictly-greater priority to replace the current best, so the
594
+ // earliest declaration wins on ties.
595
+ let mut best_idx: Option<usize> = None;
596
+ let mut best_priority: i64 = -1;
597
+ for (i, route) in routes.iter().enumerate() {
598
+ if !route.pattern.is_match(&host) {
599
+ continue;
600
+ }
601
+ let priority: i64 = match &route.path_prefix {
602
+ None => 0,
603
+ Some(prefix) => {
604
+ if !path_matches(prefix, req_path) {
605
+ continue;
525
606
  }
607
+ prefix.len() as i64
526
608
  }
609
+ };
610
+ if priority > best_priority {
611
+ best_priority = priority;
612
+ best_idx = Some(i);
613
+ }
614
+ }
527
615
 
528
- let target = expand(&route.target_template, &values);
616
+ let route = routes.get(best_idx?)?;
617
+ let caps = route.pattern.captures(&host)?;
618
+ let mut values: HashMap<String, String> = HashMap::new();
619
+ for p in &route.placeholders {
620
+ if let Some(m) = caps.name(&p.name) {
621
+ values.insert(p.name.clone(), m.as_str().to_string());
622
+ }
623
+ }
529
624
 
530
- let mut host_header: Option<String> = None;
531
- let mut other_headers: HashMap<String, String> = HashMap::new();
532
- for (k, tmpl) in &route.header_templates {
533
- let v = expand(tmpl, &values);
534
- if k.eq_ignore_ascii_case("host") {
535
- host_header = Some(v);
536
- } else {
537
- other_headers.insert(k.clone(), v);
538
- }
539
- }
625
+ let target = expand(&route.target_template, &values);
540
626
 
541
- return Some(RouteHit {
542
- route_name: route.name.clone(),
543
- target,
544
- host_header,
545
- other_headers,
546
- });
627
+ let mut host_header: Option<String> = None;
628
+ let mut other_headers: HashMap<String, String> = HashMap::new();
629
+ for (k, tmpl) in &route.header_templates {
630
+ let v = expand(tmpl, &values);
631
+ if k.eq_ignore_ascii_case("host") {
632
+ host_header = Some(v);
633
+ } else {
634
+ other_headers.insert(k.clone(), v);
547
635
  }
548
636
  }
549
- None
637
+
638
+ Some(RouteHit {
639
+ route_name: route.name.clone(),
640
+ target,
641
+ host_header,
642
+ other_headers,
643
+ })
550
644
  }
551
645
 
552
646
  // ---------------------------------------------------------------------------
@@ -562,12 +656,14 @@ mod tests {
562
656
  RouteConfig {
563
657
  name: "port-as-host".into(),
564
658
  r#match: "{port:int}.{domain}".into(),
659
+ path: None,
565
660
  target: "127.0.0.1:{port}".into(),
566
661
  headers: None,
567
662
  },
568
663
  RouteConfig {
569
664
  name: "host-double-dash-port".into(),
570
665
  r#match: "{host}--{port:int}.{domain}".into(),
666
+ path: None,
571
667
  target: "{host}:{port}".into(),
572
668
  headers: Some({
573
669
  let mut h = HashMap::new();
@@ -578,6 +674,7 @@ mod tests {
578
674
  RouteConfig {
579
675
  name: "subdomain-hoisting".into(),
580
676
  r#match: "{prefix}.{host}.{domain}".into(),
677
+ path: None,
581
678
  target: "{host}:80".into(),
582
679
  headers: Some({
583
680
  let mut h = HashMap::new();
@@ -588,6 +685,7 @@ mod tests {
588
685
  RouteConfig {
589
686
  name: "direct-forward".into(),
590
687
  r#match: "{host}.{domain}".into(),
688
+ path: None,
591
689
  target: "{host}:80".into(),
592
690
  headers: Some({
593
691
  let mut h = HashMap::new();
@@ -719,12 +817,14 @@ mod tests {
719
817
  RouteConfig {
720
818
  name: "first".into(),
721
819
  r#match: "{x}.{y}".into(),
820
+ path: None,
722
821
  target: "first-target".into(),
723
822
  headers: None,
724
823
  },
725
824
  RouteConfig {
726
825
  name: "second".into(),
727
826
  r#match: "{x}.{y}".into(),
827
+ path: None,
728
828
  target: "second-target".into(),
729
829
  headers: None,
730
830
  },
@@ -740,6 +840,7 @@ mod tests {
740
840
  let err = compile(vec![RouteConfig {
741
841
  name: "bad".into(),
742
842
  r#match: "{port:zzz}.com".into(),
843
+ path: None,
743
844
  target: "x".into(),
744
845
  headers: None,
745
846
  }])
@@ -755,6 +856,7 @@ mod tests {
755
856
  let err = compile(vec![RouteConfig {
756
857
  name: "bad".into(),
757
858
  r#match: "{port".into(),
859
+ path: None,
758
860
  target: "x".into(),
759
861
  headers: None,
760
862
  }])
@@ -772,6 +874,7 @@ mod tests {
772
874
  let err = compile(vec![RouteConfig {
773
875
  name: "bad".into(),
774
876
  r#match: "{x}.{x}".into(),
877
+ path: None,
775
878
  target: "y".into(),
776
879
  headers: None,
777
880
  }])
@@ -787,6 +890,7 @@ mod tests {
787
890
  let err = compile(vec![RouteConfig {
788
891
  name: "bad".into(),
789
892
  r#match: "{x}.{y}".into(),
893
+ path: None,
790
894
  target: "{z}".into(),
791
895
  headers: None,
792
896
  }])
@@ -805,6 +909,7 @@ mod tests {
805
909
  let err = compile(vec![RouteConfig {
806
910
  name: "bad".into(),
807
911
  r#match: "{1foo}".into(),
912
+ path: None,
808
913
  target: "x".into(),
809
914
  headers: None,
810
915
  }])
@@ -849,6 +954,7 @@ mod tests {
849
954
  let routes = compile(vec![RouteConfig {
850
955
  name: "direct".into(),
851
956
  r#match: "{host}.{domain}".into(),
957
+ path: None,
852
958
  target: "{host}:80".into(),
853
959
  headers: None,
854
960
  }])
@@ -864,6 +970,7 @@ mod tests {
864
970
  let routes = compile(vec![RouteConfig {
865
971
  name: "direct".into(),
866
972
  r#match: "{host}.{domain}".into(),
973
+ path: None,
867
974
  target: "{host}:80".into(),
868
975
  headers: None,
869
976
  }])
@@ -877,6 +984,7 @@ mod tests {
877
984
  let routes = compile(vec![RouteConfig {
878
985
  name: "dns-passthrough".into(),
879
986
  r#match: "{upstream:multi}.fbi.com".into(),
987
+ path: None,
880
988
  target: "{upstream}:80".into(),
881
989
  headers: None,
882
990
  }])
@@ -898,6 +1006,7 @@ mod tests {
898
1006
  let routes = compile(vec![RouteConfig {
899
1007
  name: "dns-with-host".into(),
900
1008
  r#match: "{upstream:multi}.fbi.com".into(),
1009
+ path: None,
901
1010
  target: "{upstream}:443".into(),
902
1011
  headers: Some(HashMap::from([("Host".into(), "{upstream}".into())])),
903
1012
  }])
@@ -926,6 +1035,7 @@ routes:
926
1035
  let routes = compile(vec![RouteConfig {
927
1036
  name: "slugged".into(),
928
1037
  r#match: "{name:slug}.example".into(),
1038
+ path: None,
929
1039
  target: "{name}".into(),
930
1040
  headers: None,
931
1041
  }])
@@ -973,4 +1083,89 @@ routes:
973
1083
  caps.insert("a".to_string(), "X".to_string());
974
1084
  assert_eq!(expand("{a}-{b}", &caps), "X-");
975
1085
  }
1086
+
1087
+ // ----- path-prefix matching (web-code use case) -----
1088
+
1089
+ fn web_code_routes() -> Vec<CompiledRoute> {
1090
+ compile_in_namespace(
1091
+ vec![
1092
+ RouteConfig {
1093
+ name: "root".into(),
1094
+ r#match: "fbi.com".into(),
1095
+ path: Some("/".into()),
1096
+ target: "localhost:3001".into(),
1097
+ headers: None,
1098
+ },
1099
+ RouteConfig {
1100
+ name: "vscode".into(),
1101
+ r#match: "fbi.com".into(),
1102
+ path: Some("/_vscode/".into()),
1103
+ target: "localhost:9999".into(),
1104
+ headers: None,
1105
+ },
1106
+ ],
1107
+ "web-code",
1108
+ )
1109
+ .expect("compile web-code routes")
1110
+ }
1111
+
1112
+ #[test]
1113
+ fn path_prefix_longest_wins() {
1114
+ let routes = web_code_routes();
1115
+ let hit = match_request(&routes, "fbi.com", "/_vscode/", Some("fbi.com")).unwrap();
1116
+ assert_eq!(hit.target, "localhost:9999");
1117
+ let hit = match_request(&routes, "fbi.com", "/_vscode/stable/x.js", Some("fbi.com")).unwrap();
1118
+ assert_eq!(hit.target, "localhost:9999");
1119
+ let hit = match_request(&routes, "fbi.com", "/", Some("fbi.com")).unwrap();
1120
+ assert_eq!(hit.target, "localhost:3001");
1121
+ let hit = match_request(&routes, "fbi.com", "/owner/repo/tree/main", Some("fbi.com")).unwrap();
1122
+ assert_eq!(hit.target, "localhost:3001");
1123
+ }
1124
+
1125
+ #[test]
1126
+ fn path_prefix_boundary_not_substring() {
1127
+ let routes = web_code_routes();
1128
+ let hit = match_request(&routes, "fbi.com", "/_vscodex", Some("fbi.com")).unwrap();
1129
+ assert_eq!(hit.target, "localhost:3001");
1130
+ let hit = match_request(&routes, "fbi.com", "/_vscode", Some("fbi.com")).unwrap();
1131
+ assert_eq!(hit.target, "localhost:9999");
1132
+ }
1133
+
1134
+ #[test]
1135
+ fn explicit_root_path_beats_pathless() {
1136
+ let routes = compile(vec![
1137
+ RouteConfig {
1138
+ name: "pathless".into(),
1139
+ r#match: "fbi.com".into(),
1140
+ path: None,
1141
+ target: "localhost:1".into(),
1142
+ headers: None,
1143
+ },
1144
+ RouteConfig {
1145
+ name: "rooted".into(),
1146
+ r#match: "fbi.com".into(),
1147
+ path: Some("/".into()),
1148
+ target: "localhost:2".into(),
1149
+ headers: None,
1150
+ },
1151
+ ])
1152
+ .unwrap();
1153
+ let hit = match_request(&routes, "fbi.com", "/anything", None).unwrap();
1154
+ assert_eq!(hit.target, "localhost:2");
1155
+ }
1156
+
1157
+ #[test]
1158
+ fn namespace_is_tagged_on_compiled_route() {
1159
+ let routes = web_code_routes();
1160
+ assert!(routes.iter().all(|r| r.namespace == "web-code"));
1161
+ let bundled = compile(vec![RouteConfig {
1162
+ name: "x".into(),
1163
+ r#match: "{host}".into(),
1164
+ path: None,
1165
+ target: "{host}:80".into(),
1166
+ headers: None,
1167
+ }])
1168
+ .unwrap();
1169
+ assert_eq!(bundled[0].namespace, "default");
1170
+ }
976
1171
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tiny client for the fbi-proxy loopback admin/control API.
3
+ *
4
+ * The running proxy publishes its (ephemeral) admin port to
5
+ * `~/.config/fbi-proxy/runtime.json`. The `up` / `down` / `ps` CLI
6
+ * subcommands read that file to find the port, then drive the `/rules`
7
+ * endpoints over loopback HTTP.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+
14
+ export type RuntimeInfo = {
15
+ adminPort: number;
16
+ proxyPort: number;
17
+ pid: number;
18
+ confDir: string;
19
+ };
20
+
21
+ /** A rule as reported by `GET /rules`. */
22
+ export type RuleInfo = {
23
+ namespace: string;
24
+ name: string;
25
+ match: string;
26
+ path: string | null;
27
+ target: string;
28
+ headers: Record<string, string>;
29
+ };
30
+
31
+ /** Default config dir, matching the Rust side + setup.ts. */
32
+ export function defaultConfigDir(): string {
33
+ const fromEnv = process.env.FBI_PROXY_CONF_DIR;
34
+ if (fromEnv && fromEnv.length > 0) {
35
+ // FBI_PROXY_CONF_DIR points at conf.d; runtime.json sits beside it.
36
+ return path.dirname(fromEnv);
37
+ }
38
+ return path.join(os.homedir(), ".config/fbi-proxy");
39
+ }
40
+
41
+ function runtimeJsonPath(): string {
42
+ return path.join(defaultConfigDir(), "runtime.json");
43
+ }
44
+
45
+ /**
46
+ * Locate the running proxy's admin endpoint. Throws a helpful error if
47
+ * the proxy doesn't appear to be running.
48
+ */
49
+ export function readRuntime(): RuntimeInfo {
50
+ const p = runtimeJsonPath();
51
+ if (!existsSync(p)) {
52
+ throw new Error(
53
+ `[fbi-proxy] no running proxy found (missing ${p}).\n` +
54
+ ` Start it first: \`fbi-proxy setup\` (daemon) or \`fbi-proxy --tls --domain <d>\`.`,
55
+ );
56
+ }
57
+ let info: RuntimeInfo;
58
+ try {
59
+ info = JSON.parse(readFileSync(p, "utf8"));
60
+ } catch (e) {
61
+ throw new Error(`[fbi-proxy] could not parse ${p}: ${e}`);
62
+ }
63
+ if (!info.adminPort) {
64
+ throw new Error(
65
+ `[fbi-proxy] ${p} has no adminPort — is the proxy up to date?`,
66
+ );
67
+ }
68
+ return info;
69
+ }
70
+
71
+ function baseUrl(info: RuntimeInfo): string {
72
+ return `http://127.0.0.1:${info.adminPort}`;
73
+ }
74
+
75
+ async function asError(res: Response): Promise<never> {
76
+ let msg = `${res.status} ${res.statusText}`;
77
+ try {
78
+ const body = await res.json();
79
+ if (body?.error) msg = body.error;
80
+ } catch {
81
+ // non-JSON body; keep the status line
82
+ }
83
+ throw new Error(`[fbi-proxy] admin API: ${msg}`);
84
+ }
85
+
86
+ /** GET /rules — the full merged rule table. */
87
+ export async function listRules(info = readRuntime()): Promise<RuleInfo[]> {
88
+ const res = await fetch(`${baseUrl(info)}/rules`);
89
+ if (!res.ok) await asError(res);
90
+ return (await res.json()) as RuleInfo[];
91
+ }
92
+
93
+ /** PUT /rules/{namespace} — reconcile a namespace to `yamlBody`. */
94
+ export async function applyRules(
95
+ namespace: string,
96
+ yamlBody: string,
97
+ info = readRuntime(),
98
+ ): Promise<RuleInfo[]> {
99
+ const res = await fetch(
100
+ `${baseUrl(info)}/rules/${encodeURIComponent(namespace)}`,
101
+ {
102
+ method: "PUT",
103
+ headers: { "Content-Type": "application/yaml" },
104
+ body: yamlBody,
105
+ },
106
+ );
107
+ if (!res.ok) await asError(res);
108
+ return (await res.json()) as RuleInfo[];
109
+ }
110
+
111
+ /** DELETE /rules/{namespace} — remove a namespace's fragment. */
112
+ export async function deleteRules(
113
+ namespace: string,
114
+ info = readRuntime(),
115
+ ): Promise<{ ok: boolean; removed: boolean }> {
116
+ const res = await fetch(
117
+ `${baseUrl(info)}/rules/${encodeURIComponent(namespace)}`,
118
+ {
119
+ method: "DELETE",
120
+ },
121
+ );
122
+ if (!res.ok) await asError(res);
123
+ return (await res.json()) as { ok: boolean; removed: boolean };
124
+ }
package/ts/cli.ts CHANGED
@@ -44,6 +44,15 @@ const originalCwd = process.cwd();
44
44
  {
45
45
  const rawArgs = hideBin(process.argv);
46
46
  const firstPositional = rawArgs.find((a) => !a.startsWith("-"));
47
+
48
+ // Rule-management subcommands (compose-style) talk to a running proxy's
49
+ // admin API; they never start a proxy themselves.
50
+ const { RULES_SUBCOMMANDS, runRulesCli } = await import("./rulesCli");
51
+ if (firstPositional && RULES_SUBCOMMANDS.has(firstPositional)) {
52
+ const code = await runRulesCli(rawArgs);
53
+ process.exit(code);
54
+ }
55
+
47
56
  const FOREGROUND_FLAGS = [
48
57
  "--dev",
49
58
  "--with-caddy",
@@ -262,7 +271,8 @@ async function ensureRootIfTlsNeedsIt(opts: {
262
271
 
263
272
  console.log(`[fbi-proxy] no TTY — opening macOS authentication dialog…`);
264
273
  const shellCmd = sudoArgs.map(shellQuote).join(" ");
265
- const script = `do shell script ${appleScriptQuote(shellCmd)} with administrator privileges`;
274
+ const prompt = `fbi-proxy needs administrator access to ${reasons} for https://${opts.domain}/.`;
275
+ const script = `do shell script ${appleScriptQuote(shellCmd)} with prompt ${appleScriptQuote(prompt)} with administrator privileges`;
266
276
  const result = spawnSync("osascript", ["-e", script], { stdio: "inherit" });
267
277
  process.exit(result.status ?? 1);
268
278
  }
@@ -122,7 +122,10 @@ function runAsRoot(script: string): number {
122
122
  return result.status ?? 1;
123
123
  }
124
124
  // GUI password dialog — works without TTY (Claude Code, oxmgr children, etc.)
125
- const osascript = `do shell script ${appleScriptQuote(script)} with administrator privileges`;
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`;
126
129
  const result = spawnSync("osascript", ["-e", osascript], {
127
130
  stdio: "inherit",
128
131
  });