fbi-proxy 1.9.1 → 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.1",
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,
@@ -590,10 +569,29 @@ async fn handle_connection(
590
569
  }
591
570
  }
592
571
 
593
- 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> {
594
592
  let host = host.unwrap_or("127.0.0.1");
595
593
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
596
- let proxy = Arc::new(FBIProxy::new(domain_filter.clone()));
594
+ let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
597
595
 
598
596
  let listener = TcpListener::bind(addr).await?;
599
597
 
@@ -606,7 +604,7 @@ pub async fn start_proxy_server(host: Option<&str>, port: u16, domain_filter: Op
606
604
  }
607
605
  println!();
608
606
  println!("== HOW IT WORKS ==");
609
- println!("Routes requests based on Host header:");
607
+ println!("Routes requests based on Host header (configurable via routes.yaml):");
610
608
  println!(" 3000 -> localhost:3000 (port as host)");
611
609
  println!(" api--8080 -> api:8080 (host--port syntax)");
612
610
  println!(" 3000.fbi.com -> localhost:3000 (subdomain as port)");
@@ -660,11 +658,11 @@ fn main() {
660
658
 
661
659
  FEATURES:
662
660
  • HTTP and WebSocket proxying with bidirectional forwarding
663
- • Smart host header parsing with multiple routing rules
661
+ • Smart host header parsing with multiple routing rules (configurable via routes.yaml)
664
662
  • Port encoding support for easy local development
665
663
  • Subdomain hoisting for multi-service architectures
666
664
 
667
- HOST PARSING RULES:
665
+ HOST PARSING RULES (default routes.yaml):
668
666
  1. Number host → local port: '3000' → localhost:3000
669
667
  2. Host--port syntax: 'api--3000' → api:3000
670
668
  3. Subdomain hoisting: 'api.service' → service:80 (host: api)
@@ -674,6 +672,7 @@ ENVIRONMENT VARIABLES:
674
672
  FBI_PROXY_PORT Port to listen on (default: 2432)
675
673
  FBI_PROXY_HOST Host/IP address to bind to (default: 127.0.0.1)
676
674
  FBI_PROXY_DOMAIN Domain filter (only accept *.domain requests)
675
+ FBI_PROXY_ROUTES Path to a custom routes.yaml (default: bundled)
677
676
  RUST_LOG Log level (error, warn, info, debug, trace)
678
677
 
679
678
  EXAMPLES:
@@ -681,6 +680,7 @@ EXAMPLES:
681
680
  fbi-proxy -p 8080 # Custom port
682
681
  fbi-proxy -h 0.0.0.0 -p 3000 # Bind to all interfaces
683
682
  fbi-proxy -d example.com # Only accept *.example.com requests
683
+ fbi-proxy -r ./my-routes.yaml # Use a custom routing config
684
684
  FBI_PROXY_PORT=8080 fbi-proxy # Use environment variable
685
685
 
686
686
  TRY RUN:
@@ -719,6 +719,15 @@ TRY RUN:
719
719
  .env("FBI_PROXY_DOMAIN")
720
720
  .default_value("")
721
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
+ )
722
731
  .get_matches();
723
732
 
724
733
  let port = matches
@@ -732,20 +741,35 @@ TRY RUN:
732
741
 
733
742
  let host = matches.get_one::<String>("host").unwrap();
734
743
  let domain = matches.get_one::<String>("domain").unwrap();
735
-
744
+ let routes_path = matches.get_one::<String>("routes").unwrap();
745
+
736
746
  let domain_filter = if domain.is_empty() {
737
747
  None
738
748
  } else {
739
749
  Some(domain.clone())
740
750
  };
741
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
+
742
766
  let rt = tokio::runtime::Runtime::new().unwrap();
743
767
  rt.block_on(async {
744
768
  info!(
745
769
  "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
746
770
  host, port, domain_filter
747
771
  );
748
- 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 {
749
773
  error!("Failed to start proxy server: {}", e);
750
774
  }
751
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;