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 +3 -3
- 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 +216 -8
- package/rs/lib.rs +3 -8
- package/rs/metrics.rs +111 -0
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
|
-
- [
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
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
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//!
|
|
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
|
+
}
|