use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
static DEFAULT_HTTPS: AtomicBool = AtomicBool::new(false);
pub fn set_default_https(on: bool) {
DEFAULT_HTTPS.store(on, Ordering::Relaxed);
}
pub fn cluster_scheme() -> &'static str {
match std::env::var("SOLIDB_CLUSTER_SCHEME") {
Ok(v) if v.trim().eq_ignore_ascii_case("http") => "http",
Ok(v) if !v.trim().is_empty() => "https",
_ => {
if DEFAULT_HTTPS.load(Ordering::Relaxed) {
"https"
} else {
"http"
}
}
}
}
fn ws_scheme_for(http_scheme: &str) -> &'static str {
if http_scheme == "https" {
"wss"
} else {
"ws"
}
}
fn join(scheme: &str, addr: &str, path: &str) -> String {
let sep = if path.starts_with('/') || path.is_empty() {
""
} else {
"/"
};
format!("{}://{}{}{}", scheme, addr, sep, path)
}
pub fn peer_url(addr: &str, path: &str) -> String {
peer_url_with(cluster_scheme(), addr, path)
}
pub fn peer_url_with(scheme: &str, addr: &str, path: &str) -> String {
if let Some((given, rest)) = addr.split_once("://") {
return join(given, rest.trim_end_matches('/'), path);
}
join(scheme, addr, path)
}
pub fn peer_ws_url(addr: &str, path: &str) -> String {
peer_ws_url_with(cluster_scheme(), addr, path)
}
pub fn peer_ws_url_with(http_scheme: &str, addr: &str, path: &str) -> String {
if let Some((given, rest)) = addr.split_once("://") {
let ws = match given {
"https" | "wss" => "wss",
_ => "ws",
};
return join(ws, rest.trim_end_matches('/'), path);
}
join(ws_scheme_for(http_scheme), addr, path)
}
fn env_secs(name: &str, default: u64) -> Duration {
let secs = std::env::var(name)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|s| *s > 0)
.unwrap_or(default);
Duration::from_secs(secs)
}
pub fn request_timeout() -> Duration {
env_secs("SOLIDB_CLUSTER_HTTP_TIMEOUT_SECS", 60)
}
pub fn read_timeout() -> Duration {
env_secs("SOLIDB_CLUSTER_HTTP_READ_TIMEOUT_SECS", 60)
}
pub fn stream_timeout() -> Duration {
env_secs("SOLIDB_CLUSTER_STREAM_TIMEOUT_SECS", 6 * 3600)
}
pub const HDR_TS: &str = "X-Cluster-Ts";
pub const HDR_NONCE: &str = "X-Cluster-Nonce";
pub const HDR_SIG: &str = "X-Cluster-Sig";
pub const SIGNED_REQUEST_MAX_SKEW_MS: u64 = 5 * 60 * 1000;
fn request_mac(secret: &str, ts: u64, nonce: &str, method: &str, path_and_query: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any size");
mac.update(
format!(
"{}\n{}\n{}\n{}",
ts,
nonce,
method.to_ascii_uppercase(),
path_and_query
)
.as_bytes(),
);
hex::encode(mac.finalize().into_bytes())
}
pub fn sign_request(
secret: &str,
method: &str,
path_and_query: &str,
) -> [(&'static str, String); 3] {
let ts = chrono::Utc::now().timestamp_millis() as u64;
let nonce = uuid::Uuid::new_v4().to_string();
let sig = request_mac(secret, ts, &nonce, method, path_and_query);
[(HDR_TS, ts.to_string()), (HDR_NONCE, nonce), (HDR_SIG, sig)]
}
pub fn verify_signed_request(
secret: &str,
method: &str,
path_and_query: &str,
ts: &str,
nonce: &str,
sig: &str,
) -> bool {
if secret.is_empty() || nonce.is_empty() || nonce.len() > 128 {
return false;
}
let Ok(ts) = ts.parse::<u64>() else {
return false;
};
let now = chrono::Utc::now().timestamp_millis() as u64;
if ts.abs_diff(now) > SIGNED_REQUEST_MAX_SKEW_MS {
return false;
}
let expected = request_mac(secret, ts, nonce, method, path_and_query);
if !crate::server::auth::constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
return false;
}
super::transport::remember_nonce(&format!("req:{}", nonce))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peer_urls_follow_the_scheme() {
assert_eq!(
peer_url_with("http", "10.0.0.2:6745", "/_api/x"),
"http://10.0.0.2:6745/_api/x"
);
assert_eq!(
peer_url_with("https", "10.0.0.2:6745", "_api/x"),
"https://10.0.0.2:6745/_api/x"
);
assert_eq!(
peer_ws_url_with("https", "n:1", "/_api/ws"),
"wss://n:1/_api/ws"
);
assert_eq!(
peer_ws_url_with("http", "n:1", "/_api/ws"),
"ws://n:1/_api/ws"
);
}
#[test]
fn an_address_with_its_own_scheme_keeps_it() {
assert_eq!(
peer_url_with("http", "https://peer:6745/", "/_api/x"),
"https://peer:6745/_api/x"
);
assert_eq!(
peer_ws_url_with("http", "https://peer:6745", "/ws"),
"wss://peer:6745/ws"
);
}
const SECRET: &str = "a-shared-cluster-secret-at-least-32-bytes";
#[test]
fn a_signed_request_verifies_once() {
let [(_, ts), (_, nonce), (_, sig)] = sign_request(SECRET, "post", "/_api/a?b=1");
assert!(verify_signed_request(
SECRET,
"POST",
"/_api/a?b=1",
&ts,
&nonce,
&sig
));
assert!(!verify_signed_request(
SECRET,
"POST",
"/_api/a?b=1",
&ts,
&nonce,
&sig
));
}
#[test]
fn a_signed_request_cannot_be_re_aimed() {
let [(_, ts), (_, nonce), (_, sig)] = sign_request(SECRET, "GET", "/_api/a");
assert!(!verify_signed_request(
SECRET, "DELETE", "/_api/a", &ts, &nonce, &sig
));
assert!(!verify_signed_request(
SECRET, "GET", "/_api/b", &ts, &nonce, &sig
));
assert!(!verify_signed_request(
"other-secret",
"GET",
"/_api/a",
&ts,
&nonce,
&sig
));
}
#[test]
fn a_stale_signed_request_is_refused() {
let ts = (chrono::Utc::now().timestamp_millis() as u64) - SIGNED_REQUEST_MAX_SKEW_MS - 1000;
let nonce = "n-stale";
let sig = request_mac(SECRET, ts, nonce, "GET", "/x");
assert!(!verify_signed_request(
SECRET,
"GET",
"/x",
&ts.to_string(),
nonce,
&sig
));
}
}