fbi-proxy 1.14.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
@@ -42,7 +42,7 @@ FBI-Proxy provides easy HTTPS access to your local services with intelligent dom
42
42
 
43
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
44
  - [x] **Hot Reload** — `routes.yaml` is watched; edits reload atomically without a restart (typos keep the previous rules live)
45
- - [ ] **Metrics** — `/varz`-style counters: requests, 2xx/4xx/5xx, upstream-connect-failures, sessions-issued, sessions-refreshed (Prometheus format)
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.14.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};
@@ -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.
@@ -740,6 +802,25 @@ pub async fn start_proxy_server(
740
802
  spawn_routes_watcher(path, proxy.routes_handle());
741
803
  }
742
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
+
743
824
  let listener = TcpListener::bind(addr).await?;
744
825
 
745
826
  info!("FBI Proxy server running on http://{}", addr);
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
+ }