fbi-proxy 1.13.0 → 1.15.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/README.md CHANGED
@@ -40,9 +40,9 @@ FBI-Proxy provides easy HTTPS access to your local services with intelligent dom
40
40
 
41
41
  ### Next Up 🚧
42
42
 
43
- - [ ] **Custom Domain Wizard polish** — Print the DNS A-records to add (`*.example.dev → <ip>`) and a Caddyfile-with-DNS-01 sample for Cloudflare during `--reconfigure` on a non-fbi.com domain
44
- - [ ] **Hot Reload** — Watch `routes.yaml` and recompile rules without a restart
45
- - [ ] **Metrics** — `/varz`-style counters: requests, 2xx/4xx/5xx, upstream-connect-failures, sessions-issued, sessions-refreshed (Prometheus format)
43
+ - [x] **Custom Domain Wizard polish** — Print the DNS A-records to add (`*.example.dev → <ip>`) and a Caddyfile-with-DNS-01 sample for Cloudflare during `--reconfigure` on a non-fbi.com domain
44
+ - [x] **Hot Reload** — `routes.yaml` is watched; edits reload atomically without a restart (typos keep the previous rules live)
45
+ - [x] **Metrics** — Set `FBI_PROXY_METRICS_PORT=<port>` to expose Prometheus counters on a separate 127.0.0.1-bound admin endpoint: requests, 2xx/3xx/4xx/5xx, upstream connect failures, upstream timeouts, WebSocket upgrades, host-rejected. (fbi-auth-side session counters still on the to-do.)
46
46
  - [ ] **Health Checks** — Active upstream liveness probes, not just per-request failure detection
47
47
  - [ ] **Cloudflare Tunnel / ngrok Integration** — Expose `*.your-domain` publicly without owning a static IP
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fbi-proxy",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
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::metrics::Metrics;
2
3
  use fbi_proxy::routes::{self, CompiledRoute, RouteHit};
3
4
  use futures_util::{SinkExt, StreamExt};
4
5
  use http_body_util::{BodyExt, Full};
@@ -11,7 +12,8 @@ use hyper_tungstenite::{HyperWebsocket, WebSocketStream};
11
12
  use hyper_util::client::legacy::{Client, connect::HttpConnector};
12
13
  use hyper_util::rt::TokioIo;
13
14
  use hyper_rustls::HttpsConnector;
14
- use log::{error, info};
15
+ use arc_swap::ArcSwap;
16
+ use log::{error, info, warn};
15
17
  use regex::Regex;
16
18
  use std::convert::Infallible;
17
19
  use std::net::SocketAddr;
@@ -33,7 +35,11 @@ pub struct FBIProxy {
33
35
  client: Client<HttpsConnector<HttpConnector>, BoxBody>,
34
36
  number_regex: Regex,
35
37
  domain_filter: Option<String>,
36
- compiled_routes: Vec<CompiledRoute>,
38
+ /// Compiled routes wrapped in an ArcSwap so they can be replaced
39
+ /// atomically at runtime (hot reload from `--routes`). Reads use
40
+ /// `.load()` and never block; writes are atomic Arc swaps.
41
+ compiled_routes: Arc<ArcSwap<Vec<CompiledRoute>>>,
42
+ metrics: Arc<Metrics>,
37
43
  }
38
44
 
