liminal_server/auth_lys/
root_presence.rs1use std::sync::{Arc, Mutex};
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum LysRoot {
23 NotAsked,
25 Held,
27 Absent,
29 Unknown {
31 cause: &'static str,
33 },
34}
35
36impl LysRoot {
37 #[must_use]
39 pub const fn cause(&self) -> Option<&'static str> {
40 match self {
41 Self::Unknown { cause } => Some(cause),
42 Self::NotAsked | Self::Held | Self::Absent => None,
43 }
44 }
45
46 #[must_use]
48 pub const fn health(&self) -> &'static str {
49 match self {
50 Self::NotAsked => "not_asked",
51 Self::Held => "held",
52 Self::Absent => "absent",
53 Self::Unknown { .. } => "unknown",
54 }
55 }
56}
57
58pub struct RootPresence {
60 network_id: String,
61 api: reqwest::Url,
62 credential: zeroize::Zeroizing<String>,
63 client: reqwest::Client,
64 handle: tokio::runtime::Handle,
65 answer: Mutex<LysRoot>,
66}
67
68impl std::fmt::Debug for RootPresence {
69 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 formatter
71 .debug_struct("RootPresence")
72 .field("network_id", &self.network_id)
73 .field("answer", &self.answer())
74 .finish_non_exhaustive()
75 }
76}
77
78#[derive(serde::Deserialize)]
80struct HeldAnswer {
81 held: bool,
82}
83
84impl RootPresence {
85 pub fn new(
91 network_id: &str,
92 api_url: &str,
93 credential: String,
94 handle: tokio::runtime::Handle,
95 ) -> Result<Self, lys_pass::Error> {
96 let mut api: reqwest::Url = api_url
97 .parse()
98 .map_err(|_| lys_pass::Error::Invalid("the Lys API base is not a URL"))?;
99 if !api.path().ends_with('/') {
100 api.set_path(&format!("{}/", api.path()));
101 }
102 let client = reqwest::Client::builder()
103 .no_proxy()
104 .redirect(reqwest::redirect::Policy::none())
105 .build()
106 .map_err(|error| lys_pass::Error::Transport(Box::new(error)))?;
107 Ok(Self {
108 network_id: network_id.to_owned(),
109 api,
110 credential: zeroize::Zeroizing::new(credential),
111 client,
112 handle,
113 answer: Mutex::new(LysRoot::NotAsked),
114 })
115 }
116
117 #[must_use]
120 pub fn answer(&self) -> LysRoot {
121 self.answer.lock().map_or(
122 LysRoot::Unknown {
123 cause: "lys_root_state_poisoned",
124 },
125 |answer| answer.clone(),
126 )
127 }
128
129 pub fn ask(&self) {
132 let handle = &self.handle;
133 let answer =
134 std::thread::scope(|scope| scope.spawn(|| handle.block_on(self.question())).join())
135 .unwrap_or(LysRoot::Unknown {
136 cause: "lys_root_ask_panicked",
137 });
138 self.record(answer);
139 }
140
141 pub fn ask_in_background(self: &Arc<Self>) {
144 let root = Arc::clone(self);
145 drop(self.handle.spawn(async move { root.ask_now().await }));
146 }
147
148 pub async fn ask_now(&self) {
151 let answer = self.question().await;
152 self.record(answer);
153 }
154
155 #[cfg(test)]
157 pub(crate) fn record_for_tests(&self, answer: LysRoot) {
158 self.store(answer);
159 }
160
161 fn record(&self, answer: LysRoot) {
162 match &answer {
163 LysRoot::Absent => tracing::error!(
164 target: "liminal::lys_root",
165 network = %self.network_id,
166 lys = %self.api,
167 "the network has no root in its Lys: no identity may administer \
168 liminal.network:{}, so every grant-bound request is refused until a root \
169 is given there",
170 self.network_id
171 ),
172 LysRoot::Unknown { cause } => tracing::warn!(
173 target: "liminal::lys_root",
174 network = %self.network_id,
175 lys = %self.api,
176 cause,
177 "whether the network has a root in its Lys could not be read"
178 ),
179 LysRoot::Held | LysRoot::NotAsked => {}
180 }
181 self.store(answer);
182 }
183
184 fn store(&self, answer: LysRoot) {
187 if let Ok(mut held) = self.answer.lock() {
188 *held = answer;
189 }
190 }
191
192 async fn question(&self) -> LysRoot {
193 let Ok(url) = self.api.join("grants/held") else {
194 return LysRoot::Unknown {
195 cause: "lys_root_route_invalid",
196 };
197 };
198 let body = serde_json::json!({
199 "route": "api",
200 "resource": { "kind": "liminal.network", "id": self.network_id },
201 "action": "admin",
202 });
203 let page = async {
204 let response = self
205 .client
206 .post(url)
207 .bearer_auth(self.credential.as_str())
208 .json(&body)
209 .send()
210 .await
211 .map_err(|_| "lys_root_unreachable")?;
212 if matches!(
217 response.status(),
218 reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
219 ) {
220 return Err("lys_root_route_absent");
221 }
222 if !response.status().is_success() {
223 return Err("lys_root_refused");
224 }
225 response
226 .json::<HeldAnswer>()
227 .await
228 .map_err(|_| "lys_root_unreadable")
229 };
230 match page.await {
231 Ok(HeldAnswer { held: true }) => LysRoot::Held,
232 Ok(HeldAnswer { held: false }) => LysRoot::Absent,
233 Err(cause) => LysRoot::Unknown { cause },
234 }
235 }
236}