fbi-proxy 1.13.0 → 1.14.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,8 +40,8 @@ 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
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
45
  - [ ] **Metrics** — `/varz`-style counters: requests, 2xx/4xx/5xx, upstream-connect-failures, sessions-issued, sessions-refreshed (Prometheus format)
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fbi-proxy",
3
- "version": "1.13.0",
3
+ "version": "1.14.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
@@ -11,7 +11,8 @@ use hyper_tungstenite::{HyperWebsocket, WebSocketStream};
11
11
  use hyper_util::client::legacy::{Client, connect::HttpConnector};
12
12
  use hyper_util::rt::TokioIo;
13
13
  use hyper_rustls::HttpsConnector;
14
- use log::{error, info};
14
+ use arc_swap::ArcSwap;
15
+ use log::{error, info, warn};
15
16
  use regex::Regex;
16
17
  use std::convert::Infallible;
17
18
  use std::net::SocketAddr;
@@ -33,7 +34,10 @@ pub struct FBIProxy {
33
34
  client: Client<HttpsConnector<HttpConnector>, BoxBody>,
34
35
  number_regex: Regex,
35
36
  domain_filter: Option<String>,
36
- compiled_routes: Vec<CompiledRoute>,
37
+ /// Compiled routes wrapped in an ArcSwap so they can be replaced
38
+ /// atomically at runtime (hot reload from `--routes`). Reads use
39
+ /// `.load()` and never block; writes are atomic Arc swaps.
40
+ compiled_routes: Arc<ArcSwap<Vec<CompiledRoute>>>,
37
41
  }
38
42
 
