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 +1 -1
- package/package.json +1 -1
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +81 -0
- package/rs/lib.rs +3 -8
- package/rs/metrics.rs +111 -0
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
|
-
- [
|
|
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
|
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
|
-
//!
|
|
4
|
-
//!
|
|
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
|
+
}
|