liminal_cli/agent/
exit.rs1use std::fmt;
4use std::time::Duration;
5use zeroize::Zeroizing;
6
7pub(super) const fn session_end(resumable: bool) -> &'static str {
8 if resumable { "dormant" } else { "ended" }
9}
10
11pub(super) struct Revoke {
12 route: String,
13 pass: Zeroizing<String>,
14 bound: usize,
15 client: ureq::Agent,
16}
17
18impl fmt::Debug for Revoke {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 formatter.write_str("RunPassRevoke")
21 }
22}
23
24impl Revoke {
25 pub fn new(
26 issuer: &str,
27 agent: &str,
28 pass: String,
29 bound: usize,
30 timeout_ms: u64,
31 ) -> anyhow::Result<Self> {
32 let pass = Zeroizing::new(pass);
33 let issuer = validate_issuer(issuer)?;
34 anyhow::ensure!(
35 !agent.is_empty()
36 && agent
37 .bytes()
38 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
39 "revoke agent must be one segment"
40 );
41 anyhow::ensure!(
42 bound > 0 && timeout_ms > 0,
43 "revoke response and request bounds must be positive"
44 );
45 anyhow::ensure!(
46 !pass.is_empty()
47 && pass.is_ascii()
48 && !pass.bytes().any(|byte| byte.is_ascii_control()),
49 "LYS_AGENT_PASS is not a valid header value"
50 );
51 let client = ureq::Agent::new_with_config(
52 ureq::Agent::config_builder()
53 .proxy(None)
54 .http_status_as_error(false)
55 .max_redirects(0)
56 .timeout_global(Some(Duration::from_millis(timeout_ms)))
57 .build(),
58 );
59 Ok(Self {
60 route: format!("{issuer}/api/agents/{agent}/pass"),
61 pass,
62 bound,
63 client,
64 })
65 }
66
67 pub fn send(self) -> anyhow::Result<()> {
68 revoke_run_audience(
69 &self.client,
70 &self.route,
71 &self.pass,
72 u64::try_from(self.bound)?,
73 )?;
74 Ok(())
75 }
76}
77
78pub(super) fn validate_issuer(issuer: &str) -> anyhow::Result<&str> {
79 let issuer = issuer.trim_end_matches('/');
80 let uri: ureq::http::Uri = issuer
81 .parse()
82 .map_err(|_| anyhow::anyhow!("lys.issuer is not an origin"))?;
83 let authority = uri
84 .authority()
85 .ok_or_else(|| anyhow::anyhow!("lys.issuer has no authority"))?;
86 anyhow::ensure!(
87 !authority.as_str().contains('@')
88 && uri.query().is_none()
89 && matches!(uri.path(), "" | "/")
90 && !issuer.contains('#'),
91 "lys.issuer must be an origin without credentials, path, query or fragment"
92 );
93 anyhow::ensure!(
94 uri.scheme_str() == Some("https")
95 || (uri.scheme_str() == Some("http")
96 && ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
97 "lys.issuer must be https or loopback http"
98 );
99 Ok(issuer)
100}
101
102pub fn revoke_run_audience(
108 client: &ureq::Agent,
109 pass_route: &str,
110 pass: &str,
111 response_bytes: u64,
112) -> Result<(), crate::lys::PassError> {
113 send_revoke(client, pass_route, pass, response_bytes)
114 .map_err(|error| crate::lys::PassError(error.to_string()))
115}
116
117fn send_revoke(
118 client: &ureq::Agent,
119 pass_route: &str,
120 pass: &str,
121 response_bytes: u64,
122) -> anyhow::Result<()> {
123 anyhow::ensure!(
124 !pass.is_empty() && pass.is_ascii() && !pass.bytes().any(|byte| byte.is_ascii_control()),
125 "revoke proof is not a valid header value"
126 );
127 let config = client.config();
128 anyhow::ensure!(
129 config.max_redirects() == 0
130 && config.proxy().is_none()
131 && config
132 .timeouts()
133 .global
134 .is_some_and(|duration| !duration.is_zero())
135 && response_bytes > 0,
136 "revoke needs a bounded client without redirects or a proxy"
137 );
138 let uri: ureq::http::Uri = pass_route
139 .parse()
140 .map_err(|_| anyhow::anyhow!("revoke route refused"))?;
141 let authority = uri
142 .authority()
143 .ok_or_else(|| anyhow::anyhow!("revoke route has no authority"))?;
144 let segments: Vec<_> = uri
145 .path()
146 .split('/')
147 .filter(|segment| !segment.is_empty())
148 .collect();
149 anyhow::ensure!(
150 !authority.as_str().contains('@')
151 && uri.query().is_none()
152 && segments.len() == 4
153 && segments[0] == "api"
154 && segments[1] == "agents"
155 && segments[3] == "pass"
156 && segments[2]
157 .bytes()
158 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
159 "revoke route is not the agent pass endpoint"
160 );
161 anyhow::ensure!(
162 uri.scheme_str() == Some("https")
163 || (uri.scheme_str() == Some("http")
164 && ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
165 "revoke route must be https or loopback http"
166 );
167 let mut proof = ureq::http::HeaderValue::from_bytes(pass.as_bytes())
168 .map_err(|_| anyhow::anyhow!("revoke proof is not a header value"))?;
169 proof.set_sensitive(true);
170 let mut response = client
171 .post(format!("{pass_route}/revoke"))
172 .header("lys-agent-pass", proof)
173 .header("content-type", "application/json")
174 .send(b"{\"audience\":\"liminal\"}".as_slice())
175 .map_err(|_| anyhow::anyhow!("run pass revoke transport failed"))?;
176 let status = response.status().as_u16();
177 let body = Zeroizing::new(
178 response
179 .body_mut()
180 .with_config()
181 .limit(response_bytes)
182 .read_to_vec()
183 .map_err(|_| {
184 anyhow::anyhow!(
185 "run pass revoke response could not be read within issuer_response_bytes"
186 )
187 })?,
188 );
189 if matches!(status, 200 | 204) {
190 return Ok(());
191 }
192 let name = serde_json::from_slice::<serde_json::Value>(&body)
193 .ok()
194 .and_then(|value| {
195 value
196 .get("refusal")
197 .and_then(serde_json::Value::as_str)
198 .map(str::to_owned)
199 })
200 .filter(|name| {
201 !name.is_empty()
202 && !name.contains(pass)
203 && name
204 .bytes()
205 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
206 })
207 .unwrap_or_else(|| "unnamed_refusal".to_owned());
208 anyhow::bail!("run pass revoke refused: {name} (HTTP {status})")
209}
210
211pub fn revoke_run_pass(
217 issuer: &str,
218 agent: &str,
219 pass: String,
220 response_bytes: usize,
221 timeout_ms: u64,
222) -> anyhow::Result<()> {
223 Revoke::new(issuer, agent, pass, response_bytes, timeout_ms)?.send()
224}