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/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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|