use std::sync::{Arc, Mutex};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LysRoot {
NotAsked,
Held,
Absent,
Unknown {
cause: &'static str,
},
}
impl LysRoot {
#[must_use]
pub const fn cause(&self) -> Option<&'static str> {
match self {
Self::Unknown { cause } => Some(cause),
Self::NotAsked | Self::Held | Self::Absent => None,
}
}
#[must_use]
pub const fn health(&self) -> &'static str {
match self {
Self::NotAsked => "not_asked",
Self::Held => "held",
Self::Absent => "absent",
Self::Unknown { .. } => "unknown",
}
}
}
pub struct RootPresence {
network_id: String,
api: reqwest::Url,
credential: zeroize::Zeroizing<String>,
client: reqwest::Client,
handle: tokio::runtime::Handle,
answer: Mutex<LysRoot>,
}
impl std::fmt::Debug for RootPresence {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RootPresence")
.field("network_id", &self.network_id)
.field("answer", &self.answer())
.finish_non_exhaustive()
}
}
#[derive(serde::Deserialize)]
struct HeldAnswer {
held: bool,
}
impl RootPresence {
pub fn new(
network_id: &str,
api_url: &str,
credential: String,
handle: tokio::runtime::Handle,
) -> Result<Self, lys_pass::Error> {
let mut api: reqwest::Url = api_url
.parse()
.map_err(|_| lys_pass::Error::Invalid("the Lys API base is not a URL"))?;
if !api.path().ends_with('/') {
api.set_path(&format!("{}/", api.path()));
}
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| lys_pass::Error::Transport(Box::new(error)))?;
Ok(Self {
network_id: network_id.to_owned(),
api,
credential: zeroize::Zeroizing::new(credential),
client,
handle,
answer: Mutex::new(LysRoot::NotAsked),
})
}
#[must_use]
pub fn answer(&self) -> LysRoot {
self.answer.lock().map_or(
LysRoot::Unknown {
cause: "lys_root_state_poisoned",
},
|answer| answer.clone(),
)
}
pub fn ask(&self) {
let handle = &self.handle;
let answer =
std::thread::scope(|scope| scope.spawn(|| handle.block_on(self.question())).join())
.unwrap_or(LysRoot::Unknown {
cause: "lys_root_ask_panicked",
});
self.record(answer);
}
pub fn ask_in_background(self: &Arc<Self>) {
let root = Arc::clone(self);
drop(self.handle.spawn(async move { root.ask_now().await }));
}
pub async fn ask_now(&self) {
let answer = self.question().await;
self.record(answer);
}
#[cfg(test)]
pub(crate) fn record_for_tests(&self, answer: LysRoot) {
self.store(answer);
}
fn record(&self, answer: LysRoot) {
match &answer {
LysRoot::Absent => tracing::error!(
target: "liminal::lys_root",
network = %self.network_id,
lys = %self.api,
"the network has no root in its Lys: no identity may administer \
liminal.network:{}, so every grant-bound request is refused until a root \
is given there",
self.network_id
),
LysRoot::Unknown { cause } => tracing::warn!(
target: "liminal::lys_root",
network = %self.network_id,
lys = %self.api,
cause,
"whether the network has a root in its Lys could not be read"
),
LysRoot::Held | LysRoot::NotAsked => {}
}
self.store(answer);
}
fn store(&self, answer: LysRoot) {
if let Ok(mut held) = self.answer.lock() {
*held = answer;
}
}
async fn question(&self) -> LysRoot {
let Ok(url) = self.api.join("grants/held") else {
return LysRoot::Unknown {
cause: "lys_root_route_invalid",
};
};
let body = serde_json::json!({
"route": "api",
"resource": { "kind": "liminal.network", "id": self.network_id },
"action": "admin",
});
let page = async {
let response = self
.client
.post(url)
.bearer_auth(self.credential.as_str())
.json(&body)
.send()
.await
.map_err(|_| "lys_root_unreachable")?;
if matches!(
response.status(),
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
) {
return Err("lys_root_route_absent");
}
if !response.status().is_success() {
return Err("lys_root_refused");
}
response
.json::<HeldAnswer>()
.await
.map_err(|_| "lys_root_unreadable")
};
match page.await {
Ok(HeldAnswer { held: true }) => LysRoot::Held,
Ok(HeldAnswer { held: false }) => LysRoot::Absent,
Err(cause) => LysRoot::Unknown { cause },
}
}
}