TLS guardrails (insecure http refusal, custom CAs) and deployment guide
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user