fbi-proxy 1.9.0 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fbi-proxy",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "FBI-Proxy provides easy HTTPS access to your local services with intelligent domain routing",
5
5
  "keywords": [
6
6
  "development-tools",
@@ -13,13 +13,12 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/snomiao/fbi-proxy/issues"
15
15
  },
16
+ "license": "MIT",
17
+ "author": "snomiao",
16
18
  "repository": {
17
19
  "type": "git",
18
20
  "url": "git+https://github.com/snomiao/fbi-proxy.git"
19
21
  },
20
- "license": "MIT",
21
- "author": "snomiao",
22
- "type": "module",
23
22
  "bin": {
24
23
  "fbi-proxy": "dist/cli.js"
25
24
  },
@@ -29,6 +28,7 @@
29
28
  "rs",
30
29
  "ts"
31
30
  ],
31
+ "type": "module",
32
32
  "scripts": {
33
33
  "build": "bun run build:rs && bun run build:js",
34
34
  "build:js": "bun build ./ts/cli.ts --outdir dist --target node",
@@ -52,7 +52,10 @@
52
52
  "execa": "^9.6.1",
53
53
  "from-node-stream": "^0.1.2",
54
54
  "get-port": "^7.1.0",
55
+ "hono": "^4.12.18",
55
56
  "hot-memo": "^1.1.1",
57
+ "jose": "^6.2.3",
58
+ "oauth4webapi": "^3.8.6",
56
59
  "sflow": "^1.27.0",
57
60
  "tsa-composer": "^1.0.0",
58
61
  "yargs": "^17.7.2"
@@ -70,6 +73,7 @@
70
73
  "husky": "^9.1.7",
71
74
  "lint-staged": "^16.2.7",
72
75
  "node-fetch": "^3.3.2",
76
+ "oxmgr": "^0.4.0",
73
77
  "playwright": "^1.58.2",
74
78
  "prettier": "^3.8.1",
75
79
  "semantic-release": "^24.0.0",
@@ -78,8 +82,8 @@
78
82
  "vitest": "^3.2.4",
79
83
  "ws": "^8.19.0"
80
84
  },
