fbi-proxy 1.14.0 → 1.16.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.14.0",
3
+ "version": "1.16.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",
@@ -39,6 +39,8 @@
39
39
  "format": "prettier --write .",
40
40
  "lint": "prettier --check .",
41
41
  "prepare": "husky",
42
+ "port-forward:install": "bun ts/install-port-forward.ts",
43
+ "port-forward:uninstall": "bun ts/install-port-forward.ts --uninstall",
42
44
  "start": "bun ts/cli.ts",
43
45
  "start:rs": "./target/release/fbi-proxy",
44
46
  "test": "vitest run",
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};
@@ -38,6 +39,7 @@ pub struct FBIProxy {
38
39
  /// atomically at runtime (hot reload from `--routes`). Reads use
39
40
  /// `.load()` and never block; writes are atomic Arc swaps.
40
41
  compiled_routes: Arc<ArcSwap<Vec<CompiledRoute>>>,
42
+ metrics: Arc<Metrics>,
41
43
  }
42
44
 
43
45
  /*
@@ -90,6 +92,7 @@ impl FBIProxy {
90
92
  number_regex: Regex::new(r"^\d+$").unwrap(),
91
93
  domain_filter,
92
94
  compiled_routes: Arc::new(ArcSwap::from_pointee(compiled_routes)),
95
+ metrics: Metrics::new(),
93
96
  }
94
97
  }
95
98
 
@@ -100,6 +103,12 @@ impl FBIProxy {
100
103
  Arc::clone(&self.compiled_routes)
101
104
  }
102
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
+
103
112
  fn landing_page_html() -> String {
104
113
  r#"<!DOCTYPE html>
105
114
  <html lang="en">
@@ -266,6 +275,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
266
275
  host_header,
267
276
  uri
268
277
  );
278
+ self.metrics.host_rejected_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
279
+ self.metrics.record_status(502);
269
280
  return Ok(Response::builder()
270
281
  .status(StatusCode::BAD_GATEWAY)
271
282
  .body(Full::new(Bytes::from("Bad Gateway: Host not allowed")).map_err(|e| match e {}).boxed())?);
@@ -275,6 +286,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
275
286
  // Serve landing page for root domain access
276
287
  if target_host == "@LANDING" {
277
288
  info!("GET {} => LANDING 200", host_header);
289
+ self.metrics.record_status(200);
278
290
  return Ok(Response::builder()
279
291
  .status(StatusCode::OK)
280
292
  .header("Content-Type", "text/html; charset=utf-8")
@@ -408,6 +420,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
408
420
 
409
421
  // Handle WebSocket upgrade requests
410
422
  if hyper_tungstenite::is_upgrade_request(&req) {
423
+ self.metrics.websocket_upgrades_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
411
424
  return self
412
425
  .handle_websocket_upgrade(req, &target_host, &new_host)
413
426
  .await;
@@ -457,6 +470,7 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
457
470
  original_uri,
458
471
  status.as_u16()
459
472
  );
473
+ self.metrics.record_status(status.as_u16());
460
474
  // Convert the response body back to BoxBody
461
475
  let (parts, body) = response.into_parts();
462
476
  let boxed_body = body.map_err(|e| e).boxed();
@@ -471,6 +485,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
471
485
  original_uri,
472
486
  e
473
487
  );
488
+ self.metrics.upstream_connect_failures_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
489
+ self.metrics.record_status(502);
474
490
  Ok(Response::builder()
475
491
  .status(StatusCode::BAD_GATEWAY)
476
492
  .header("Content-Type", "text/plain")
@@ -484,6 +500,8 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
484
500
  target_host,
485
501
  original_uri
486
502
  );
503
+ self.metrics.upstream_timeouts_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
504
+ self.metrics.record_status(502);
487
505
  Ok(Response::builder()
488
506
  .status(StatusCode::BAD_GATEWAY)
489
507
  .header("Content-Type", "text/plain")
@@ -631,6 +649,50 @@ fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
631
649
  }
632
650
  }
633
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
+
634
696
  /// Parse + compile a routes file without panicking. Returns Err with a
635
697
  /// human-readable message on any failure. Used by the hot-reload path
636
698
  /// where we want to log + keep current rules rather than crash.
@@ -721,12 +783,23 @@ fn spawn_routes_watcher(
721
783
  });
722
784
  }
723
785
 
786
+ pub struct TlsOptions {
787
+ /// Apex domain used for SAN entries on the self-signed cert.
788
+ /// Empty string falls back to `localhost` + `127.0.0.1`.
789
+ pub domain: String,
790
+ /// Directory the generated cert+key are persisted to. The same
791
+ /// fingerprint is reused across boots so browsers can remember
792
+ /// "trust this exception" once.
793
+ pub cert_dir: std::path::PathBuf,
794
+ }
795
+
724
796
  pub async fn start_proxy_server(
725
797
  host: Option<&str>,
726
798
  port: u16,
727
799
  domain_filter: Option<String>,
728
800
  compiled_routes: Vec<CompiledRoute>,
729
801
  watch_path: Option<String>,
802
+ tls: Option<TlsOptions>,
730
803
  ) -> Result<(), BoxError> {
731
804
  let host = host.unwrap_or("127.0.0.1");
732
805
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
@@ -740,10 +813,64 @@ pub async fn start_proxy_server(
740
813
  spawn_routes_watcher(path, proxy.routes_handle());
741
814
  }
742
815
 
816
+ // Metrics: when FBI_PROXY_METRICS_PORT is set, expose a 127.0.0.1-bound
817
+ // Prometheus-text endpoint at /metrics on that port. Off by default —
818
+ // never exposed on the proxy's user-traffic port.
819
+ if let Ok(metrics_port_str) = std::env::var("FBI_PROXY_METRICS_PORT") {
820
+ if let Ok(metrics_port) = metrics_port_str.parse::<u16>() {
821
+ let metrics = proxy.metrics_handle();
822
+ tokio::spawn(async move {
823
+ if let Err(e) = serve_metrics(metrics, metrics_port).await {
824
+ error!("[metrics] server exited: {}", e);
825
+ }
826
+ });
827
+ } else {
828
+ warn!(
829
+ "[metrics] FBI_PROXY_METRICS_PORT='{}' is not a valid port number; metrics disabled",
830
+ metrics_port_str
831
+ );
832
+ }
833
+ }
834
+
835
+ let acceptor = match &tls {
836
+ Some(opts) => {
837
+ let acc = fbi_proxy::tls::build_acceptor(&opts.domain, &opts.cert_dir)?;
838
+ // Auto-install the self-signed leaf as a system trust anchor when
839
+ // running with the privileges to do so. Idempotent — no-op if
840
+ // already trusted. If unprivileged (and untrusted), this errors and
841
+ // we surface a clear message rather than booting silently into
842
+ // "browser warnings forever" mode.
843
+ let cert_path = fbi_proxy::tls::cert_pem_path(&opts.domain, &opts.cert_dir);
844
+ match fbi_proxy::tls::install_to_system_trust(&cert_path) {
845
+ Ok(true) => println!(
846
+ "TLS: cert installed to system trust store ({})",
847
+ cert_path.display()
848
+ ),
849
+ Ok(false) => {}
850
+ Err(e) => {
851
+ eprintln!(
852
+ "[tls] could not auto-install cert to system trust: {e}\n \
853
+ start with sudo to install, or accept the browser warning."
854
+ );
855
+ }
856
+ }
857
+ Some(acc)
858
+ }
859
+ None => None,
860
+ };
861
+
743
862
  let listener = TcpListener::bind(addr).await?;
744
863
 
745
- info!("FBI Proxy server running on http://{}", addr);
746
- println!("FBI Proxy listening on: http://{}", addr);
864
+ let scheme = if acceptor.is_some() { "https" } else { "http" };
865
+ info!("FBI Proxy server running on {}://{}", scheme, addr);
866
+ println!("FBI Proxy listening on: {}://{}", scheme, addr);
867
+ if let Some(opts) = &tls {
868
+ println!(
869
+ "TLS: self-signed cert at {}/{}.pem (browser warning expected — Phase 1)",
870
+ opts.cert_dir.display(),
871
+ if opts.domain.is_empty() { "localhost" } else { &opts.domain }
872
+ );
873
+ }
747
874
  if let Some(ref domain) = domain_filter {
748
875
  if !domain.is_empty() {
749
876
  println!("Domain filter: Only accepting requests for *.{}", domain);
@@ -775,20 +902,35 @@ pub async fn start_proxy_server(
775
902
 
776
903
  loop {
777
904
  let (stream, _) = listener.accept().await?;
778
- let io = TokioIo::new(stream);
779
905
  let proxy = proxy.clone();
906
+ let acceptor = acceptor.clone();
780
907
 
781
908
  tokio::task::spawn(async move {
782
909
  let service = service_fn(move |req| handle_connection(req, proxy.clone()));
783
910
 
784
- if let Err(err) = http1::Builder::new()
911
+ let mut builder = http1::Builder::new();
912
+ builder
785
913
  .preserve_header_case(true)
786
- .title_case_headers(true)
787
- .serve_connection(io, service)
788
- .with_upgrades()
789
- .await
790
- {
791
- error!("Error serving connection: {:?}", err);
914
+ .title_case_headers(true);
915
+
916
+ match acceptor {
917
+ Some(a) => match a.accept(stream).await {
918
+ Ok(tls_stream) => {
919
+ let io = TokioIo::new(tls_stream);
920
+ if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
921
+ error!("Error serving TLS connection: {:?}", err);
922
+ }
923
+ }
924
+ Err(err) => {
925
+ error!("TLS handshake failed: {:?}", err);
926
+ }
927
+ },
928
+ None => {
929
+ let io = TokioIo::new(stream);
930
+ if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
931
+ error!("Error serving connection: {:?}", err);
932
+ }
933
+ }
792
934
  }
793
935
  });
794
936
  }
@@ -875,16 +1017,47 @@ TRY RUN:
875
1017
  .env("FBI_PROXY_ROUTES")
876
1018
  .default_value("")
877
1019
  )
1020
+ .arg(
1021
+ Arg::new("tls")
1022
+ .long("tls")
1023
+ .help("Terminate TLS using a self-signed cert (browser warning expected — Phase 1, no system trust install). Use --port 443 with sudo for the standard HTTPS port. (env: FBI_PROXY_TLS)")
1024
+ .env("FBI_PROXY_TLS")
1025
+ .num_args(0)
1026
+ .action(clap::ArgAction::SetTrue)
1027
+ )
1028
+ .arg(
1029
+ Arg::new("cert-dir")
1030
+ .long("cert-dir")
1031
+ .value_name("DIR")
1032
+ .help("Directory for the self-signed cert+key (env: FBI_PROXY_CERT_DIR, default: ~/.config/fbi-proxy/certs)")
1033
+ .env("FBI_PROXY_CERT_DIR")
1034
+ .default_value("")
1035
+ )
878
1036
  .get_matches();
879
1037
 
880
- let port = matches
881
- .get_one::<String>("port")
882
- .unwrap()
883
- .parse::<u16>()
884
- .unwrap_or_else(|_| {
885
- error!("Invalid port value, using default 2432");
886
- 2432
887
- });
1038
+ let tls_enabled = matches.get_flag("tls");
1039
+
1040
+ // Default port jumps to 443 when --tls is set unless the user explicitly
1041
+ // overrode --port / FBI_PROXY_PORT. Binding :443 needs sudo on most
1042
+ // systems; the helpful failure path is documented in the bind error.
1043
+ let port_source = matches.value_source("port");
1044
+ let port_explicit = matches!(
1045
+ port_source,
1046
+ Some(clap::parser::ValueSource::CommandLine)
1047
+ | Some(clap::parser::ValueSource::EnvVariable)
1048
+ );
1049
+ let port = if tls_enabled && !port_explicit {
1050
+ 443
1051
+ } else {
1052
+ matches
1053
+ .get_one::<String>("port")
1054
+ .unwrap()
1055
+ .parse::<u16>()
1056
+ .unwrap_or_else(|_| {
1057
+ error!("Invalid port value, using default 2432");
1058
+ 2432
1059
+ })
1060
+ };
888
1061
 
889
1062
  let host = matches.get_one::<String>("host").unwrap();
890
1063
  let domain = matches.get_one::<String>("domain").unwrap();
@@ -915,11 +1088,26 @@ TRY RUN:
915
1088
  )
916
1089
  };
917
1090
 
1091
+ let cert_dir_raw = matches.get_one::<String>("cert-dir").unwrap();
1092
+ let tls_opts = if tls_enabled {
1093
+ let cert_dir = if cert_dir_raw.is_empty() {
1094
+ fbi_proxy::tls::default_cert_dir()
1095
+ } else {
1096
+ std::path::PathBuf::from(cert_dir_raw)
1097
+ };
1098
+ Some(TlsOptions {
1099
+ domain: domain.clone(),
1100
+ cert_dir,
1101
+ })
1102
+ } else {
1103
+ None
1104
+ };
1105
+
918
1106
  let rt = tokio::runtime::Runtime::new().unwrap();
919
1107
  rt.block_on(async {
920
1108
  info!(
921
- "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
922
- host, port, domain_filter
1109
+ "Starting FBI-Proxy on {}:{} with domain filter: {:?}, tls: {}",
1110
+ host, port, domain_filter, tls_enabled
923
1111
  );
924
1112
  if let Err(e) = start_proxy_server(
925
1113
  Some(host),
@@ -927,6 +1115,7 @@ TRY RUN:
927
1115
  domain_filter,
928
1116
  compiled_routes,
929
1117
  watch_path,
1118
+ tls_opts,
930
1119
  )
931
1120
  .await
932
1121
  {
package/rs/lib.rs CHANGED
@@ -1,12 +1,8 @@
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;
8
+ pub mod tls;
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
+ }