fbi-proxy 1.15.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/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,48 +1014,237 @@ 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
+
1137
+ pub struct TlsOptions {
1138
+ /// Apex domain used for SAN entries on the self-signed cert.
1139
+ /// Empty string falls back to `localhost` + `127.0.0.1`.
1140
+ pub domain: String,
1141
+ /// Directory the generated cert+key are persisted to. The same
1142
+ /// fingerprint is reused across boots so browsers can remember
1143
+ /// "trust this exception" once.
1144
+ pub cert_dir: std::path::PathBuf,
1145
+ }
1146
+
786
1147
  pub async fn start_proxy_server(
787
1148
  host: Option<&str>,
788
1149
  port: u16,
789
1150
  domain_filter: Option<String>,
790
1151
  compiled_routes: Vec<CompiledRoute>,
791
1152
  watch_path: Option<String>,
1153
+ conf_dir: Option<std::path::PathBuf>,
1154
+ admin_port: Option<u16>,
1155
+ tls: Option<TlsOptions>,
792
1156
  ) -> Result<(), BoxError> {
793
1157
  let host = host.unwrap_or("127.0.0.1");
794
1158
  let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
795
1159
  let proxy = Arc::new(FBIProxy::new(domain_filter.clone(), compiled_routes));
796
1160
 
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 {
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 {
802
1168
  spawn_routes_watcher(path, proxy.routes_handle());
803
1169
  }
804
1170
 
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);
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);
814
1193
  }
815
- });
816
- } else {
817
- warn!(
818
- "[metrics] FBI_PROXY_METRICS_PORT='{}' is not a valid port number; metrics disabled",
819
- metrics_port_str
820
- );
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),
821
1206
  }
822
1207
  }
823
1208
 
1209
+ let acceptor = match &tls {
1210
+ Some(opts) => {
1211
+ let acc = fbi_proxy::tls::build_acceptor(&opts.domain, &opts.cert_dir)?;
1212
+ // Auto-install the self-signed leaf as a system trust anchor when
1213
+ // running with the privileges to do so. Idempotent — no-op if
1214
+ // already trusted. If unprivileged (and untrusted), this errors and
1215
+ // we surface a clear message rather than booting silently into
1216
+ // "browser warnings forever" mode.
1217
+ let cert_path = fbi_proxy::tls::cert_pem_path(&opts.domain, &opts.cert_dir);
1218
+ match fbi_proxy::tls::install_to_system_trust(&cert_path) {
1219
+ Ok(true) => println!(
1220
+ "TLS: cert installed to system trust store ({})",
1221
+ cert_path.display()
1222
+ ),
1223
+ Ok(false) => {}
1224
+ Err(e) => {
1225
+ eprintln!(
1226
+ "[tls] could not auto-install cert to system trust: {e}\n \
1227
+ start with sudo to install, or accept the browser warning."
1228
+ );
1229
+ }
1230
+ }
1231
+ Some(acc)
1232
+ }
1233
+ None => None,
1234
+ };
1235
+
824
1236
  let listener = TcpListener::bind(addr).await?;
825
1237
 