81
- "engines": {
82
- "node": ">=22.14.0"
85
+ "overrides": {
86
+ "@semantic-release/npm": "^13.1.3"
83
87
  },
84
88
  "lint-staged": {
85
89
  "*.{ts,js}": [
@@ -89,8 +93,10 @@
89
93
  "bun --bun prettier --write"
90
94
  ]
91
95
  },
92
- "trustedDependencies": [],
93
- "overrides": {
94
- "@semantic-release/npm": "^13.1.3"
95
- }
96
+ "engines": {
97
+ "node": ">=22.14.0"
98
+ },
99
+ "trustedDependencies": [
100
+ "oxmgr"
101
+ ]
96
102
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/rs/fbi-proxy.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  use clap::{Arg, Command};
2
+ use fbi_proxy::routes::{self, CompiledRoute, RouteHit};
2
3
  use futures_util::{SinkExt, StreamExt};
3
4
  use http_body_util::{BodyExt, Full};
4
5
  use hyper::body::{Bytes, Incoming};
@@ -23,18 +24,22 @@ use tokio_tungstenite::connect_async;
23
24
  type BoxError = Box<dyn std::error::Error + Send + Sync>;
24
25
  type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
25
26
 
27
+ /// Bundled default routes.yaml — reproduces the original `parse_host`
28
+ /// behavior. Loaded at compile-time so the binary works out-of-the-box.
29
+ const BUNDLED_ROUTES_YAML: &str = include_str!("../routes.yaml");
30
+
26
31
  pub struct FBIProxy {
27
32
  client: Client<HttpConnector, BoxBody>,
28
33
  number_regex: Regex,
29
34
  domain_filter: Option<String>,
35
+ compiled_routes: Vec<CompiledRoute>,
30
36
  }
31
37
 
32
38
  /*
33
- FBIProxy is a simple HTTP and WebSocket proxy server that supports port encoding in the Host header.
34
-
35
- parse incoming Host headers and convert them to a target URL format:
36
-
37
- for localhost, it uses "localhost"
39
+ FBIProxy is a simple HTTP and WebSocket proxy server with rule-based
40
+ host header routing. The rules are loaded from `routes.yaml` (bundled
41
+ by default, overridable via --routes). See the bundled `routes.yaml`
42
+ for the default 8 rules; in short:
38
43
 
39
44
  rule1: number host goes to local port
40
45
  - Host="3000" => localhost:3000
@@ -52,15 +57,13 @@ rule3: subdomains are hoisted
52
57
  - 3000.amd => proxies to http://amd:80, with host: 3000
53
58
  - sur.amd => proxies to http://amd:80, with host: sur
54
59
  - amd.sur.amd => proxies to http://amd:80, with host: amd.sur
55
- - if sur also runs fbi-proxy, it will proxies to http://amd:80, with host: amd
56
- - 3000.sur.amd => proxies to http://amd:80, with host: 3000.sur
57
-
58
- for subdomains
59
- *.amd => localhost:amd
60
60
 
61
+ When `--domain` (or `FBI_PROXY_DOMAIN`) is set, only hosts ending with
62
+ that suffix are accepted. The exact-domain host (e.g. `fbi.com`) serves
63
+ the landing page.
61
64
  */
62
65
  impl FBIProxy {
63
- pub fn new(domain_filter: Option<String>) -> Self {
66
+ pub fn new(domain_filter: Option<String>, compiled_routes: Vec<CompiledRoute>) -> Self {
64
67
  let mut connector = HttpConnector::new();
65
68
  // Set connection timeout to 5 seconds to avoid hanging on invalid hosts
66
69
  connector.set_connect_timeout(Some(Duration::from_secs(3)));
@@ -72,6 +75,7 @@ impl FBIProxy {
72
75
  client,
73
76
  number_regex: Regex::new(r"^\d+$").unwrap(),
74
77
  domain_filter,
78
+ compiled_routes,
75
79
  }
76
80
  }
77
81
 
@@ -165,77 +169,52 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
165
169
  </html>"#.to_string()
166
170
  }
167
171
 
168
- fn parse_host(&self, host_header: &str, domain_filter: &Option<String>) -> Option<(String, String)> {
169
- // Remove port if present (e.g., "localhost:8080" -> "localhost")
170
- let host_without_port = if let Some(colon_pos) = host_header.find(':') {
171
- &host_header[..colon_pos]
172
- } else {
173
- host_header
174
- };
175
-
176
- // Apply domain filter if specified
177
- let host = if let Some(domain) = domain_filter {
178
- if !domain.is_empty() {
179
- // Check if host ends with the domain filter
180
- if host_without_port.ends_with(domain) {
181
- // Strip the domain suffix (including the dot)
182
- let prefix_len = host_without_port.len() - domain.len();
183
- if prefix_len > 0 && host_without_port.chars().nth(prefix_len - 1) == Some('.') {
184
- // Remove the domain and the dot before it
185
- &host_without_port[..prefix_len - 1]
186
- } else if prefix_len == 0 {
187
- // The host is exactly the domain, treat as root
188
- "@"
189
- } else {
190
- // No dot separator, invalid format
191
- return None;
192
- }
193
- } else {
194
- // Host doesn't match domain filter
195
- return None;
196
- }
197
- } else {
198
- // Empty domain filter, accept all
199
- host_without_port
200
- }
201
- } else {
202
- // No domain filter, accept all
203
- host_without_port
204
- };
205
-
206
- // Handle special case: @ means root domain was accessed - serve landing page
207
- if host == "@" {
208
- return Some(("@LANDING".to_string(), "@LANDING".to_string()));
209
- }
210
-
211
- // Rule 1: number host goes to local port (e.g., "3000" => "localhost:3000")
212
- if self.number_regex.is_match(host) {
213
- return Some((format!("localhost:{}", host), "localhost".to_string()));
172
+ /// Extract the hostname portion (before the first `:`) from a target
173
+ /// string like `"127.0.0.1:3000"`. This is the default `Host` header
174
+ /// value used when a matched rule doesn't specify an explicit
175
+ /// `headers.Host` rewrite — which preserves the original
176
+ /// `parse_host` semantics (numeric subdomain → Host: localhost).
177
+ fn host_from_target(target: &str) -> String {
178
+ match target.find(':') {
179
+ Some(i) => target[..i].to_string(),
180
+ None => target.to_string(),
214
181
  }
182
+ }
215
183
 
216
- // Rule 1.2: host--port goes to host:port (e.g., "localhost--3000" => "localhost:3000")
217
- if let Some(double_dash_pos) = host.find("--") {
218
- let hostname = &host[..double_dash_pos];
219
- let port = &host[double_dash_pos + 2..];
220
- return Some((format!("{}:{}", hostname, port), hostname.to_string()));
221
- }
184
+ /// Returns Some((target, new_host_header)) if the routing engine
185
+ /// matches `host_header`, accounting for:
186
+ /// * domain filter pre-check (host must end with the configured
187
+ /// domain suffix, if any),
188
+ /// * exact-domain match → ("@LANDING", "@LANDING") so the request
189
+ /// handler serves the landing page,
190
+ /// * normal rule match → (target, host_header).
191
+ ///
192
+ /// Returns None if the host is rejected (filter mismatch or no
193
+ /// matching rule).
194
+ fn route(&self, host_header: &str) -> Option<(String, String)> {
195
+ // Drop port if present.
196
+ let host_without_port = match host_header.find(':') {
197
+ Some(i) => &host_header[..i],
198
+ None => host_header,
199
+ };
222
200
 
223
- // Rule 3: subdomains are hoisted
224
- let parts: Vec<&str> = host.split('.').collect();
225
- if parts.len() > 1 {
226
- // The last part is the main domain, everything before is subdomain
227
- let main_domain = parts.last().unwrap();
228
- let subdomain_parts = &parts[..parts.len() - 1];
229
- let subdomain = subdomain_parts.join(".");
230
-
231
- // Target is the main domain on port 80
232
- let target_host = format!("{}:80", main_domain);
233
- // New host header is the subdomain
234
- return Some((target_host, subdomain.to_string()));
201
+ // Exact-domain match serve landing page. Only relevant when a
202
+ // domain filter is configured.
203
+ if let Some(ref domain) = self.domain_filter {
204
+ if !domain.is_empty() && host_without_port.eq_ignore_ascii_case(domain) {
205
+ return Some(("@LANDING".to_string(), "@LANDING".to_string()));
206
+ }
235
207
  }
236
208
 
237
- // Rule 2: other host goes to that host:80 (e.g., "localhost" => "localhost:80")
238
- Some((format!("{}:80", host), host.to_string()))
209
+ let hit = routes::match_host_with_domain(
210
+ &self.compiled_routes,
211
+ host_header,
212
+ self.domain_filter.as_deref(),
213
+ )?;
214
+
215
+ let RouteHit { target, host_header: rewrite, .. } = hit;
216
+ let new_host = rewrite.unwrap_or_else(|| Self::host_from_target(&target));
217
+ Some((target, new_host))
239
218
  }
240
219
 
241
220
  pub async fn handle_request(&self, req: Request<Incoming>) -> Result<Response<BoxBody>, BoxError> {
@@ -247,9 +226,9 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
247
226
  .unwrap_or("localhost")
248
227
  .to_string();
249
228
 
250
- // Parse host with domain filtering
251
- let parsed_host = self.parse_host(&host_header, &self.domain_filter);
252
-
229
+ // Route the host via the rule engine.
230
+ let parsed_host = self.route(&host_header);
231
+
253
232
  // If domain filter rejects the host, return 502 Bad Gateway
254
233
  let (target_host, new_host) = match parsed_host {
255
234
  Some(hosts) => hosts,
@@ -384,7 +363,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
384
363
  );
385
364
  return Ok(Response::builder()
386
365
  .status(StatusCode::BAD_GATEWAY)
387
- .body(Full::new(Bytes::from("FBIPROXY CONNECT ERROR")).map_err(|e| match e {}).boxed())?);
366
+ .header("Content-Type", "text/plain")
367
+ .body(Full::new(Bytes::from(format!("502 Bad Gateway: failed to connect to {}: {}", tunnel_target, e))).map_err(|e| match e {}).boxed())?);
388
368
  }
389
369
  Err(_) => {
390
370
  error!(
@@ -395,7 +375,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
395
375
  );
396
376
  return Ok(Response::builder()
397
377
  .status(StatusCode::BAD_GATEWAY)
398
- .body(Full::new(Bytes::from("FBIPROXY CONNECT TIMEOUT")).map_err(|e| match e {}).boxed())?);
378
+ .header("Content-Type", "text/plain")
379
+ .body(Full::new(Bytes::from(format!("502 Bad Gateway: connection to {} timed out", tunnel_target))).map_err(|e| match e {}).boxed())?);
399
380
  }
400
381
  }
401
382
  }
@@ -462,7 +443,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
462
443
  );
