use std::path::PathBuf;
use std::time::Duration;
use super::{
AUDIENCE_BYTES, Held, LysAgent, LysTable, PassError, RENEW_WITHIN_SECS, read_key, signature,
};
pub(super) fn environment(name: &str) -> Result<Option<String>, &'static str> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(match name {
"LYS_AGENT" => "LYS_AGENT is not Unicode",
_ => "LYS_AGENT_PASS is not Unicode",
}),
}
}
pub(super) fn key_file<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<PathBuf, D::Error> {
let path = <PathBuf as serde::Deserialize>::deserialize(deserializer)?;
if path.as_os_str().is_empty() {
return Err(serde::de::Error::custom(
"lys.key_file must be an absolute path",
));
}
Ok(path)
}
pub(super) fn table_agent<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<String, D::Error> {
let agent = <String as serde::Deserialize>::deserialize(deserializer)?;
if agent.is_empty() {
return Err(serde::de::Error::custom(
"lys.agent must be a plain agent id",
));
}
Ok(agent)
}
pub(super) enum Proof {
Signed(PathBuf),
RunPass(String),
}
impl Proof {
pub(super) fn resolve(
table: &LysTable,
env: impl Fn(&str) -> Result<Option<String>, &'static str>,
) -> Result<(String, Self), &'static str> {
if !table.key_file.as_os_str().is_empty() {
return Ok((table.agent.clone(), Self::Signed(table.key_file.clone())));
}
let agent = env("LYS_AGENT")?
.filter(|value| !value.trim().is_empty())
.ok_or("LYS_AGENT is missing or empty")?;
if !table.agent.is_empty() && table.agent != agent {
return Err("lys.agent differs from LYS_AGENT");
}
if agent.len() > 128
|| !agent
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err("LYS_AGENT must be a plain agent id");
}
let pass = env("LYS_AGENT_PASS")?
.filter(|value| !value.trim().is_empty())
.ok_or("LYS_AGENT_PASS is missing or empty")?;
if !pass.is_ascii() || pass.bytes().any(|byte| byte.is_ascii_control()) {
return Err("LYS_AGENT_PASS is not a valid header value");
}
Ok((agent, Self::RunPass(pass)))
}
pub(super) const fn renew_within(&self) -> u64 {
match self {
Self::Signed(_) => RENEW_WITHIN_SECS,
Self::RunPass(_) => 0,
}
}
}
pub(super) struct PassRequest {
pub(super) header: &'static str,
pub(super) proof: String,
pub(super) body: Vec<u8>,
}
impl PassRequest {
pub(super) fn proof_header(&self) -> Result<ureq::http::HeaderValue, PassError> {
let mut header = ureq::http::HeaderValue::from_bytes(self.proof.as_bytes())
.map_err(|_| PassError("the Lys proof is not a valid header value".to_owned()))?;
header.set_sensitive(true);
Ok(header)
}
}
pub(super) fn transport_reason(error: &ureq::Error) -> String {
match error {
ureq::Error::Io(error) => return format!("I/O: {}", error.kind()),
ureq::Error::Timeout(timeout) => return format!("timeout: {timeout}"),
ureq::Error::StatusCode(status) => return format!("HTTP {status}"),
ureq::Error::Http(_) => "invalid HTTP request",
ureq::Error::BadUri(_) => "bad issuer URI",
ureq::Error::Protocol(_) => "HTTP protocol failure",
ureq::Error::HostNotFound => "host not found",
ureq::Error::RedirectFailed => "redirect refused",
ureq::Error::InvalidProxyUrl => "invalid proxy URI",
ureq::Error::ConnectionFailed => "connection failed",
ureq::Error::BodyExceedsLimit(_) => "body exceeds limit",
ureq::Error::TooManyRedirects => "too many redirects",
ureq::Error::Tls(_) | ureq::Error::Rustls(_) => "TLS failure",
ureq::Error::Pem(_) => "TLS certificate read failure",
ureq::Error::RequireHttpsOnly(_) => "HTTPS required",
ureq::Error::LargeResponseHeader(_, _) => "response header exceeds limit",
ureq::Error::ConnectProxyFailed(_) => "proxy connection failed",
ureq::Error::TlsRequired => "TLS required",
ureq::Error::Other(_) => "other transport failure",
ureq::Error::BodyStalled => "response body stalled",
_ => "unclassified HTTP transport failure",
}
.to_owned()
}
impl LysAgent {
pub(super) fn new_with_env(
table: &LysTable,
timeout: Duration,
env: impl Fn(&str) -> Result<Option<String>, &'static str>,
) -> Result<Self, &'static str> {
let (agent, proof) = Proof::resolve(table, env)?;
Self::configured(table, timeout, agent, proof)
}
pub(super) fn pass_for_at(
&self,
audience: &str,
now: u64,
transport: impl FnOnce(&str, &PassRequest) -> Result<(u16, Vec<u8>), PassError>,
) -> Result<String, PassError> {
if audience.is_empty()
|| audience.len() > AUDIENCE_BYTES
|| !audience
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(PassError(
"pass_for must name a plain audience, such as haematite".to_owned(),
));
}
let held = {
let held = self.lock()?;
held.get(audience)
.filter(|current| {
current.expires_at > now.saturating_add(self.proof.renew_within())
})
.map(|current| current.pass.clone())
};
if let Some(pass) = held {
return Ok(pass);
}
let fresh = self.ask_with(now, audience, transport)?;
let pass = fresh.pass.clone();
self.lock()?.insert(audience.to_owned(), fresh);
Ok(pass)
}
pub(crate) fn forget_expired_pass(&self) -> Result<bool, PassError> {
if matches!(&self.proof, Proof::Signed(_)) {
return Ok(false);
}
self.lock()?.remove(super::AUDIENCE);
Ok(true)
}
fn ask_with(
&self,
now: u64,
audience: &str,
transport: impl FnOnce(&str, &PassRequest) -> Result<(u16, Vec<u8>), PassError>,
) -> Result<Held, PassError> {
let body = serde_json::to_vec(&serde_json::json!({ "audience": audience }))
.map_err(|_| PassError("the pass request could not be written".to_owned()))?;
let (header, proof) = match &self.proof {
Proof::Signed(path) => {
let key = read_key(path)?;
(
"lys-agent-signature",
signature(
&key,
&self.agent,
&self.path,
&body,
now.saturating_mul(1000),
)?,
)
}
Proof::RunPass(pass) => ("lys-agent-pass", pass.clone()),
};
let request = PassRequest {
header,
proof,
body,
};
let (status, body) = transport(&self.route, &request)?;
Self::read_answer(status, &body, now, audience)
}
}
#[cfg(test)]
#[path = "lys_run_pass_tests.rs"]
mod tests;