use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const ALLOW_HTTP_ENV: &str = "BASEMIND_ALLOW_HTTP";
pub const TOKEN_QUERY_KEY: &str = "token";
const PORTFILE_NAME: &str = "http.addr";
#[cfg(unix)]
const PORTFILE_MODE: u32 = 0o600;
const TOKEN_BYTES: usize = 32;
pub fn portfile_path(comms_dir: &Path) -> PathBuf {
comms_dir.join(PORTFILE_NAME)
}
fn is_truthy(value: &str) -> bool {
let value = value.trim();
value.eq_ignore_ascii_case("1") || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
}
pub fn http_enabled() -> bool {
grants_http(std::env::var(ALLOW_HTTP_ENV).ok().as_deref())
}
fn grants_http(value: Option<&str>) -> bool {
value.is_some_and(is_truthy)
}
#[derive(Clone)]
pub struct HttpToken(String);
impl HttpToken {
pub fn generate() -> Result<Self> {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes).context("draw the HTTP front-end bearer token from the OS CSPRNG")?;
Ok(Self(hex::encode(bytes)))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn matches(&self, presented: &str) -> bool {
constant_time_eq(self.0.as_bytes(), presented.as_bytes())
}
}
fn constant_time_eq(expected: &[u8], presented: &[u8]) -> bool {
if expected.len() != presented.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.iter().zip(presented) {
diff |= a ^ b;
}
std::hint::black_box(diff) == 0
}
pub fn presented_token(authorization: Option<&str>, query: Option<&str>) -> Option<String> {
if let Some(raw) = authorization {
let raw = raw.trim();
let bytes = raw.as_bytes();
if bytes.len() > 7 && bytes[..6].eq_ignore_ascii_case(b"bearer") && bytes[6] == b' ' {
let value = raw[7..].trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
let query = query?;
form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == TOKEN_QUERY_KEY)
.map(|(_, value)| value.into_owned())
.filter(|value| !value.is_empty())
}
pub struct Portfile {
pub addr: SocketAddr,
pub token: Option<String>,
}
pub fn write_portfile(comms_dir: &Path, addr: &SocketAddr, token: &HttpToken) -> std::io::Result<()> {
use std::io::Write;
let path = portfile_path(comms_dir);
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(PORTFILE_MODE);
}
let mut file = options.open(&path)?;
writeln!(file, "{addr}")?;
writeln!(file, "{}", token.as_str())?;
file.flush()
}
pub fn read_portfile(comms_dir: &Path) -> Option<Portfile> {
let raw = std::fs::read_to_string(portfile_path(comms_dir)).ok()?;
let mut lines = raw.lines();
let addr: SocketAddr = lines.next()?.trim().parse().ok()?;
let token = lines
.next()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string);
Some(Portfile { addr, token })
}
pub fn remove_portfile(comms_dir: &Path) {
let path = portfile_path(comms_dir);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::debug!(%error, path = %path.display(), "http: removing the portfile failed"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_tokens_are_wide_and_distinct() {
let a = HttpToken::generate().expect("generate");
let b = HttpToken::generate().expect("generate");
assert_eq!(a.as_str().len(), TOKEN_BYTES * 2, "hex-encoded 32 bytes");
assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a.as_str(), b.as_str(), "each daemon draws its own token");
}
#[test]
fn matches_only_the_exact_token() {
let token = HttpToken::generate().expect("generate");
assert!(token.matches(token.as_str()));
assert!(!token.matches(""));
assert!(!token.matches(&token.as_str()[..63]), "a prefix is not the token");
let mut wrong = token.as_str().to_string();
wrong.replace_range(0..1, if wrong.starts_with('a') { "b" } else { "a" });
assert!(!token.matches(&wrong), "one flipped character is rejected");
}
#[test]
fn presented_token_reads_header_then_query() {
assert_eq!(presented_token(Some("Bearer abc"), None).as_deref(), Some("abc"));
assert_eq!(presented_token(Some("bearer abc"), None).as_deref(), Some("abc"));
assert_eq!(presented_token(Some("BEARER abc "), None).as_deref(), Some("abc"));
assert_eq!(
presented_token(Some("Basic abc"), Some("token=xyz")).as_deref(),
Some("xyz")
);
assert_eq!(
presented_token(None, Some("root=%2Frepo&token=xyz")).as_deref(),
Some("xyz")
);
assert_eq!(presented_token(None, Some("root=%2Frepo")), None);
assert_eq!(presented_token(Some("Bearer"), None), None);
assert_eq!(presented_token(Some("Bearer "), None), None);
assert_eq!(presented_token(None, None), None);
}
#[test]
fn portfile_round_trips_and_is_owner_only() {
let dir = tempfile::tempdir().expect("tempdir");
let addr: SocketAddr = "127.0.0.1:51786".parse().unwrap();
let token = HttpToken::generate().expect("generate");
write_portfile(dir.path(), &addr, &token).expect("write portfile");
let read = read_portfile(dir.path()).expect("portfile parses");
assert_eq!(read.addr, addr);
assert_eq!(read.token.as_deref(), Some(token.as_str()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(portfile_path(dir.path()))
.expect("stat portfile")
.permissions()
.mode();
assert_eq!(mode & 0o777, PORTFILE_MODE, "the portfile carries a credential");
}
remove_portfile(dir.path());
assert!(read_portfile(dir.path()).is_none(), "removed portfile stops resolving");
remove_portfile(dir.path());
}
#[test]
fn portfile_without_a_token_line_still_parses() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(portfile_path(dir.path()), "127.0.0.1:51786\n").expect("write legacy portfile");
let read = read_portfile(dir.path()).expect("legacy portfile parses");
assert_eq!(read.addr, "127.0.0.1:51786".parse::<SocketAddr>().unwrap());
assert_eq!(read.token, None, "a pre-token daemon published no credential");
}
#[test]
fn only_an_explicit_truthy_grant_starts_the_listener() {
assert!(!grants_http(None), "an unset variable is not a grant");
for truthy in ["1", "true", "TRUE", " yes "] {
assert!(grants_http(Some(truthy)), "{truthy:?} grants the listener");
}
for falsy in [
"0",
"false",
"no",
"",
" ",
"on",
"enabled",
"2",
"yes please",
"truthy",
] {
assert!(!grants_http(Some(falsy)), "{falsy:?} does not grant the listener");
}
}
}