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/README.md +18 -5
- package/dist/cli.js +2158 -1692
- package/package.json +3 -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 +209 -20
- package/rs/lib.rs +4 -8
- package/rs/metrics.rs +111 -0
- package/rs/tls.rs +243 -0
- package/ts/auth/authConfig.ts +19 -1
- package/ts/cli.ts +148 -2
- package/ts/install-port-forward.ts +149 -0
- package/ts/setup.ts +370 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fbi-proxy",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
911
|
+
let mut builder = http1::Builder::new();
|
|
912
|
+
builder
|
|
785
913
|
.preserve_header_case(true)
|
|
786
|
-
.title_case_headers(true)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
.await
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
//!
|
|
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;
|
|
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
|
+
}
|