fbi-proxy 1.16.0 → 1.17.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/dist/cli.js +7542 -161
- package/package.json +2 -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 +520 -100
- package/rs/routes.rs +226 -31
- package/ts/adminClient.ts +124 -0
- package/ts/cli.ts +11 -1
- package/ts/install-port-forward.ts +4 -1
- package/ts/routes.ts +50 -0
- package/ts/rulesCli.ts +166 -0
- package/ts/setup.ts +5 -1
package/rs/fbi-proxy.rs
CHANGED
|
@@ -69,6 +69,16 @@ When `--domain` (or `FBI_PROXY_DOMAIN`) is set, only hosts ending with
|
|
|
69
69
|
that suffix are accepted. The exact-domain host (e.g. `fbi.com`) serves
|
|
70
70
|
the landing page.
|
|
71
71
|
*/
|
|
72
|
+
/// Outcome of routing a request through the rule engine.
|
|
73
|
+
enum RouteDecision {
|
|
74
|
+
/// Forward to `target` (upstream authority) with this outgoing `Host`.
|
|
75
|
+
Hit { target: String, host: String },
|
|
76
|
+
/// Serve the built-in landing page (apex domain, no matching rule).
|
|
77
|
+
Landing,
|
|
78
|
+
/// Reject with 502 (host not allowed / no matching rule).
|
|
79
|
+
Reject,
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
impl FBIProxy {
|
|
73
83
|
pub fn new(domain_filter: Option<String>, compiled_routes: Vec<CompiledRoute>) -> Self {
|
|
74
84
|
let mut http = HttpConnector::new();
|
|
@@ -221,34 +231,42 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
221
231
|
///
|
|
222
232
|
/// Returns None if the host is rejected (filter mismatch or no
|
|
223
233
|
/// matching rule).
|
|
224
|
-
fn route(&self, host_header: &str) ->
|
|
234
|
+
fn route(&self, host_header: &str, req_path: &str) -> RouteDecision {
|
|
225
235
|
// Drop port if present.
|
|
226
236
|
let host_without_port = match host_header.find(':') {
|
|
227
237
|
Some(i) => &host_header[..i],
|
|
228
238
|
None => host_header,
|
|
229
239
|
};
|
|
230
240
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return Some(("@LANDING".to_string(), "@LANDING".to_string()));
|
|
236
|
-
}
|
|
237
|
-
}
|
|
241
|
+
// CONNECT and some clients carry an empty path — treat it as "/"
|
|
242
|
+
// so host-level rules (path "/" or path-less) still match. Path
|
|
243
|
+
// routing only applies to L7 requests we terminate ourselves.
|
|
244
|
+
let req_path = if req_path.is_empty() { "/" } else { req_path };
|
|
238
245
|
|
|
239
|
-
//
|
|
240
|
-
// the
|
|
241
|
-
//
|
|
246
|
+
// Try the rules first (path-aware: longest matching prefix wins).
|
|
247
|
+
// Lock-free read of the live routes (may have been swapped by the
|
|
248
|
+
// watcher/admin API mid-flight). `.load()` returns an Arc held
|
|
249
|
+
// for the duration of the match.
|
|
242
250
|
let routes_guard = self.compiled_routes.load();
|
|
243
|
-
let hit = routes::
|
|
251
|
+
if let Some(hit) = routes::match_request(
|
|
244
252
|
routes_guard.as_ref(),
|
|
245
253
|
host_header,
|
|
254
|
+
req_path,
|
|
246
255
|
self.domain_filter.as_deref(),
|
|
247
|
-
)
|
|
256
|
+
) {
|
|
257
|
+
let RouteHit { target, host_header: rewrite, .. } = hit;
|
|
258
|
+
let new_host = rewrite.unwrap_or_else(|| Self::host_from_target(&target));
|
|
259
|
+
return RouteDecision::Hit { target, host: new_host };
|
|
260
|
+
}
|
|
248
261
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
Some(
|
|
262
|
+
// No rule matched. Serve the landing page for an exact-apex
|
|
263
|
+
// request when a domain filter is configured; otherwise reject.
|
|
264
|
+
if let Some(ref domain) = self.domain_filter {
|
|
265
|
+
if !domain.is_empty() && host_without_port.eq_ignore_ascii_case(domain) {
|
|
266
|
+
return RouteDecision::Landing;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
RouteDecision::Reject
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
pub async fn handle_request(&self, req: Request<Incoming>) -> Result<Response<BoxBody>, BoxError> {
|
|
@@ -260,21 +278,22 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
260
278
|
.unwrap_or("localhost")
|
|
261
279
|
.to_string();
|
|
262
280
|
|
|
263
|
-
// Route the host via the rule engine.
|
|
264
|
-
let
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
281
|
+
// Route the host + path via the rule engine.
|
|
282
|
+
let req_path = req.uri().path().to_string();
|
|
283
|
+
let (target_host, new_host) = match self.route(&host_header, &req_path) {
|
|
284
|
+
RouteDecision::Hit { target, host } => (target, host),
|
|
285
|
+
RouteDecision::Landing => {
|
|
286
|
+
info!("GET {} => LANDING 200", host_header);
|
|
287
|
+
self.metrics.record_status(200);
|
|
288
|
+
return Ok(Response::builder()
|
|
289
|
+
.status(StatusCode::OK)
|
|
290
|
+
.header("Content-Type", "text/html; charset=utf-8")
|
|
291
|
+
.body(Full::new(Bytes::from(Self::landing_page_html())).map_err(|e| match e {}).boxed())?);
|
|
292
|
+
}
|
|
293
|
+
RouteDecision::Reject => {
|
|
270
294
|
let method = req.method();
|
|
271
295
|
let uri = req.uri();
|
|
272
|
-
info!(
|
|
273
|
-
"{} {} => REJECTED{} 502",
|
|
274
|
-
method,
|
|
275
|
-
host_header,
|
|
276
|
-
uri
|
|
277
|
-
);
|
|
296
|
+
info!("{} {} => REJECTED{} 502", method, host_header, uri);
|
|
278
297
|
self.metrics.host_rejected_total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
279
298
|
self.metrics.record_status(502);
|
|
280
299
|
return Ok(Response::builder()
|
|
@@ -283,16 +302,6 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
283
302
|
}
|
|
284
303
|
};
|
|
285
304
|
|
|
286
|
-
// Serve landing page for root domain access
|
|
287
|
-
if target_host == "@LANDING" {
|
|
288
|
-
info!("GET {} => LANDING 200", host_header);
|
|
289
|
-
self.metrics.record_status(200);
|
|
290
|
-
return Ok(Response::builder()
|
|
291
|
-
.status(StatusCode::OK)
|
|
292
|
-
.header("Content-Type", "text/html; charset=utf-8")
|
|
293
|
-
.body(Full::new(Bytes::from(Self::landing_page_html())).map_err(|e| match e {}).boxed())?);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
305
|
let method = req.method().clone();
|
|
297
306
|
let original_uri = req.uri().clone();
|
|
298
307
|
|
|
@@ -526,9 +535,52 @@ npx fbi-proxy -d fbi.example.com # Only accept *.fbi.example.com</pre>
|
|
|
526
535
|
uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/")
|
|
527
536
|
);
|
|
528
537
|
|
|
538
|
+
// Build the upstream handshake request from the URL (this generates
|
|
539
|
+
// the mandatory Host / Sec-WebSocket-Key / -Version / Upgrade
|
|
540
|
+
// headers), then forward only the WebSocket subprotocol/extension
|
|
541
|
+
// negotiation headers from the client.
|
|
542
|
+
//
|
|
543
|
+
// Deliberately do NOT forward `Origin`: VS Code `serve-web` accepts
|
|
544
|
+
// the 101 handshake but then drops the management socket immediately
|
|
545
|
+
// (101 → silent close, observed as a dead file tree behind the
|
|
546
|
+
// proxy) when it sees a cross-origin `Origin` like https://fbi.com
|
|
547
|
+
// against its localhost listener. The direct 127.0.0.1 path works
|
|
548
|
+
// precisely because it is same-origin. `cookie`/`authorization`
|
|
549
|
+
// are likewise omitted — serve-web runs `--without-connection-token`
|
|
550
|
+
// and they only invite extra rejection paths.
|
|
551
|
+
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
552
|
+
let mut upstream_req = match ws_url.as_str().into_client_request() {
|
|
553
|
+
Ok(r) => r,
|
|
554
|
+
Err(e) => {
|
|
555
|
+
error!("WS :ws:{} => invalid upstream request {}: {}", target_host, uri, e);
|
|
556
|
+
return Ok(Response::builder()
|
|
557
|
+
.status(StatusCode::BAD_GATEWAY)
|
|
558
|
+
.body(Full::new(Bytes::from(format!("502 Bad Gateway: invalid WebSocket target: {}", e))).map_err(|e| match e {}).boxed())?);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
// Forward the subprotocol, but deliberately NOT
|
|
562
|
+
// `Sec-WebSocket-Extensions`. If we advertise the client's
|
|
563
|
+
// `permessage-deflate` to the upstream, serve-web enables
|
|
564
|
+
// compression and sets the RSV1 bit on its frames — but this is a
|
|
565
|
+
// separate proxy<->upstream socket whose tungstenite client did
|
|
566
|
+
// not negotiate deflate, so it rejects those frames with
|
|
567
|
+
// "Reserved bits are non-zero" and tears the management channel
|
|
568
|
+
// down right after the 101 (the file tree never loads). Leaving
|
|
569
|
+
// extensions unset keeps upstream frames uncompressed and valid.
|
|
570
|
+
if let Some(v) = req.headers().get("sec-websocket-protocol") {
|
|
571
|
+
upstream_req.headers_mut().insert("sec-websocket-protocol", v.clone());
|
|
572
|
+
}
|
|
573
|
+
// Present a same-origin Origin to the upstream so serve-web's
|
|
574
|
+
// origin check (which would otherwise see https://fbi.com against
|
|
575
|
+
// its localhost listener) is satisfied through the proxy.
|
|
576
|
+
let upstream_origin = format!("{}://{}", scheme, authority);
|
|
577
|
+
if let Ok(v) = HeaderValue::from_str(&upstream_origin) {
|
|
578
|
+
upstream_req.headers_mut().insert("origin", v);
|
|
579
|
+
}
|
|
580
|
+
|
|
529
581
|
// Step 1: Connect to upstream WebSocket FIRST before upgrading client
|
|
530
582
|
// This ensures we can return proper errors if upstream is unavailable
|
|
531
|
-
let (upstream_ws, _) = match connect_async(
|
|
583
|
+
let (upstream_ws, _) = match connect_async(upstream_req).await {
|
|
532
584
|
Ok(ws) => ws,
|
|
533
585
|
Err(e) => {
|
|
534
586
|
error!("WS :ws:{} => :ws:{}{} 502 (upstream connection failed: {})", target_host, target_host, uri, e);
|
|
@@ -587,7 +639,7 @@ async fn handle_websocket_forwarding(
|
|
|
587
639
|
while let Some(msg) = client_stream.next().await {
|
|
588
640
|
match msg {
|
|
589
641
|
Ok(msg) => {
|
|
590
|
-
if
|
|
642
|
+
if upstream_sink.send(msg).await.is_err() {
|
|
591
643
|
break;
|
|
592
644
|
}
|
|
593
645
|
}
|
|
@@ -596,16 +648,22 @@ async fn handle_websocket_forwarding(
|
|
|
596
648
|
}
|
|
597
649
|
};
|
|
598
650
|
|
|
599
|
-
// Forward messages from upstream to client
|
|
651
|
+
// Forward messages from upstream to client. A protocol error here is
|
|
652
|
+
// worth surfacing — it's how the permessage-deflate RSV1 mismatch
|
|
653
|
+
// ("Reserved bits are non-zero") manifested before we stopped
|
|
654
|
+
// advertising the client's WS extensions upstream.
|
|
600
655
|
let upstream_to_client = async {
|
|
601
656
|
while let Some(msg) = upstream_stream.next().await {
|
|
602
657
|
match msg {
|
|
603
658
|
Ok(msg) => {
|
|
604
|
-
if
|
|
659
|
+
if client_sink.send(msg).await.is_err() {
|
|
605
660
|
break;
|
|
606
661
|
}
|
|
607
662
|
}
|
|
608
|
-
Err(
|
|
663
|
+
Err(e) => {
|
|
664
|
+
warn!("[ws] upstream recv error: {}", e);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
609
667
|
}
|
|
610
668
|
}
|
|
611
669
|
};
|
|
@@ -649,50 +707,223 @@ fn load_routes(yaml_src: &str, source_label: &str) -> Vec<CompiledRoute> {
|
|
|
649
707
|
}
|
|
650
708
|
}
|
|
651
709
|
|
|
652
|
-
///
|
|
653
|
-
|
|
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(
|
|
710
|
+
/// Shared state for the loopback admin/control server.
|
|
711
|
+
struct AdminState {
|
|
657
712
|
metrics: Arc<Metrics>,
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
713
|
+
routes_handle: Arc<ArcSwap<Vec<CompiledRoute>>>,
|
|
714
|
+
/// conf.d directory. `Some` enables the mutating `/rules` endpoints;
|
|
715
|
+
/// `None` (legacy `--routes` single-file mode) makes them 409.
|
|
716
|
+
conf_dir: Option<std::path::PathBuf>,
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
fn admin_text(status: StatusCode, content_type: &str, body: String) -> Response<BoxBody> {
|
|
720
|
+
Response::builder()
|
|
721
|
+
.status(status)
|
|
722
|
+
.header("Content-Type", content_type)
|
|
723
|
+
.body(Full::new(Bytes::from(body)).map_err(|e| match e {}).boxed())
|
|
724
|
+
.unwrap()
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
fn admin_json(status: StatusCode, body: String) -> Response<BoxBody> {
|
|
728
|
+
admin_text(status, "application/json", body)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
fn admin_err(status: StatusCode, msg: &str) -> Response<BoxBody> {
|
|
732
|
+
admin_json(status, serde_json::json!({ "error": msg }).to_string())
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/// A namespace must be a safe filename stem (it becomes `<ns>.yaml`).
|
|
736
|
+
fn is_valid_namespace(ns: &str) -> bool {
|
|
737
|
+
!ns.is_empty()
|
|
738
|
+
&& ns.len() <= 64
|
|
739
|
+
&& ns.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/// Serialize the live compiled routes to a JSON array for `GET /rules`.
|
|
743
|
+
fn rules_to_json(routes: &[CompiledRoute]) -> String {
|
|
744
|
+
let arr: Vec<serde_json::Value> = routes
|
|
745
|
+
.iter()
|
|
746
|
+
.map(|r| {
|
|
747
|
+
serde_json::json!({
|
|
748
|
+
"namespace": r.namespace,
|
|
749
|
+
"name": r.name,
|
|
750
|
+
"match": r.match_pattern,
|
|
751
|
+
"path": r.path_prefix,
|
|
752
|
+
"target": r.target_template,
|
|
753
|
+
"headers": r.header_templates,
|
|
754
|
+
})
|
|
755
|
+
})
|
|
756
|
+
.collect();
|
|
757
|
+
serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async fn handle_admin(req: Request<Incoming>, state: Arc<AdminState>) -> Response<BoxBody> {
|
|
761
|
+
let method = req.method().clone();
|
|
762
|
+
let path = req.uri().path().to_string();
|
|
763
|
+
|
|
764
|
+
match (&method, path.as_str()) {
|
|
765
|
+
(&Method::GET, "/metrics") => {
|
|
766
|
+
admin_text(StatusCode::OK, "text/plain; version=0.0.4", state.metrics.render_prometheus())
|
|
767
|
+
}
|
|
768
|
+
(&Method::GET, "/rules") => {
|
|
769
|
+
let routes = state.routes_handle.load();
|
|
770
|
+
admin_json(StatusCode::OK, rules_to_json(routes.as_ref()))
|
|
771
|
+
}
|
|
772
|
+
(&Method::PUT, p) if p.starts_with("/rules/") => {
|
|
773
|
+
let ns = p.trim_start_matches("/rules/").to_string();
|
|
774
|
+
handle_put_rules(req, state, ns).await
|
|
775
|
+
}
|
|
776
|
+
(&Method::DELETE, p) if p.starts_with("/rules/") => {
|
|
777
|
+
let ns = p.trim_start_matches("/rules/").to_string();
|
|
778
|
+
handle_delete_rules(state, ns).await
|
|
779
|
+
}
|
|
780
|
+
_ => admin_err(StatusCode::NOT_FOUND, "not found"),
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/// Reconcile namespace `ns` to the rules in the request body: validate +
|
|
785
|
+
/// compile, write `<conf_dir>/<ns>.yaml`, then rebuild + atomically swap
|
|
786
|
+
/// the live route set. Returns the new merged rule list on success.
|
|
787
|
+
async fn handle_put_rules(
|
|
788
|
+
req: Request<Incoming>,
|
|
789
|
+
state: Arc<AdminState>,
|
|
790
|
+
ns: String,
|
|
791
|
+
) -> Response<BoxBody> {
|
|
792
|
+
let conf_dir = match &state.conf_dir {
|
|
793
|
+
Some(d) => d.clone(),
|
|
794
|
+
None => {
|
|
795
|
+
return admin_err(
|
|
796
|
+
StatusCode::CONFLICT,
|
|
797
|
+
"rule mutation requires conf.d mode (started with --routes single-file mode)",
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
if !is_valid_namespace(&ns) {
|
|
802
|
+
return admin_err(
|
|
803
|
+
StatusCode::BAD_REQUEST,
|
|
804
|
+
"invalid namespace (allowed: A-Za-z0-9_-, max 64 chars)",
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let body_bytes = match req.into_body().collect().await {
|
|
809
|
+
Ok(b) => b.to_bytes(),
|
|
810
|
+
Err(e) => return admin_err(StatusCode::BAD_REQUEST, &format!("read body: {}", e)),
|
|
811
|
+
};
|
|
812
|
+
let src = String::from_utf8_lossy(&body_bytes);
|
|
813
|
+
let parsed = match routes::parse_yaml(&src) {
|
|
814
|
+
Ok(p) => p,
|
|
815
|
+
Err(e) => return admin_err(StatusCode::BAD_REQUEST, &format!("parse: {}", e)),
|
|
816
|
+
};
|
|
817
|
+
// Validate by compiling under this namespace *before* touching disk.
|
|
818
|
+
if let Err(e) = routes::compile_in_namespace(parsed.routes.clone(), &ns) {
|
|
819
|
+
return admin_err(StatusCode::BAD_REQUEST, &format!("compile: {}", e));
|
|
820
|
+
}
|
|
821
|
+
let yaml = match serde_yaml::to_string(&parsed) {
|
|
822
|
+
Ok(y) => y,
|
|
823
|
+
Err(e) => return admin_err(StatusCode::INTERNAL_SERVER_ERROR, &format!("serialize: {}", e)),
|
|
824
|
+
};
|
|
825
|
+
let frag_path = conf_dir.join(format!("{}.yaml", ns));
|
|
826
|
+
if let Err(e) = std::fs::write(&frag_path, yaml) {
|
|
827
|
+
return admin_err(
|
|
828
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
829
|
+
&format!("write {}: {}", frag_path.display(), e),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
match rebuild_routes(&conf_dir, BUNDLED_ROUTES_YAML) {
|
|
833
|
+
Ok(merged) => state.routes_handle.store(Arc::new(merged)),
|
|
834
|
+
Err(e) => {
|
|
835
|
+
return admin_err(
|
|
836
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
837
|
+
&format!("rebuild after write: {}", e),
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
info!("[admin] applied {} rule(s) to namespace '{}'", parsed.routes.len(), ns);
|
|
842
|
+
let routes = state.routes_handle.load();
|
|
843
|
+
admin_json(StatusCode::OK, rules_to_json(routes.as_ref()))
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/// Remove namespace `ns`: delete its fragment, rebuild + swap.
|
|
847
|
+
async fn handle_delete_rules(state: Arc<AdminState>, ns: String) -> Response<BoxBody> {
|
|
848
|
+
let conf_dir = match &state.conf_dir {
|
|
849
|
+
Some(d) => d.clone(),
|
|
850
|
+
None => {
|
|
851
|
+
return admin_err(
|
|
852
|
+
StatusCode::CONFLICT,
|
|
853
|
+
"rule mutation requires conf.d mode (started with --routes single-file mode)",
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
if !is_valid_namespace(&ns) {
|
|
858
|
+
return admin_err(
|
|
859
|
+
StatusCode::BAD_REQUEST,
|
|
860
|
+
"invalid namespace (allowed: A-Za-z0-9_-, max 64 chars)",
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
let frag_path = conf_dir.join(format!("{}.yaml", ns));
|
|
864
|
+
let existed = frag_path.exists();
|
|
865
|
+
if existed {
|
|
866
|
+
if let Err(e) = std::fs::remove_file(&frag_path) {
|
|
867
|
+
return admin_err(
|
|
868
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
869
|
+
&format!("remove {}: {}", frag_path.display(), e),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
match rebuild_routes(&conf_dir, BUNDLED_ROUTES_YAML) {
|
|
874
|
+
Ok(merged) => state.routes_handle.store(Arc::new(merged)),
|
|
875
|
+
Err(e) => {
|
|
876
|
+
return admin_err(
|
|
877
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
878
|
+
&format!("rebuild after delete: {}", e),
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
info!("[admin] removed namespace '{}' (existed: {})", ns, existed);
|
|
883
|
+
admin_json(StatusCode::OK, serde_json::json!({ "ok": true, "removed": existed }).to_string())
|
|
884
|
+
}
|
|
664
885
|
|
|
886
|
+
/// Run the loopback admin/control server on an already-bound listener.
|
|
887
|
+
/// Serves `GET /metrics`, `GET /rules`, `PUT /rules/{ns}`,
|
|
888
|
+
/// `DELETE /rules/{ns}`. Binds loopback-only so it is never reachable
|
|
889
|
+
/// from the user-facing proxy port.
|
|
890
|
+
async fn serve_admin(state: Arc<AdminState>, listener: TcpListener) -> Result<(), BoxError> {
|
|
665
891
|
loop {
|
|
666
892
|
let (stream, _) = listener.accept().await?;
|
|
667
|
-
let
|
|
893
|
+
let state = Arc::clone(&state);
|
|
668
894
|
let io = TokioIo::new(stream);
|
|
669
895
|
tokio::spawn(async move {
|
|
670
896
|
let service = service_fn(move |req: Request<Incoming>| {
|
|
671
|
-
let
|
|
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
|
-
}
|
|
897
|
+
let state = Arc::clone(&state);
|
|
898
|
+
async move { Ok::<_, Infallible>(handle_admin(req, state).await) }
|
|
688
899
|
});
|
|
689
900
|
if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
|
|
690
|
-
error!("[
|
|
901
|
+
error!("[admin] connection error: {}", e);
|
|
691
902
|
}
|
|
692
903
|
});
|
|
693
904
|
}
|
|
694
905
|
}
|
|
695
906
|
|
|
907
|
+
/// Publish `runtime.json` (next to conf.d) so `fbi-proxy up/down/ps` can
|
|
908
|
+
/// discover the running daemon's admin port. Single-instance model:
|
|
909
|
+
/// last writer wins.
|
|
910
|
+
fn write_runtime_json(conf_dir: &std::path::Path, admin_port: u16, proxy_port: u16) {
|
|
911
|
+
let runtime_path = conf_dir
|
|
912
|
+
.parent()
|
|
913
|
+
.unwrap_or_else(|| std::path::Path::new("."))
|
|
914
|
+
.join("runtime.json");
|
|
915
|
+
let body = serde_json::json!({
|
|
916
|
+
"adminPort": admin_port,
|
|
917
|
+
"proxyPort": proxy_port,
|
|
918
|
+
"pid": std::process::id(),
|
|
919
|
+
"confDir": conf_dir.to_string_lossy(),
|
|
920
|
+
});
|
|
921
|
+
match std::fs::write(&runtime_path, serde_json::to_string_pretty(&body).unwrap_or_default()) {
|
|
922
|
+
Ok(_) => info!("[admin] wrote {}", runtime_path.display()),
|
|
923
|
+
Err(e) => warn!("[admin] could not write {}: {}", runtime_path.display(), e),
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
696
927
|
/// Parse + compile a routes file without panicking. Returns Err with a
|
|
697
928
|
/// human-readable message on any failure. Used by the hot-reload path
|
|
698
929
|
/// where we want to log + keep current rules rather than crash.
|
|
@@ -783,6 +1014,126 @@ fn spawn_routes_watcher(
|
|
|
783
1014
|
});
|
|
784
1015
|
}
|
|
785
1016
|
|
|
1017
|
+
/// The conf.d directory holding per-namespace route fragments.
|
|
1018
|
+
/// `FBI_PROXY_CONF_DIR` overrides; default `~/.config/fbi-proxy/conf.d`.
|
|
1019
|
+
fn default_conf_dir() -> std::path::PathBuf {
|
|
1020
|
+
if let Ok(d) = std::env::var("FBI_PROXY_CONF_DIR") {
|
|
1021
|
+
if !d.is_empty() {
|
|
1022
|
+
return std::path::PathBuf::from(d);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
|
1026
|
+
std::path::PathBuf::from(home).join(".config/fbi-proxy/conf.d")
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/// Rebuild the merged compiled route set: bundled defaults (namespace
|
|
1030
|
+
/// `"default"`) followed by every `<conf_dir>/*.yaml` fragment (namespace
|
|
1031
|
+
/// = file stem), sorted by filename so ordering is deterministic.
|
|
1032
|
+
/// Returns Err with a human-readable message on any parse/compile error
|
|
1033
|
+
/// so callers can log + keep the previous rules instead of crashing.
|
|
1034
|
+
fn rebuild_routes(
|
|
1035
|
+
conf_dir: &std::path::Path,
|
|
1036
|
+
bundled_yaml: &str,
|
|
1037
|
+
) -> Result<Vec<CompiledRoute>, String> {
|
|
1038
|
+
let parsed = routes::parse_yaml(bundled_yaml)
|
|
1039
|
+
.map_err(|e| format!("parse bundled routes: {}", e))?;
|
|
1040
|
+
let mut merged = routes::compile_in_namespace(parsed.routes, "default")
|
|
1041
|
+
.map_err(|e| format!("compile bundled routes: {}", e))?;
|
|
1042
|
+
|
|
1043
|
+
if conf_dir.is_dir() {
|
|
1044
|
+
let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(conf_dir)
|
|
1045
|
+
.map_err(|e| format!("read {}: {}", conf_dir.display(), e))?
|
|
1046
|
+
.filter_map(|e| e.ok().map(|e| e.path()))
|
|
1047
|
+
.filter(|p| {
|
|
1048
|
+
matches!(
|
|
1049
|
+
p.extension().and_then(|x| x.to_str()),
|
|
1050
|
+
Some("yaml") | Some("yml")
|
|
1051
|
+
)
|
|
1052
|
+
})
|
|
1053
|
+
.collect();
|
|
1054
|
+
paths.sort();
|
|
1055
|
+
for path in paths {
|
|
1056
|
+
let ns = path
|
|
1057
|
+
.file_stem()
|
|
1058
|
+
.and_then(|s| s.to_str())
|
|
1059
|
+
.unwrap_or("")
|
|
1060
|
+
.to_string();
|
|
1061
|
+
let src = std::fs::read_to_string(&path)
|
|
1062
|
+
.map_err(|e| format!("read {}: {}", path.display(), e))?;
|
|
1063
|
+
let parsed = routes::parse_yaml(&src)
|
|
1064
|
+
.map_err(|e| format!("parse {}: {}", path.display(), e))?;
|
|
1065
|
+
let compiled = routes::compile_in_namespace(parsed.routes, &ns)
|
|
1066
|
+
.map_err(|e| format!("compile {}: {}", path.display(), e))?;
|
|
1067
|
+
merged.extend(compiled);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
Ok(merged)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/// Watch the conf.d directory and atomically swap in the merged rule set
|
|
1074
|
+
/// on any change. Same debounce + fail-soft policy as the single-file
|
|
1075
|
+
/// watcher: a bad fragment logs a warning and leaves current rules in
|
|
1076
|
+
/// place. External edits and admin-API writes both converge here because
|
|
1077
|
+
/// disk is the source of truth.
|
|
1078
|
+
fn spawn_conf_dir_watcher(
|
|
1079
|
+
conf_dir: std::path::PathBuf,
|
|
1080
|
+
bundled_yaml: &'static str,
|
|
1081
|
+
handle: Arc<ArcSwap<Vec<CompiledRoute>>>,
|
|
1082
|
+
) {
|
|
1083
|
+
use notify::{RecursiveMode, Watcher};
|
|
1084
|
+
use std::sync::mpsc;
|
|
1085
|
+
|
|
1086
|
+
std::thread::spawn(move || {
|
|
1087
|
+
let (tx, rx) = mpsc::channel();
|
|
1088
|
+
let mut watcher = match notify::recommended_watcher(move |res| {
|
|
1089
|
+
let _ = tx.send(res);
|
|
1090
|
+
}) {
|
|
1091
|
+
Ok(w) => w,
|
|
1092
|
+
Err(e) => {
|
|
1093
|
+
error!("[routes hot-reload] failed to create watcher: {}", e);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
if let Err(e) = watcher.watch(&conf_dir, RecursiveMode::NonRecursive) {
|
|
1099
|
+
error!(
|
|
1100
|
+
"[routes hot-reload] failed to watch {}: {}",
|
|
1101
|
+
conf_dir.display(),
|
|
1102
|
+
e
|
|
1103
|
+
);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
info!("[routes hot-reload] watching {}", conf_dir.display());
|
|
1107
|
+
|
|
1108
|
+
const DEBOUNCE: Duration = Duration::from_millis(150);
|
|
1109
|
+
loop {
|
|
1110
|
+
match rx.recv() {
|
|
1111
|
+
Ok(Ok(_event)) => {}
|
|
1112
|
+
Ok(Err(e)) => {
|
|
1113
|
+
warn!("[routes hot-reload] watcher error: {}", e);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
Err(_) => break,
|
|
1117
|
+
}
|
|
1118
|
+
while rx.recv_timeout(DEBOUNCE).is_ok() {}
|
|
1119
|
+
|
|
1120
|
+
match rebuild_routes(&conf_dir, bundled_yaml) {
|
|
1121
|
+
Ok(new_routes) => {
|
|
1122
|
+
let n = new_routes.len();
|
|
1123
|
+
handle.store(Arc::new(new_routes));
|
|
1124
|
+
info!("[routes hot-reload] reloaded {} rule(s) from {}", n, conf_dir.display());
|
|
1125
|
+
}
|
|
1126
|
+
Err(reason) => {
|
|
1127
|
+
warn!(
|
|
1128
|
+
"[routes hot-reload] reload failed, keeping previous rules: {}",
|
|
1129
|
+
reason
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
786
1137
|
pub struct TlsOptions {
|
|
787
1138
|
/// Apex domain used for SAN entries on the self-signed cert.
|
|
788
1139
|
/// Empty string falls back to `localhost` + `127.0.0.1`.
|
|
@@ -799,36 +1150,59 @@ pub async fn start_proxy_server(
|
|
|
799
1150
|
domain_filter: Option<String>,
|
|
800
1151
|
compiled_routes: Vec<CompiledRoute>,
|
|
801
1152
|
watch_path: Option<String>,
|
|
1153
|
+
conf_dir: Option<std::path::PathBuf>,
|
|
1154
|
+
admin_port: Option<u16>,
|
|
802
1155
|
tls: Option<TlsOptions>,
|
|
803
1156
|
) -> Result<(), BoxError> {
|
|
804
1157
|
let host = host.unwrap_or("127.0.0.1");
|
|
805
1158
|
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
|
806
1159
|
let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
|
|
807
1160
|
|
|
808
|
-
// Hot-reload
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// place — never crash
|
|
812
|
-
if let Some(
|
|
1161
|
+
// Hot-reload. In conf.d mode (the default) we watch the directory and
|
|
1162
|
+
// re-merge bundled + all fragments on change. In legacy single-file
|
|
1163
|
+
// mode (--routes <file>) we watch just that file. Failures leave the
|
|
1164
|
+
// current rules in place — never crash on a typo in YAML.
|
|
1165
|
+
if let Some(dir) = conf_dir.clone() {
|
|
1166
|
+
spawn_conf_dir_watcher(dir, BUNDLED_ROUTES_YAML, proxy.routes_handle());
|
|
1167
|
+
} else if let Some(path) = watch_path {
|
|
813
1168
|
spawn_routes_watcher(path, proxy.routes_handle());
|
|
814
1169
|
}
|
|
815
1170
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1171
|
+
// Admin/control server: always on, loopback-only. Serves /metrics and
|
|
1172
|
+
// (in conf.d mode) the /rules API. Binds an ephemeral port unless
|
|
1173
|
+
// --admin-port / FBI_PROXY_ADMIN_PORT (or legacy FBI_PROXY_METRICS_PORT)
|
|
1174
|
+
// pins one. The bound port is published to runtime.json so the CLI can
|
|
1175
|
+
// find it.
|
|
1176
|
+
{
|
|
1177
|
+
let pinned = admin_port.or_else(|| {
|
|
1178
|
+
std::env::var("FBI_PROXY_METRICS_PORT")
|
|
1179
|
+
.ok()
|
|
1180
|
+
.and_then(|s| s.parse::<u16>().ok())
|
|
1181
|
+
});
|
|
1182
|
+
let admin_addr = format!("127.0.0.1:{}", pinned.unwrap_or(0));
|
|
1183
|
+
match TcpListener::bind(&admin_addr).await {
|
|
1184
|
+
Ok(listener) => {
|
|
1185
|
+
let bound = listener
|
|
1186
|
+
.local_addr()
|
|
1187
|
+
.map(|a| a.port())
|
|
1188
|
+
.unwrap_or_else(|_| pinned.unwrap_or(0));
|
|
1189
|
+
info!("[admin] listening on http://127.0.0.1:{}", bound);
|
|
1190
|
+
println!("[admin] control API on http://127.0.0.1:{}/ (/metrics, /rules)", bound);
|
|
1191
|
+
if let Some(dir) = &conf_dir {
|
|
1192
|
+
write_runtime_json(dir, bound, port);
|
|
825
1193
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1194
|
+
let state = Arc::new(AdminState {
|
|
1195
|
+
metrics: proxy.metrics_handle(),
|
|
1196
|
+
routes_handle: proxy.routes_handle(),
|
|
1197
|
+
conf_dir: conf_dir.clone(),
|
|
1198
|
+
});
|
|
1199
|
+
tokio::spawn(async move {
|
|
1200
|
+
if let Err(e) = serve_admin(state, listener).await {
|
|
1201
|
+
error!("[admin] server exited: {}", e);
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
Err(e) => warn!("[admin] could not bind {}: {} — admin API disabled", admin_addr, e),
|
|
832
1206
|
}
|
|
833
1207
|
}
|
|
834
1208
|
|
|
@@ -1033,6 +1407,22 @@ TRY RUN:
|
|
|
1033
1407
|
.env("FBI_PROXY_CERT_DIR")
|
|
1034
1408
|
.default_value("")
|
|
1035
1409
|
)
|
|
1410
|
+
.arg(
|
|
1411
|
+
Arg::new("conf-dir")
|
|
1412
|
+
.long("conf-dir")
|
|
1413
|
+
.value_name("DIR")
|
|
1414
|
+
.help("conf.d directory of per-namespace route fragments (env: FBI_PROXY_CONF_DIR, default: ~/.config/fbi-proxy/conf.d). Ignored when --routes is set.")
|
|
1415
|
+
.env("FBI_PROXY_CONF_DIR")
|
|
1416
|
+
.default_value("")
|
|
1417
|
+
)
|
|
1418
|
+
.arg(
|
|
1419
|
+
Arg::new("admin-port")
|
|
1420
|
+
.long("admin-port")
|
|
1421
|
+
.value_name("PORT")
|
|
1422
|
+
.help("Loopback admin/control port for /metrics and the /rules API (env: FBI_PROXY_ADMIN_PORT, default: ephemeral). FBI_PROXY_METRICS_PORT is accepted as an alias.")
|
|
1423
|
+
.env("FBI_PROXY_ADMIN_PORT")
|
|
1424
|
+
.default_value("")
|
|
1425
|
+
)
|
|
1036
1426
|
.get_matches();
|
|
1037
1427
|
|
|
1038
1428
|
let tls_enabled = matches.get_flag("tls");
|
|
@@ -1069,12 +1459,13 @@ TRY RUN:
|
|
|
1069
1459
|
Some(domain.clone())
|
|
1070
1460
|
};
|
|
1071
1461
|
|
|
1072
|
-
// Load routes
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1462
|
+
// Load routes. Two modes:
|
|
1463
|
+
// * --routes <file> → legacy single-file mode (file fully replaces
|
|
1464
|
+
// the bundled defaults; hot-reload watches that one file).
|
|
1465
|
+
// * otherwise → conf.d mode (default): merge bundled defaults
|
|
1466
|
+
// with every <conf_dir>/*.yaml fragment; the admin API + CLI
|
|
1467
|
+
// manage fragments at runtime, and the dir is hot-reloaded.
|
|
1468
|
+
let (compiled_routes, watch_path, conf_dir) = if !routes_path.is_empty() {
|
|
1078
1469
|
let src = match std::fs::read_to_string(routes_path) {
|
|
1079
1470
|
Ok(s) => s,
|
|
1080
1471
|
Err(e) => {
|
|
@@ -1085,9 +1476,36 @@ TRY RUN:
|
|
|
1085
1476
|
(
|
|
1086
1477
|
load_routes(&src, &format!("routes file '{}'", routes_path)),
|
|
1087
1478
|
Some(routes_path.clone()),
|
|
1479
|
+
None,
|
|
1088
1480
|
)
|
|
1481
|
+
} else {
|
|
1482
|
+
let cli_conf_dir = matches.get_one::<String>("conf-dir").map(String::as_str).unwrap_or("");
|
|
1483
|
+
let dir = if cli_conf_dir.is_empty() {
|
|
1484
|
+
default_conf_dir()
|
|
1485
|
+
} else {
|
|
1486
|
+
std::path::PathBuf::from(cli_conf_dir)
|
|
1487
|
+
};
|
|
1488
|
+
if let Err(e) = std::fs::create_dir_all(&dir) {
|
|
1489
|
+
eprintln!("warning: could not create conf dir '{}': {}", dir.display(), e);
|
|
1490
|
+
}
|
|
1491
|
+
let compiled = match rebuild_routes(&dir, BUNDLED_ROUTES_YAML) {
|
|
1492
|
+
Ok(c) => c,
|
|
1493
|
+
Err(reason) => {
|
|
1494
|
+
eprintln!(
|
|
1495
|
+
"warning: failed to load conf.d ({}); falling back to bundled defaults",
|
|
1496
|
+
reason
|
|
1497
|
+
);
|
|
1498
|
+
load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml")
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
(compiled, None, Some(dir))
|
|
1089
1502
|
};
|
|
1090
1503
|
|
|
1504
|
+
let admin_port = matches
|
|
1505
|
+
.get_one::<String>("admin-port")
|
|
1506
|
+
.filter(|s| !s.is_empty())
|
|
1507
|
+
.and_then(|s| s.parse::<u16>().ok());
|
|
1508
|
+
|
|
1091
1509
|
let cert_dir_raw = matches.get_one::<String>("cert-dir").unwrap();
|
|
1092
1510
|
let tls_opts = if tls_enabled {
|
|
1093
1511
|
let cert_dir = if cert_dir_raw.is_empty() {
|
|
@@ -1115,6 +1533,8 @@ TRY RUN:
|
|
|
1115
1533
|
domain_filter,
|
|
1116
1534
|
compiled_routes,
|
|
1117
1535
|
watch_path,
|
|
1536
|
+
conf_dir,
|
|
1537
|
+
admin_port,
|
|
1118
1538
|
tls_opts,
|
|
1119
1539
|
)
|
|
1120
1540
|
.await
|