463
444
  Ok(Response::builder()
464
445
  .status(StatusCode::BAD_GATEWAY)
465
- .body(Full::new(Bytes::from("FBIPROXY ERROR")).map_err(|e| match e {}).boxed())?)
446
+ .header("Content-Type", "text/plain")
447
+ .body(Full::new(Bytes::from(format!("502 Bad Gateway: failed to connect to {}: {}", target_host, e))).map_err(|e| match e {}).boxed())?)
466
448
  }
467
449
  Err(_) => {
468
450
  error!(
@@ -474,7 +456,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
474
456
  );
475
457
  Ok(Response::builder()
476
458
  .status(StatusCode::BAD_GATEWAY)
477
- .body(Full::new(Bytes::from("FBIPROXY TIMEOUT")).map_err(|e| match e {}).boxed())?)
459
+ .header("Content-Type", "text/plain")
460
+ .body(Full::new(Bytes::from(format!("502 Bad Gateway: request to {} timed out", target_host))).map_err(|e| match e {}).boxed())?)
478
461
  }
479
462
  }
480
463
  }
@@ -500,7 +483,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
500
483
  error!("WS :ws:{} => :ws:{}{} 502 (upstream connection failed: {})", target_host, target_host, uri, e);
501
484
  return Ok(Response::builder()
502
485
  .status(StatusCode::BAD_GATEWAY)
503
- .body(Full::new(Bytes::from("WebSocket upstream unavailable")).map_err(|e| match e {}).boxed())?);
486
+ .header("Content-Type", "text/plain")
487
+ .body(Full::new(Bytes::from(format!("502 Bad Gateway: WebSocket upstream {} unavailable: {}", target_host, e))).map_err(|e| match e {}).boxed())?);
504
488
  }