826
- info!("FBI Proxy server running on http://{}", addr);
827
- println!("FBI Proxy listening on: http://{}", addr);
1238
+ let scheme = if acceptor.is_some() { "https" } else { "http" };
1239
+ info!("FBI Proxy server running on {}://{}", scheme, addr);
1240
+ println!("FBI Proxy listening on: {}://{}", scheme, addr);
1241
+ if let Some(opts) = &tls {
1242
+ println!(
1243
+ "TLS: self-signed cert at {}/{}.pem (browser warning expected — Phase 1)",
1244
+ opts.cert_dir.display(),
1245
+ if opts.domain.is_empty() { "localhost" } else { &opts.domain }
1246
+ );
1247
+ }
828
1248
  if let Some(ref domain) = domain_filter {
829
1249
  if !domain.is_empty() {
830
1250
  println!("Domain filter: Only accepting requests for *.{}", domain);
@@ -856,20 +1276,35 @@ pub async fn start_proxy_server(
856
1276
 
857
1277
  loop {
858
1278
  let (stream, _) = listener.accept().await?;
859
- let io = TokioIo::new(stream);
860
1279
  let proxy = proxy.clone();
1280
+ let acceptor = acceptor.clone();
861
1281
 
862
1282
  tokio::task::spawn(async move {
863
1283
  let service = service_fn(move |req| handle_connection(req, proxy.clone()));
864
1284
 
865
- if let Err(err) = http1::Builder::new()
1285
+ let mut builder = http1::Builder::new();
1286
+ builder
866
1287
  .preserve_header_case(true)
867
- .title_case_headers(true)
868
- .serve_connection(io, service)
869
- .with_upgrades()
870
- .await
871
- {
872
- error!("Error serving connection: {:?}", err);
1288
+ .title_case_headers(true);
1289
+
1290
+ match acceptor {
1291
+ Some(a) => match a.accept(stream).await {
1292
+ Ok(tls_stream) => {
1293
+ let io = TokioIo::new(tls_stream);
1294
+ if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
1295
+ error!("Error serving TLS connection: {:?}", err);
1296
+ }
1297
+ }
1298
+ Err(err) => {
1299
+ error!("TLS handshake failed: {:?}", err);
1300
+ }
1301
+ },
1302
+ None => {
1303
+ let io = TokioIo::new(stream);
1304
+ if let Err(err) = builder.serve_connection(io, service).with_upgrades().await {
1305
+ error!("Error serving connection: {:?}", err);
1306
+ }
1307
+ }
873
1308
  }
874
1309
  });
875
1310
  }
@@ -956,16 +1391,63 @@ TRY RUN:
956
1391
  .env("FBI_PROXY_ROUTES")
957
1392
  .default_value("")
958
1393
  )
1394
+ .arg(
1395
+ Arg::new("tls")
1396
+ .long("tls")
1397
+ .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)")
1398
+ .env("FBI_PROXY_TLS")
1399
+ .num_args(0)
1400
+ .action(clap::ArgAction::SetTrue)
1401
+ )
1402
+ .arg(
1403
+ Arg::new("cert-dir")
1404
+ .long("cert-dir")
1405
+ .value_name("DIR")
1406
+ .help("Directory for the self-signed cert+key (env: FBI_PROXY_CERT_DIR, default: ~/.config/fbi-proxy/certs)")
1407
+ .env("FBI_PROXY_CERT_DIR")
1408
+ .default_value("")
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
+ )
959
1426
  .get_matches();
960
1427
 
961
- let port = matches
962
- .get_one::<String>("port")
963
- .unwrap()
964
- .parse::<u16>()
965
- .unwrap_or_else(|_| {
966
- error!("Invalid port value, using default 2432");
967
- 2432
968
- });
1428
+ let tls_enabled = matches.get_flag("tls");
1429
+
1430
+ // Default port jumps to 443 when --tls is set unless the user explicitly
1431
+ // overrode --port / FBI_PROXY_PORT. Binding :443 needs sudo on most
1432
+ // systems; the helpful failure path is documented in the bind error.
1433
+ let port_source = matches.value_source("port");
1434
+ let port_explicit = matches!(
1435
+ port_source,
1436
+ Some(clap::parser::ValueSource::CommandLine)
1437
+ | Some(clap::parser::ValueSource::EnvVariable)
1438
+ );
1439
+ let port = if tls_enabled && !port_explicit {
1440
+ 443
1441
+ } else {
1442
+ matches
1443
+ .get_one::<String>("port")
1444
+ .unwrap()
1445
+ .parse::<u16>()
1446
+ .unwrap_or_else(|_| {
1447
+ error!("Invalid port value, using default 2432");
1448
+ 2432
1449
+ })
1450
+ };
969
1451
 
970
1452
  let host = matches.get_one::<String>("host").unwrap();
971
1453
  let domain = matches.get_one::<String>("domain").unwrap();
@@ -977,12 +1459,13 @@ TRY RUN:
977
1459
  Some(domain.clone())
978
1460
  };
979
1461
 
980
- // Load routes: either from --routes <path> or the bundled default.
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)
985
- } 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() {
986
1469
  let src = match std::fs::read_to_string(routes_path) {
987
1470
  Ok(s) => s,
988
1471
  Err(e) => {
@@ -993,14 +1476,56 @@ TRY RUN:
993
1476
  (
994
1477
  load_routes(&src, &format!("routes file '{}'", routes_path)),
995
1478
  Some(routes_path.clone()),
1479
+ None,
996
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))
1502
+ };
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
+
1509
+ let cert_dir_raw = matches.get_one::<String>("cert-dir").unwrap();
1510
+ let tls_opts = if tls_enabled {
1511
+ let cert_dir = if cert_dir_raw.is_empty() {
1512
+ fbi_proxy::tls::default_cert_dir()
1513
+ } else {
1514
+ std::path::PathBuf::from(cert_dir_raw)
1515
+ };
1516
+ Some(TlsOptions {
1517
+ domain: domain.clone(),
1518
+ cert_dir,
1519
+ })
1520
+ } else {
1521
+ None
997
1522
  };
998
1523
 
999
1524
  let rt = tokio::runtime::Runtime::new().unwrap();
1000
1525
  rt.block_on(async {
1001
1526
  info!(
1002
- "Starting FBI-Proxy on {}:{} with domain filter: {:?}",
1003
- host, port, domain_filter
1527
+ "Starting FBI-Proxy on {}:{} with domain filter: {:?}, tls: {}",
1528
+ host, port, domain_filter, tls_enabled
1004
1529
  );
1005
1530
  if let Err(e) = start_proxy_server(
1006
1531
  Some(host),
@@ -1008,6 +1533,9 @@ TRY RUN:
1008
1533
  domain_filter,
1009
1534
  compiled_routes,
1010
1535
  watch_path,
1536
+ conf_dir,
1537
+ admin_port,
1538
+ tls_opts,
1011
1539
  )
1012
1540
  .await
1013
1541
  {