39
45
  /*
@@ -85,10 +91,24 @@ impl FBIProxy {
85
91
  client,
86
92
  number_regex: Regex::new(r"^\d+$").unwrap(),
87
93
  domain_filter,
88
- compiled_routes,
94
+ compiled_routes: Arc::new(ArcSwap::from_pointee(compiled_routes)),
95
+ metrics: Metrics::new(),
89
96
  }
90
97
  }
91
98
 
99
+ /// Return a handle to the live routes Arc so callers (e.g. the
100
+ /// file watcher) can swap them at runtime without re-creating the
101
+ /// proxy.
102
+ pub fn routes_handle(&self) -> Arc<ArcSwap<Vec<CompiledRoute>>> {
103
+ Arc::clone(&self.compiled_routes)
104
+ }
105
+
106
+ /// Return a handle to the live metrics counters so the metrics
107
+ /// admin endpoint can read them.
108
+ pub fn metrics_handle(&self) -> Arc<Metrics> {
109
+ Arc::clone(&self.metrics)
110
+ }
111
+
92
112
  fn landing_page_html() -> String {
93
113
  r#"<!DOCTYPE html>
94
114
  <html lang="en">
@@ -216,8 +236,12 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
216
236
  }
217
237
  }
218
238
 
239
+ // Lock-free read of the live routes (may have been swapped by
240
+ // the file watcher mid-flight). `.load()` returns an Arc; we
241
+ // hold a reference for the duration of the match.
242
+ let routes_guard = self.compiled_routes.load();
219
243
  let hit = routes::match_host_with_domain(
220
- &self.compiled_routes,
244
+ routes_guard.as_ref(),
221
245
  host_header,
222
246
  self.domain_filter.as_deref(),
223
247
  )?;
@@ -251,6 +275,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
251
275
  host_header,
252
276
  uri
253
277
  );
278
+ self.metrics.host_rejected_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
279
+ self.metrics.record_status(502);
254
280
  return Ok(Response::builder()
255
281
  .status(StatusCode::BAD_GATEWAY)
256
282
  .body(Full::new(Bytes::from("Bad Gateway: Host not allowed")).map_err(|e| match e {}).boxed())?);
@@ -260,6 +286,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
260
286
  // Serve landing page for root domain access
261
287
  if target_host == "@LANDING" {
262
288
  info!("GET {} => LANDING 200", host_header);
289
+ self.metrics.record_status(200);
263
290
  return Ok(Response::builder()
264
291
  .status(StatusCode::OK)
265
292
  .header("Content-Type", "text/html; charset=utf-8")
@@ -393,6 +420,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
393
420
 
394
421
  // Handle WebSocket upgrade requests
395
422
  if hyper_tungstenite::is_upgrade_request(&req) {
423
+ self.metrics.websocket_upgrades_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
396
424
  return self
397
425
  .handle_websocket_upgrade(req, &target_host, &new_host)
398
426
  .await;
@@ -442,6 +470,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
442
470
  original_uri,
443
471
  status.as_u16()
444
472
  );
473
+ self.metrics.record_status(status.as_u16());
445
474
  // Convert the response body back to BoxBody
446
475
  let (parts, body) = response.into_parts();
447
476
  let boxed_body = body.map_err(|e| e).boxed();
@@ -456,6 +485,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
456
485
  original_uri,
457
486
  e
458
487
  );
488
+ self.metrics.upstream_connect_failures_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
489
+ self.metrics.record_status(502);
459
490
  Ok(Response::builder()
460
491
  .status(StatusCode::BAD_GATEWAY)
461
492
  .header("Content-Type", "text/plain")
@@ -469,6 +500,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
469
500
  target_host,
470
501
  original_uri
471
502
  );
503
+ self.metrics.upstream_timeouts_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
504
+ self.metrics.record_status(502);
472
505
  Ok(Response::builder()
473
506
  .status(StatusCode::BAD_GATEWAY)
474
507
  .header("Content-Type", "text/plain")
@@ -616,16 +649,178 @@ fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
616
649
  }
617
650
  }
618
651
 
652
+ /// Run a tiny HTTP server on 127.0.0.1:{port} that responds to
653
+ /// `GET /metrics` with the current counters in Prometheus text format.
654
+ /// All other paths return 404. The endpoint binds loopback-only so
655
+ /// metrics aren't exposed via the user-facing proxy port.
656
+ async fn serve_metrics(
657
+ metrics: Arc<Metrics>,
658
+ port: u16,
659
+ ) -> Result<(), BoxError> {
660
+ let addr: SocketAddr = format!("127.0.0.1:{}", port).parse()?;
661
+ let listener = TcpListener::bind(addr).await?;
662
+ info!("[metrics] listening on http://{}/metrics", addr);
663
+ println!("[metrics] listening on http://{}/metrics", addr);
664
+
665
+ loop {
666
+ let (stream, _) = listener.accept().await?;
667
+ let metrics = Arc::clone(&metrics);
668
+ let io = TokioIo::new(stream);
669
+ tokio::spawn(async move {
670
+ let service = service_fn(move |req: Request<Incoming>| {
671
+ let metrics = Arc::clone(&metrics);
672
+ async move {
673
+ let resp = if req.uri().path() == "/metrics" {
674
+ let body = metrics.render_prometheus();
675
+ Response::builder()
676
+ .status(StatusCode::OK)
677
+ .header("Content-Type", "text/plain; version=0.0.4")
678
+ .body(Full::new(Bytes::from(body)).map_err(|e| match e {}).boxed())
679
+ .unwrap()
680
+ } else {
681
+ Response::builder()
682
+ .status(StatusCode::NOT_FOUND)
683
+ .body(Full::new(Bytes::from("not found")).map_err(|e| match e {}).boxed())
684
+ .unwrap()
685
+ };
686
+ Ok::<_, Infallible>(resp)
687
+ }
688
+ });
689
+ if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
690
+ error!("[metrics] connection error: {}", e);
691
+ }
692
+ });
693
+ }
694
+ }
695
+
696
+ /// Parse + compile a routes file without panicking. Returns Err with a
697
+ /// human-readable message on any failure. Used by the hot-reload path
698
+ /// where we want to log + keep current rules rather than crash.
699
+ fn try_reload_routes(path: &str) -> Result<Vec<CompiledRoute>, String> {
700
+ let yaml = std::fs::read_to_string(path)
701
+ .map_err(|e| format!("read {}: {}", path, e))?;
702
+ let parsed = routes::parse_yaml(&yaml)
703
+ .map_err(|e| format!("parse {}: {}", path, e))?;
704
+ routes::compile(parsed.routes)
705
+ .map_err(|e| format!("compile {}: {}", path, e))
706
+ }
707
+
708
+ /// Watch a routes file and atomically swap in new rules on change.
709
+ /// Debounces flurries of FS events (some editors save by truncate+
710
+ /// rewrite which can fire multiple notifications in ~ms). On parse or
711
+ /// compile failure, log a warning and leave the existing rules in
712
+ /// place — the running proxy continues to work with whatever last
713
+ /// loaded successfully.
714
+ fn spawn_routes_watcher(
715
+ path: String,
716
+ handle: Arc<ArcSwap<Vec<CompiledRoute>>>,
717
+ ) {
718
+ use notify::{RecursiveMode, Watcher};
719
+ use std::sync::mpsc;
720
+
721
+ std::thread::spawn(move || {
722
+ let (tx, rx) = mpsc::channel();
723
+ let mut watcher = match notify::recommended_watcher(move |res| {
724
+ // Best-effort forward; if the receiver is gone the watcher
725
+ // thread is shutting down anyway.
726
+ let _ = tx.send(res);
727
+ }) {
728
+ Ok(w) => w,
729
+ Err(e) => {
730
+ error!("[routes hot-reload] failed to create watcher: {}", e);
731
+ return;
732
+ }
733
+ };
734
+
735
+ if let Err(e) = watcher.watch(
736
+ std::path::Path::new(&path),
737
+ RecursiveMode::NonRecursive,
738
+ ) {
739
+ error!("[routes hot-reload] failed to watch {}: {}", path, e);
740
+ return;
741
+ }
742
+
743
+ info!("[routes hot-reload] watching {}", path);
744
+
745
+ // Debounce window — wait this long for the burst to subside
746
+ // before reloading.
747
+ const DEBOUNCE: Duration = Duration::from_millis(150);
748
+
749
+ loop {
750
+ // Block for the next event.
751
+ match rx.recv() {
752
+ Ok(Ok(_event)) => {}
753
+ Ok(Err(e)) => {
754
+ warn!("[routes hot-reload] watcher error: {}", e);
755
+ continue;
756
+ }
757
+ Err(_) => break, // sender dropped — proxy shutting down
758
+ }
759
+ // Drain any additional events that arrive during the debounce
760
+ // window, so a single save that fires 3 events triggers
761
+ // exactly one reload.
762
+ loop {
763
+ match rx.recv_timeout(DEBOUNCE) {
764
+ Ok(_) => continue,
765
+ Err(_) => break,
766
+ }
767
+ }
768
+
769
+ match try_reload_routes(&path) {
770
+ Ok(new_routes) => {
771
+ let n = new_routes.len();
772
+ handle.store(Arc::new(new_routes));
773
+ info!("[routes hot-reload] reloaded {} rule(s) from {}", n, path);
774
+ }
775
+ Err(reason) => {
776
+ warn!(
777
+ "[routes hot-reload] reload failed, keeping previous rules: {}",
778
+ reason
779
+ );
780
+ }
781
+ }
782
+ }
783
+ });
784
+ }
785
+
619
786
  pub async fn start_proxy_server(
620
787
  host: Option<&str>,
621
788
  port: u16,
622
789
  domain_filter: Option<String>,
623
790
  compiled_routes: Vec<CompiledRoute>,
791
+ watch_path: Option<String>,
624
792
  ) -> Result<(), BoxError> {
625
793
  let host = host.unwrap_or("127.0.0.1");
626
794
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
627
795
  let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
628
796
 
797
+ // Hot-reload: when the user pointed us at a routes file with
798
+ // --routes, spawn a background watcher that re-parses + swaps in
799
+ // new rules on file change. Failures leave the current rules in
800
+ // place — never crash the running proxy because of a typo in YAML.
801
+ if let Some(path) = watch_path {
802
+ spawn_routes_watcher(path, proxy.routes_handle());
803
+ }
804
+
805
+ // Metrics: when FBI_PROXY_METRICS_PORT is set, expose a 127.0.0.1-bound
806
+ // Prometheus-text endpoint at /metrics on that port. Off by default —
807
+ // never exposed on the proxy's user-traffic port.
808
+ if let Ok(metrics_port_str) = std::env::var("FBI_PROXY_METRICS_PORT") {
809
+ if let Ok(metrics_port) = metrics_port_str.parse::<u16>() {
810
+ let metrics = proxy.metrics_handle();
811
+ tokio::spawn(async move {
812
+ if let Err(e) = serve_metrics(metrics, metrics_port).await {
813
+ error!("[metrics] server exited: {}", e);
814
+ }
815
+ });
816
+ } else {
817
+ warn!(
818
+ "[metrics] FBI_PROXY_METRICS_PORT='{}' is not a valid port number; metrics disabled",
819
+ metrics_port_str
820
+ );
821
+ }
822
+ }
823
+
629
824
  let listener = TcpListener::bind(addr).await?;
630
825
 
631
826
  info!("FBI Proxy server running on http://{}", addr);
@@ -783,8 +978,10 @@ TRY RUN:
783
978
  };
784
979
 
785
980
  // Load routes: either from --routes <path> or the bundled default.
786
- let compiled_routes = if routes_path.is_empty() {
787
- load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml")
981
+ // The bundled YAML is baked into the binary and can't change at
982
+ // runtime, so hot reload only applies to the --routes path.
983
+ let (compiled_routes, watch_path) = if routes_path.is_empty() {
984
+ (load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml"), None)
788
985
  } else {
789
986
  let src = match std::fs::read_to_string(routes_path) {
790
987
  Ok(s) => s,
@@ -793,7 +990,10 @@ TRY RUN:
793
990
  std::process::exit(2);
794
991
  }
795
992
  };
796
- load_routes(&src, &format!("routes file '{}'", routes_path))
993
+ (
994
+ load_routes(&src, &format!("routes file '{}'", routes_path)),
995
+ Some(routes_path.clone()),
996
+ )
797
997
  };
798
998
 
799
999
  let rt = tokio::runtime::Runtime::new().unwrap();
@@ -802,7 +1002,15 @@ TRY RUN:
802
1002
  "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
803
1003
  host, port, domain_filter
804
1004
  );
805
- if let Err(e) = start_proxy_server(Some(host), port, domain_filter, compiled_routes).await {
1005
+ if let Err(e) = start_proxy_server(
1006
+ Some(host),
1007
+ port,
1008
+ domain_filter,
1009
+ compiled_routes,
1010
+ watch_path,
1011
+ )
1012
+ .await
1013
+ {
806
1014
  error!("Failed to start proxy server: {}", e);
807
1015
  }
808
1016
  });
package/rs/lib.rs CHANGED
@@ -1,12 +1,7 @@
1
1
  //! Library entry point for `fbi-proxy`.
2
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).
3
+ //! Exposes internal modules so they can be unit-tested via
4
+ //! `cargo test --lib` and reused by the binary in `rs/fbi-proxy.rs`.
11
5
 
6
+ pub mod metrics;
12
7
  pub mod routes;
package/rs/metrics.rs ADDED
@@ -0,0 +1,111 @@
1
+ //! Simple Prometheus-text metrics for fbi-proxy.
2
+ //!
3
+ //! Tiny counter set, no external prom_client dep — `fmt::Write` is
4
+ //! enough. The counters are atomic so they can be incremented from any
5
+ //! request task without locks; the renderer reads them with `Ordering::
6
+ //! Relaxed` (monotonic counters, dirty reads are fine).
7
+
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ use std::sync::Arc;
10
+
11
+ #[derive(Default)]
12
+ pub struct Metrics {
13
+ pub requests_total: AtomicU64,
14
+ pub status_2xx_total: AtomicU64,
15
+ pub status_3xx_total: AtomicU64,
16
+ pub status_4xx_total: AtomicU64,
17
+ pub status_5xx_total: AtomicU64,
18
+ pub upstream_connect_failures_total: AtomicU64,
19
+ pub upstream_timeouts_total: AtomicU64,
20
+ pub websocket_upgrades_total: AtomicU64,
21
+ pub host_rejected_total: AtomicU64,
22
+ }
23
+
24
+ impl Metrics {
25
+ pub fn new() -> Arc<Self> {
26
+ Arc::new(Self::default())
27
+ }
28
+
29
+ pub fn record_status(&self, status: u16) {
30
+ self.requests_total.fetch_add(1, Ordering::Relaxed);
31
+ let bucket = match status {
32
+ 200..=299 => &self.status_2xx_total,
33
+ 300..=399 => &self.status_3xx_total,
34
+ 400..=499 => &self.status_4xx_total,
35
+ 500..=599 => &self.status_5xx_total,
36
+ _ => return, // other status codes (1xx, etc.) — ignore for now
37
+ };
38
+ bucket.fetch_add(1, Ordering::Relaxed);
39
+ }
40
+
41
+ pub fn render_prometheus(&self) -> String {
42
+ let mut out = String::with_capacity(1024);
43
+ emit_counter(&mut out, "fbi_proxy_requests_total",
44
+ "Total HTTP requests handled by fbi-proxy.",
45
+ self.requests_total.load(Ordering::Relaxed));
46
+ emit_counter(&mut out, "fbi_proxy_status_2xx_total",
47
+ "HTTP responses with status 2xx.",
48
+ self.status_2xx_total.load(Ordering::Relaxed));
49
+ emit_counter(&mut out, "fbi_proxy_status_3xx_total",
50
+ "HTTP responses with status 3xx.",
51
+ self.status_3xx_total.load(Ordering::Relaxed));
52
+ emit_counter(&mut out, "fbi_proxy_status_4xx_total",
53
+ "HTTP responses with status 4xx.",
54
+ self.status_4xx_total.load(Ordering::Relaxed));
55
+ emit_counter(&mut out, "fbi_proxy_status_5xx_total",
56
+ "HTTP responses with status 5xx.",
57
+ self.status_5xx_total.load(Ordering::Relaxed));
58
+ emit_counter(&mut out, "fbi_proxy_upstream_connect_failures_total",
59
+ "Failed TCP/TLS connects to upstream.",
60
+ self.upstream_connect_failures_total.load(Ordering::Relaxed));
61
+ emit_counter(&mut out, "fbi_proxy_upstream_timeouts_total",
62
+ "Upstream requests that exceeded the request timeout.",
63
+ self.upstream_timeouts_total.load(Ordering::Relaxed));
64
+ emit_counter(&mut out, "fbi_proxy_websocket_upgrades_total",
65
+ "WebSocket upgrade requests handled.",
66
+ self.websocket_upgrades_total.load(Ordering::Relaxed));
67
+ emit_counter(&mut out, "fbi_proxy_host_rejected_total",
68
+ "Requests rejected because the Host header didn't match the domain filter or any route.",
69
+ self.host_rejected_total.load(Ordering::Relaxed));
70
+ out
71
+ }
72
+ }
73
+
74
+ fn emit_counter(out: &mut String, name: &str, help: &str, value: u64) {
75
+ use std::fmt::Write;
76
+ let _ = writeln!(out, "# HELP {} {}", name, help);
77
+ let _ = writeln!(out, "# TYPE {} counter", name);
78
+ let _ = writeln!(out, "{} {}", name, value);
79
+ }
80
+
81
+ #[cfg(test)]
82
+ mod tests {
83
+ use super::*;
84
+
85
+ #[test]
86
+ fn record_status_routes_to_correct_bucket() {
87
+ let m = Metrics::new();
88
+ m.record_status(200);
89
+ m.record_status(204);
90
+ m.record_status(302);
91
+ m.record_status(404);
92
+ m.record_status(502);
93
+ m.record_status(502);
94
+ assert_eq!(m.requests_total.load(Ordering::Relaxed), 6);
95
+ assert_eq!(m.status_2xx_total.load(Ordering::Relaxed), 2);
96
+ assert_eq!(m.status_3xx_total.load(Ordering::Relaxed), 1);
97
+ assert_eq!(m.status_4xx_total.load(Ordering::Relaxed), 1);
98
+ assert_eq!(m.status_5xx_total.load(Ordering::Relaxed), 2);
99
+ }
100
+
101
+ #[test]
102
+ fn render_includes_help_and_type_lines() {
103
+ let m = Metrics::new();
104
+ m.record_status(200);
105
+ let out = m.render_prometheus();
106
+ assert!(out.contains("# HELP fbi_proxy_requests_total"));
107
+ assert!(out.contains("# TYPE fbi_proxy_requests_total counter"));
108
+ assert!(out.contains("fbi_proxy_requests_total 1\n"));
109
+ assert!(out.contains("fbi_proxy_status_2xx_total 1\n"));
110
+ }
111
+ }