505
489
  };
506
490
 
@@ -578,16 +562,36 @@ async fn handle_connection(
578
562
  error!("Request handling error: {}", e);
579
563
  Ok(Response::builder()
580
564
  .status(StatusCode::INTERNAL_SERVER_ERROR)
581
- .body(Full::new(Bytes::from("Internal Server Error")).map_err(|e| match e {}).boxed())
565
+ .header("Content-Type", "text/plain")
566
+ .body(Full::new(Bytes::from(format!("500 Internal Server Error: {}", e))).map_err(|e| match e {}).boxed())
582
567
  .unwrap())
583
568
  }
584
569
  }
585
570
  }
586
571
 
587
- pub async fn start_proxy_server(host: Option<&str>, port: u16, domain_filter: Option<String>) -> Result<(), BoxError> {
572
+ /// Load routes from `routes.yaml` source text and compile them. Panics
573
+ /// with a descriptive message on parse or compile failure — this is
574
+ /// only invoked at startup, so failing fast is the right policy.
575
+ fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
576
+ let parsed = match routes::parse_yaml(yaml_src) {
577
+ Ok(p) => p,
578
+ Err(e) => panic!("failed to parse {}: {}", source_label, e),
579
+ };
580
+ match routes::compile(parsed.routes) {
581
+ Ok(c) => c,
582
+ Err(e) => panic!("failed to compile {}: {}", source_label, e),
583
+ }
584
+ }
585
+
586
+ pub async fn start_proxy_server(
587
+ host: Option<&str>,
588
+ port: u16,
589
+ domain_filter: Option<String>,
590
+ compiled_routes: Vec<CompiledRoute>,
591
+ ) -> Result<(), BoxError> {
588
592
  let host = host.unwrap_or("127.0.0.1");
589
593
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
590
- let proxy = Arc::new(FBIProxy::new(domain_filter.clone()));
594
+ let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
591
595
 
592
596
  let listener = TcpListener::bind(addr).await?;
593
597
 
@@ -600,7 +604,7 @@ pub async fn start_proxy_server(host: Option<&str>, port: u16, domain_filter: Op
600
604
  }
601
605
  println!();
602
606
  println!("== HOW IT WORKS ==");
603
- println!("Routes requests based on Host header:");
607
+ println!("Routes requests based on Host header (configurable via routes.yaml):");
604
608
  println!(" 3000 -> localhost:3000 (port as host)");
605
609
  println!(" api--8080 -> api:8080 (host--port syntax)");
606
610
  println!(" 3000.fbi.com -> localhost:3000 (subdomain as port)");
@@ -654,11 +658,11 @@ fn main() {
654
658
 
655
659
  FEATURES:
656
660
  • HTTP and WebSocket proxying with bidirectional forwarding
657
- • Smart host header parsing with multiple routing rules
661
+ • Smart host header parsing with multiple routing rules (configurable via routes.yaml)
658
662
  • Port encoding support for easy local development
659
663
  • Subdomain hoisting for multi-service architectures
660
664
 
661
- HOST PARSING RULES:
665
+ HOST PARSING RULES (default routes.yaml):
662
666
  1. Number host → local port: '3000' → localhost:3000
663
667
  2. Host--port syntax: 'api--3000' → api:3000
664
668
  3. Subdomain hoisting: 'api.service' → service:80 (host: api)
@@ -668,6 +672,7 @@ ENVIRONMENT VARIABLES:
668
672
  FBI_PROXY_PORT Port to listen on (default: 2432)
669
673
  FBI_PROXY_HOST Host/IP address to bind to (default: 127.0.0.1)
670
674
  FBI_PROXY_DOMAIN Domain filter (only accept *.domain requests)
675
+ FBI_PROXY_ROUTES Path to a custom routes.yaml (default: bundled)
671
676
  RUST_LOG Log level (error, warn, info, debug, trace)
672
677
 
673
678
  EXAMPLES:
@@ -675,6 +680,7 @@ EXAMPLES:
675
680
  fbi-proxy -p 8080 # Custom port
676
681
  fbi-proxy -h 0.0.0.0 -p 3000 # Bind to all interfaces
677
682
  fbi-proxy -d example.com # Only accept *.example.com requests
683
+ fbi-proxy -r ./my-routes.yaml # Use a custom routing config
678
684
  FBI_PROXY_PORT=8080 fbi-proxy # Use environment variable
679
685
 
680
686
  TRY RUN:
@@ -713,6 +719,15 @@ TRY RUN:
713
719
  .env("FBI_PROXY_DOMAIN")
714
720
  .default_value("")
715
721
  )
722
+ .arg(
723
+ Arg::new("routes")
724
+ .short('r')
725
+ .long("routes")
726
+ .value_name("PATH")
727
+ .help("Path to a custom routes.yaml (env: FBI_PROXY_ROUTES, default: bundled)")
728
+ .env("FBI_PROXY_ROUTES")
729
+ .default_value("")
730
+ )
716
731
  .get_matches();
717
732
 
718
733
  let port = matches
@@ -726,20 +741,35 @@ TRY RUN:
726
741
 
727
742
  let host = matches.get_one::<String>("host").unwrap();
728
743
  let domain = matches.get_one::<String>("domain").unwrap();
729
-
744
+ let routes_path = matches.get_one::<String>("routes").unwrap();
745
+
730
746
  let domain_filter = if domain.is_empty() {
731
747
  None
732
748
  } else {
733
749
  Some(domain.clone())
734
750
  };
735
751
 
752
+ // Load routes: either from --routes <path> or the bundled default.
753
+ let compiled_routes = if routes_path.is_empty() {
754
+ load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml")
755
+ } else {
756
+ let src = match std::fs::read_to_string(routes_path) {
757
+ Ok(s) => s,
758
+ Err(e) => {
759
+ eprintln!("error: failed to read --routes file '{}': {}", routes_path, e);
760
+ std::process::exit(2);
761
+ }
762
+ };
763
+ load_routes(&src, &format!("routes file '{}'", routes_path))
764
+ };
765
+
736
766
  let rt = tokio::runtime::Runtime::new().unwrap();
737
767
  rt.block_on(async {
738
768
  info!(
739
769
  "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
740
770
  host, port, domain_filter
741
771
  );
742
- if let Err(e) = start_proxy_server(Some(host), port, domain_filter).await {
772
+ if let Err(e) = start_proxy_server(Some(host), port, domain_filter, compiled_routes).await {
743
773
  error!("Failed to start proxy server: {}", e);
744
774
  }
745
775
  });
package/rs/lib.rs ADDED
@@ -0,0 +1,12 @@
1
+ //! Library entry point for `fbi-proxy`.
2
+ //!
3
+ //! This file exists primarily so that internal modules (like `routes`)
4
+ //! can be unit-tested via `cargo test --lib` without coupling them to
5
+ //! the binary's runtime concerns.
6
+ //!
7
+ //! The binary in `rs/fbi-proxy.rs` does not currently depend on this
8
+ //! library — the routing engine is intentionally not wired into the
9
+ //! live request path yet (see `docs/routing.md` for the migration
10
+ //! plan).
11
+
12
+ pub mod routes;