Skip to main content

liminal_cli/agent/
exit.rs

1//! Close asks the issuer once, without redirects, and never exposes the launch proof.
2
3use 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
102/// Revoke the audience using the run-pass owner's existing bounded client.
103/// Redirects and proxies are refused before the proof crosses the boundary.
104///
105/// # Errors
106/// Returns a named client, route, transport or issuer refusal without secret bytes.
107pub 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
211/// Revoke the launch's audience pass at its owner's close boundary.
212/// The request consumes a transient proof; diagnostics carry only the refusal name.
213///
214/// # Errors
215/// Returns the named configuration, transport, response bound or issuer refusal.
216pub 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}