fbi-proxy 1.16.0 → 1.18.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/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) -> Option<(String, String)> {
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
- // Exact-domain match serve landing page. Only relevant when a
232
- // domain filter is configured.
233
- if let Some(ref domain) = self.domain_filter {
234
- if !domain.is_empty() && host_without_port.eq_ignore_ascii_case(domain) {
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
- // 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.
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::match_host_with_domain(
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
- let RouteHit { target, host_header: rewrite, .. } = hit;
250
- let new_host = rewrite.unwrap_or_else(|| Self::host_from_target(&target));
251
- Some((target, new_host))
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 parsed_host = self.route(&host_header);
265
-
266
- // If domain filter rejects the host, return 502 Bad Gateway
267
- let (target_host, new_host) = match parsed_host {
268
- Some(hosts) => hosts,
269
- None => {
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(&ws_url).await {
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 let Err(_) = upstream_sink.send(msg).await {
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 let Err(_) = client_sink.send(msg).await {
659
+ if client_sink.send(msg).await.is_err() {
605
660
  break;
606
661
  }
607
662
  }
608
- Err(_) => break,
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
- /// 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(
710
+ /// Shared state for the loopback admin/control server.
711
+ struct AdminState {
657
712
  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);
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 metrics = Arc::clone(&metrics);
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 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
- }
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!("[metrics] connection error: {}", e);
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: when the user pointed us at a routes file with
809
- // --routes, spawn a background watcher that re-parses + swaps in
810
- // new rules on file change. Failures leave the current rules in
811
- // place — never crash the running proxy because of a typo in YAML.
812
- if let Some(path) = watch_path {
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
- // 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);
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
- } else {
828
- warn!(
829
- "[metrics] FBI_PROXY_METRICS_PORT='{}' is not a valid port number; metrics disabled",
830
- metrics_port_str
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: either from --routes <path> or the bundled default.
1073
- // The bundled YAML is baked into the binary and can't change at
1074
- // runtime, so hot reload only applies to the --routes path.
1075
- let (compiled_routes, watch_path) = if routes_path.is_empty() {
1076
- (load_routes(BUNDLED_ROUTES_YAML, "bundled routes.yaml"), None)
1077
- } else {
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