From 67112c2af3912bfd5fe615c1d7767f6c3d801fed Mon Sep 17 00:00:00 2001 From: George Coles Date: Tue, 15 Sep 2026 07:10:32 -0400 Subject: [PATCH] TLS guardrails (insecure http refusal, custom CAs) and deployment guide --- AGENTS.md | 3 +- Cargo.toml | 5 +++ DEPLOY.md | 101 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 14 ++++++ src/lib.rs | 1 + src/main.rs | 16 +++++++ src/net.rs | 79 +++++++++++++++++++++++++++++++++ src/node.rs | 17 ++++++-- src/relay.rs | 58 +++++++++++++++++++++++-- tests/cli.rs | 34 +++++++++++++++ tests/common/mod.rs | 2 + tests/conformance.rs | 27 ++++++++++++ 12 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 DEPLOY.md create mode 100644 src/net.rs diff --git a/AGENTS.md b/AGENTS.md index c0e09c9..ad01fdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ ## Repo shape - `rfc.txt` (FRX — Federated Retrieval Exchange, Draft 0.5) is the normative spec; `src/` is the Phase 1 `frxd` implementation (single crate, two binaries). - `frxd` is the member node (init/add/index/serve/relay/query/status); `frx` is the thin client (search/query/status). Relay and node roles are separate subcommands. -- Commands: `cargo build`, `cargo test` (90 tests: unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config. +- `DEPLOY.md` documents the TLS/deployment story: members need no TLS (outbound HTTPS), relays terminate TLS with Caddy or a tunnel, `ca_cert` adds private CAs, `allow_insecure` opts into plain http on private networks, and non-loopback `http://` is refused by default. +- Commands: `cargo build`, `cargo test` (97 tests: unit in `src/`; e2e `tests/phase1.rs`; conformance `tests/conformance.rs`; aggregates + member directory `tests/aggregates.rs`; registry `tests/registry.rs`; federation/isolation/admission `tests/federation.rs`; SSE `tests/streaming.rs`; encrypted unicast `tests/encryption.rs`; concurrency/restart `tests/concurrency.rs`; real subprocess CLI `tests/cli.rs`; 1000-doc `tests/scale.rs`; purge-log absence `tests/purges.rs`; shared fixtures `tests/common/mod.rs`). No CI/lint config. - E2E pattern: relay + nodes in-process on ephemeral ports with tempdir corpora; use `tests/common/mod.rs` helpers (`spawn_relay*`, `query_envelope`, `poll_messages`, `register`) for new coverage. Raw relay polls return envelopes (payload under `body`), not response bodies. ## Editing the spec diff --git a/Cargo.toml b/Cargo.toml index 9dcaffe..88fff55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,11 @@ tokio = { version = "1.53.1", features = ["full"] } toml = "1.1.6" x25519-dalek = { version = "2", features = ["static_secrets"] } +[profile.release] +lto = true +codegen-units = 1 +strip = true + [dev-dependencies] bytes = "1.12.1" tempfile = "3.27.0" diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..8563fca --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,101 @@ +# Deploying FRX + +Three roles: **member node** (`frxd serve`), **relay** (`frxd relay`), **registry** (`frxd registry`, run by the MA). + +TLS is only a concern for servers. Members connect outbound and need no domain, port, or certificate. + +## Member node (zero TLS work) + +``` +frxd init --name alice --id alice.frx.example \ + --registry https://ma.example.com/registry.json --ma-key \ + --relay https://relay.example.com --data-dir ./alice-data +frxd add ~/documents --name docs --shared --exposure full +frxd serve +``` + +The node connects outbound over HTTPS, verifies the registry with the pinned MA key, and holds an SSE stream per relay. Nothing inbound, no DNS, no certificates. Members at departmental level can start here. + +## Relay with TLS (one line) + +Run `frxd` on loopback and terminate TLS with Caddy: + +``` +caddy reverse-proxy --from relay.example.com --to 127.0.0.1:7700 +``` + +Caddyfile equivalent: + +``` +relay.example.com { + reverse_proxy 127.0.0.1:7700 +} +``` + +Relay command (peers and registry gated by the MA): + +``` +frxd relay --listen 127.0.0.1:7700 \ + --url https://relay.example.com \ + --peer https://relay2.example.com \ + --registry https://ma.example.com/registry.json --ma-key +``` + +No domain or open ports? Tunnel it: + +``` +frxd relay --listen 127.0.0.1:7700 --allow-insecure +cloudflared tunnel --url http://127.0.0.1:7700 +``` + +`systemd` unit example: + +```ini +[Unit] +Description=FRX relay +After=network-online.target + +[Service] +ExecStart=/usr/local/bin/frxd relay --listen 127.0.0.1:7700 \ + --url https://relay.example.com \ + --registry https://ma.example.com/registry.json --ma-key +Restart=on-failure +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes + +[Install] +WantedBy=multi-user.target +``` + +The same unit shape works for `frxd serve` (add `--config /etc/frxd/frxd.toml`). + +## Registry (MA) + +``` +frxd registry --dir /var/lib/frxd/registry init +frxd registry --dir /var/lib/frxd/registry add alice.frx.example --enc-key +frxd registry --dir /var/lib/frxd/registry serve --listen 127.0.0.1:7800 +``` + +Put the same Caddy in front, or distribute `registry.json` out of band (it is signed, so the channel does not matter). The snapshot is versioned; nodes reject rollback and fail static during outages. + +## Private networks and custom CAs + +- `ca_cert = "/etc/ssl/private-ca.pem"` in `[node]`, or `--ca-cert` on the relay: adds a private/corporate root CA for relay and registry connections. +- `allow_insecure = true` / `--allow-insecure`: explicit opt-in for plain `http://` on a VPN/LAN. Without it, non-loopback `http://` endpoints are refused at startup. +- `http://127.0.0.1` is always allowed for development. + +## Static binary + +``` +rustup target add x86_64-unknown-linux-musl +cargo build --release --target x86_64-unknown-linux-musl +``` + +`[profile.release]` enables LTO and stripping. All dependencies are pure Rust, so the musl build has no system-library requirements. + +## What TLS does and does not cover + +Envelopes are signed and registry snapshots are MA-signed, so TLS is not what protects message authenticity or registry integrity. TLS protects traffic from network observers, authenticates the relay endpoint, and hides mailbox metadata. Unicast response bodies are already encrypted end-to-end to the recipient's X25519 key. diff --git a/src/config.rs b/src/config.rs index f50a83b..20c3e81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,10 @@ pub struct NodeSection { #[serde(default)] pub ma_key: Option, #[serde(default)] + pub ca_cert: Option, + #[serde(default)] + pub allow_insecure: bool, + #[serde(default)] pub dev_bootstrap: bool, #[serde(default = "default_true")] pub responder: bool, @@ -132,6 +136,8 @@ impl Config { relays, registry: None, ma_key: None, + ca_cert: None, + allow_insecure: false, dev_bootstrap: false, responder: true, }, @@ -162,6 +168,14 @@ impl Config { PathBuf::from(&self.index.data_dir) } + pub fn insecure_endpoints(&self) -> Vec { + let mut urls = self.node.relays.clone(); + if let Some(registry) = &self.node.registry { + urls.push(registry.clone()); + } + crate::net::insecure_http_urls(urls) + } + pub fn index_dir(&self) -> PathBuf { self.data_dir().join("index") } diff --git a/src/lib.rs b/src/lib.rs index 43ce497..98cdc74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod crypto; pub mod extract; pub mod index; pub mod message; +pub mod net; pub mod node; pub mod registry; pub mod relay; diff --git a/src/main.rs b/src/main.rs index cda38c4..b1c239c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,10 @@ enum Command { registry: Option, #[arg(long)] ma_key: Option, + #[arg(long)] + ca_cert: Option, + #[arg(long)] + allow_insecure: bool, }, Add { path: PathBuf, @@ -78,6 +82,10 @@ enum Command { registry: Option, #[arg(long)] ma_key: Option, + #[arg(long)] + ca_cert: Option, + #[arg(long)] + allow_insecure: bool, #[arg(long, default_value_t = 3)] max_hops: usize, }, @@ -194,6 +202,8 @@ async fn main() -> Result<()> { id, registry, ma_key, + ca_cert, + allow_insecure, } => { if cli.config.exists() && !force { bail!( @@ -208,6 +218,8 @@ async fn main() -> Result<()> { config.node.id = id; config.node.registry = registry; config.node.ma_key = ma_key; + config.node.ca_cert = ca_cert; + config.node.allow_insecure = allow_insecure; if config.node.registry.is_none() { config.node.dev_bootstrap = true; println!( @@ -262,6 +274,8 @@ async fn main() -> Result<()> { url, registry, ma_key, + ca_cert, + allow_insecure, max_hops, } => { let options = relay::RelayOptions { @@ -270,6 +284,8 @@ async fn main() -> Result<()> { url, registry, ma_key, + ca_cert, + allow_insecure, max_hops, }; let (listener, addr) = relay::bind(&listen).await?; diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..ca530f1 --- /dev/null +++ b/src/net.rs @@ -0,0 +1,79 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use reqwest::Client; + +pub fn build_client(ca_cert: Option<&Path>, timeout: Duration) -> Result { + let mut builder = Client::builder().timeout(timeout); + if let Some(path) = ca_cert { + let pem = std::fs::read(path) + .with_context(|| format!("reading CA certificate {}", path.display()))?; + let certificates = + reqwest::Certificate::from_pem_bundle(&pem).context("parsing CA certificate bundle")?; + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + } + builder.build().context("building http client") +} + +pub fn is_loopback_url(url: &str) -> bool { + let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); + let host_port = rest.split(['/', '?', '#']).next().unwrap_or(""); + let host = if let Some(stripped) = host_port.strip_prefix('[') { + stripped.split(']').next().unwrap_or("") + } else { + host_port.split(':').next().unwrap_or("") + }; + matches!(host, "127.0.0.1" | "localhost" | "::1") +} + +pub fn insecure_http_urls(urls: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + urls.into_iter() + .map(|url| url.as_ref().to_string()) + .filter(|url| url.starts_with("http://") && !is_loopback_url(url)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_detection() { + assert!(is_loopback_url("http://127.0.0.1:7700")); + assert!(is_loopback_url("http://localhost:7700/v1")); + assert!(is_loopback_url("https://[::1]:7700")); + assert!(!is_loopback_url("http://10.0.0.1:7700")); + assert!(!is_loopback_url("https://relay.example.com")); + assert!(!is_loopback_url("http://relay.example.com")); + } + + #[test] + fn insecure_filter_keeps_only_plain_non_loopback() { + let urls = vec![ + "http://127.0.0.1:7700", + "https://relay.example.com", + "http://relay.example.com", + "http://10.0.0.1:7700", + ]; + assert_eq!( + insecure_http_urls(urls), + vec!["http://relay.example.com", "http://10.0.0.1:7700"] + ); + } + + #[test] + fn missing_ca_file_is_an_error() { + let result = build_client( + Some(Path::new("/nonexistent/ca.pem")), + Duration::from_secs(1), + ); + assert!(result.is_err()); + } +} diff --git a/src/node.rs b/src/node.rs index 0d197e7..f9ff6d8 100644 --- a/src/node.rs +++ b/src/node.rs @@ -119,10 +119,19 @@ impl Node { let members_mtime = fs::metadata(&members_path) .and_then(|metadata| metadata.modified()) .ok(); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .context("building http client")?; + if !config.node.allow_insecure { + let insecure = config.insecure_endpoints(); + if !insecure.is_empty() { + return Err(anyhow!( + "refusing plain http endpoints (use https, configure ca_cert, or set allow_insecure for private networks): {}", + insecure.join(", ") + )); + } + } + let client = crate::net::build_client( + config.node.ca_cert.as_deref().map(std::path::Path::new), + Duration::from_secs(15), + )?; let registry = match ( config.node.registry.as_deref(), config.node.ma_key.as_deref(), diff --git a/src/relay.rs b/src/relay.rs index 260c587..1b70c44 100644 --- a/src/relay.rs +++ b/src/relay.rs @@ -31,6 +31,8 @@ pub struct RelayOptions { pub url: Option, pub registry: Option, pub ma_key: Option, + pub ca_cert: Option, + pub allow_insecure: bool, pub max_hops: usize, } @@ -42,6 +44,8 @@ impl Default for RelayOptions { url: None, registry: None, ma_key: None, + ca_cert: None, + allow_insecure: false, max_hops: 3, } } @@ -84,6 +88,23 @@ impl Relay { if !options.peers.is_empty() && options.url.is_none() { return Err(anyhow!("--url is required when --peer is set")); } + if !options.allow_insecure { + let mut urls = options.peers.clone(); + if let Some(registry) = &options.registry { + urls.push(registry.clone()); + } + let insecure = crate::net::insecure_http_urls(urls); + if !insecure.is_empty() { + return Err(anyhow!( + "refusing plain http endpoints (use https, configure --ca-cert, or set --allow-insecure for private networks): {}", + insecure.join(", ") + )); + } + } + let client = crate::net::build_client( + options.ca_cert.as_deref().map(std::path::Path::new), + Duration::from_secs(5), + )?; let relay = Arc::new(Self { inner: Mutex::new(Inner { members: HashMap::new(), @@ -93,9 +114,7 @@ impl Relay { }), options, registry, - client: reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build()?, + client, notify: Notify::new(), }); if let Some(watcher) = &relay.registry { @@ -520,3 +539,36 @@ fn backpressure(status: StatusCode, missed: u64) -> Response { ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_plain_http_peers_off_loopback() { + let options = RelayOptions { + peers: vec!["http://10.0.0.1:7700".to_string()], + url: Some("http://10.0.0.1:7700".to_string()), + ..Default::default() + }; + assert!(Relay::new(options).is_err()); + + let options = RelayOptions { + peers: vec!["http://10.0.0.1:7700".to_string()], + url: Some("http://10.0.0.1:7700".to_string()), + allow_insecure: true, + ..Default::default() + }; + assert!(Relay::new(options).is_ok()); + } + + #[test] + fn loopback_peers_need_no_opt_in() { + let options = RelayOptions { + peers: vec!["http://127.0.0.1:7701".to_string()], + url: Some("http://127.0.0.1:7700".to_string()), + ..Default::default() + }; + assert!(Relay::new(options).is_ok()); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index d43fb80..d9998dd 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -324,6 +324,40 @@ fn cli_key_rotation() { assert!(data.join("key.hex.bak").exists()); } +#[test] +fn cli_relay_refuses_plain_http_off_loopback() { + let output = frxd() + .args([ + "relay", + "--listen", + "127.0.0.1:0", + "--url", + "http://10.0.0.1:1", + "--peer", + "http://10.0.0.1:2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("allow-insecure"), "{stderr}"); + + let port = common::free_port(); + let _service = spawn_service( + &[ + "relay".to_string(), + "--listen".to_string(), + format!("127.0.0.1:{port}"), + "--url".to_string(), + "http://10.0.0.1:1".to_string(), + "--peer".to_string(), + "http://10.0.0.1:2".to_string(), + "--allow-insecure".to_string(), + ], + "relay listening", + ); +} + #[test] fn cli_full_network_pipeline() { let root = tempfile::tempdir().unwrap(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 41a3a91..0735ffc 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -18,6 +18,8 @@ pub fn config_for(dir: &Path, name: &str, relay_url: &str) -> Config { relays: vec![relay_url.to_string()], registry: None, ma_key: None, + ca_cert: None, + allow_insecure: false, dev_bootstrap: true, responder: true, }, diff --git a/tests/conformance.rs b/tests/conformance.rs index 85b5d6e..b58070d 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -448,6 +448,33 @@ async fn rotated_keys_are_accepted_through_previous_listing() { assert!(revoked.is_empty(), "revoked key was still accepted"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn plain_http_transport_is_refused_off_loopback() { + let root = tempfile::tempdir().unwrap(); + let mut config = config_for(&root.path().join("alice"), "alice", "http://10.0.0.1:1"); + let refused = Node::start(config.clone()).await; + assert!( + refused.is_err(), + "plain http to a non-loopback relay must be refused" + ); + + config.node.allow_insecure = true; + let node = Node::start(config).await.unwrap(); + let outcome = frxd::node::control_query( + &format!("http://{}", node.addr), + "rust", + Some(5), + Some(100), + false, + ) + .await + .unwrap(); + assert_eq!( + outcome.pointer("/local/total").and_then(Value::as_u64), + Some(0) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn stale_envelopes_are_rejected() { let relay_url = spawn_relay().await;