39
43
  /*
@@ -85,10 +89,17 @@ impl FBIProxy {
85
89
  client,
86
90
  number_regex: Regex::new(r"^\d+$").unwrap(),
87
91
  domain_filter,
88
- compiled_routes,
92
+ compiled_routes: Arc::new(ArcSwap::from_pointee(compiled_routes)),
89
93
  }
90
94
  }
91
95
 
96
+ /// Return a handle to the live routes Arc so callers (e.g. the
97
+ /// file watcher) can swap them at runtime without re-creating the
98
+ /// proxy.
99
+ pub fn routes_handle(&self) -> Arc<ArcSwap<Vec<CompiledRoute>>> {
100
+ Arc::clone(&self.compiled_routes)
101
+ }
102
+
92
103
  fn landing_page_html() -> String {
93
104
  r#"<!DOCTYPE html>
94
105
  <html lang="en">
@@ -216,8 +227,12 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
216
227
  }
217
228
  }
218
229
 
230
+ // Lock-free read of the live routes (may have been swapped by
231
+ // the file watcher mid-flight). `.load()` returns an Arc; we
232
+ // hold a reference for the duration of the match.
233
+ let routes_guard = self.compiled_routes.load();
219
234
  let hit = routes::match_host_with_domain(
220
- &self.compiled_routes,
235
+ routes_guard.as_ref(),
221
236
  host_header,
222
237
  self.domain_filter.as_deref(),
223
238
  )?;
@@ -616,16 +631,115 @@ fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
616
631
  }
617
632
  }
618
633
 
634
+ /// Parse + compile a routes file without panicking. Returns Err with a
635
+ /// human-readable message on any failure. Used by the hot-reload path
636
+ /// where we want to log + keep current rules rather than crash.
637
+ fn try_reload_routes(path: &str) -> Result<Vec<CompiledRoute>, String> {
638
+ let yaml = std::fs::read_to_string(path)
639
+ .map_err(|e| format!("read {}: {}", path, e))?;
640
+ let parsed = routes::parse_yaml(&yaml)
641
+ .map_err(|e| format!("parse {}: {}", path, e))?;
642
+ routes::compile(parsed.routes)
643
+ .map_err(|e| format!("compile {}: {}", path, e))
644
+ }
645
+
646
+ /// Watch a routes file and atomically swap in new rules on change.
647
+ /// Debounces flurries of FS events (some editors save by truncate+
648
+ /// rewrite which can fire multiple notifications in ~ms). On parse or
649
+ /// compile failure, log a warning and leave the existing rules in
650
+ /// place — the running proxy continues to work with whatever last
651
+ /// loaded successfully.
652
+ fn spawn_routes_watcher(
653
+ path: String,
654
+ handle: Arc<ArcSwap<Vec<CompiledRoute>>>,
655
+ ) {
656
+ use notify::{RecursiveMode, Watcher};
657
+ use std::sync::mpsc;
658
+
659
+ std::thread::spawn(move || {
660
+ let (tx, rx) = mpsc::channel();
661
+ let mut watcher = match notify::recommended_watcher(move |res| {
662
+ // Best-effort forward; if the receiver is gone the watcher
663
+ // thread is shutting down anyway.
664
+ let _ = tx.send(res);
665
+ }) {
666
+ Ok(w) => w,
667
+ Err(e) => {
668
+ error!("[routes hot-reload] failed to create watcher: {}", e);
669
+ return;
670
+ }
671
+ };
672
+
673
+ if let Err(e) = watcher.watch(
674
+ std::path::Path::new(&path),
675
+ RecursiveMode::NonRecursive,
676
+ ) {
677
+ error!("[routes hot-reload] failed to watch {}: {}", path, e);
678
+ return;
679
+ }
680
+
681
+ info!("[routes hot-reload] watching {}", path);
682
+
683
+ // Debounce window — wait this long for the burst to subside
684
+ // before reloading.
685
+ const DEBOUNCE: Duration = Duration::from_millis(150);
686
+
687
+ loop {
688
+ // Block for the next event.
689
+ match rx.recv() {
690
+ Ok(Ok(_event)) => {}
691
+ Ok(Err(e)) => {
692
+ warn!("[routes hot-reload] watcher error: {}", e);
693
+ continue;
694
+ }
695
+ Err(_) => break, // sender dropped — proxy shutting down
696
+ }
697
+ // Drain any additional events that arrive during the debounce
698
+ // window, so a single save that fires 3 events triggers
699
+ // exactly one reload.
700
+ loop {
701
+ match rx.recv_timeout(DEBOUNCE) {
702
+ Ok(_) => continue,
703
+ Err(_) => break,
704
+ }
705
+ }
706
+
707
+ match try_reload_routes(&path) {
708
+ Ok(new_routes) => {
709
+ let n = new_routes.len();
710
+ handle.store(Arc::new(new_routes));
711
+ info!("[routes hot-reload] reloaded {} rule(s) from {}", n, path);
712
+ }
713
+ Err(reason) => {
714
+ warn!(
715
+ "[routes hot-reload] reload failed, keeping previous rules: {}",
716
+ reason
717
+ );
718
+ }
719
+ }
720
+ }
721
+ });
722
+ }
723
+
619
724
  pub async fn start_proxy_server(
620
725
  host: Option<&str>,
621
726
  port: u16,
622
727
  domain_filter: Option<String>,
623
728
  compiled_routes: Vec<CompiledRoute>,
729
+ watch_path: Option<String>,
624
730
  ) -> Result<(), BoxError> {
625
731
  let host = host.unwrap_or("127.0.0.1");
626
732
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
627
733
  let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
628
734
 
735
+ // Hot-reload: when the user pointed us at a routes file with
736
+ // --routes, spawn a background watcher that re-parses + swaps in
737
+ // new rules on file change. Failures leave the current rules in
738
+ // place — never crash the running proxy because of a typo in YAML.
739
+ if let Some(path) = watch_path {
740
+ spawn_routes_watcher(path, proxy.routes_handle());
741
+ }
742
+
629
743
  let listener = TcpListener::bind(addr).await?;
630
744
 
631
745
  info!("FBI Proxy server running on http://{}", addr);
@@ -783,8 +897,10 @@ TRY RUN:
783
897
  };
784
898
 
785
899
  // 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")
900
+ // The bundled YAML is baked into the binary and can't change at
901
+ // runtime, so hot reload only applies to the --routes path.
902
+ let (compiled_routes, watch_path) = if routes_path.is_empty() {
903
+ (load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml"), None)
788
904
  } else {
789
905
  let src = match std::fs::read_to_string(routes_path) {
790
906
  Ok(s) => s,
@@ -793,7 +909,10 @@ TRY RUN:
793
909
  std::process::exit(2);
794
910
  }
795
911
  };
796
- load_routes(&src, &format!("routes file '{}'", routes_path))
912
+ (
913
+ load_routes(&src, &format!("routes file '{}'", routes_path)),
914
+ Some(routes_path.clone()),
915
+ )
797
916
  };
798
917
 
799
918
  let rt = tokio::runtime::Runtime::new().unwrap();
@@ -802,7 +921,15 @@ TRY RUN:
802
921
  "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
803
922
  host, port, domain_filter
804
923
  );
805
- if let Err(e) = start_proxy_server(Some(host), port, domain_filter, compiled_routes).await {
924
+ if let Err(e) = start_proxy_server(
925
+ Some(host),
926
+ port,
927
+ domain_filter,
928
+ compiled_routes,
929
+ watch_path,
930
+ )
931
+ .await
932
+ {
806
933
  error!("Failed to start proxy server: {}", e);
807
934
  }
808
935
  });