TLS guardrails (insecure http refusal, custom CAs) and deployment guide

This commit is contained in:
George Coles
2026-09-15 07:10:32 -04:00
parent ec98275613
commit 67112c2af3
12 changed files with 349 additions and 8 deletions
+14
View File
@@ -27,6 +27,10 @@ pub struct NodeSection {
#[serde(default)]
pub ma_key: Option<String>,
#[serde(default)]
pub ca_cert: Option<String>,
#[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<String> {
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")
}
+1
View File
@@ -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;
+16
View File
@@ -39,6 +39,10 @@ enum Command {
registry: Option<String>,
#[arg(long)]
ma_key: Option<String>,
#[arg(long)]
ca_cert: Option<String>,
#[arg(long)]
allow_insecure: bool,
},
Add {
path: PathBuf,
@@ -78,6 +82,10 @@ enum Command {
registry: Option<String>,
#[arg(long)]
ma_key: Option<String>,
#[arg(long)]
ca_cert: Option<String>,
#[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?;
+79
View File
@@ -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<Client> {
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<I, S>(urls: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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());
}
}
+13 -4
View File
@@ -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(),
+55 -3
View File
@@ -31,6 +31,8 @@ pub struct RelayOptions {
pub url: Option<String>,
pub registry: Option<String>,
pub ma_key: Option<String>,
pub ca_cert: Option<String>,
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());
}
}