Skip to main content

shell_tunnel/api/
handlers.rs

1//! REST API handlers.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use axum::{
7    extract::{Path, State},
8    http::StatusCode,
9    Json,
10};
11
12use super::types::{
13    CreateSessionRequest, CreateSessionResponse, ErrorResponse, ExecuteCommandRequest,
14    ExecuteCommandResponse, ListSessionsResponse, SessionStatusResponse, SessionSummary,
15};
16use crate::execution::{Command, CommandExecutor};
17use crate::session::{SessionId, SessionState, SessionStore};
18
19/// Shared application state.
20#[derive(Clone)]
21pub struct AppState {
22    pub store: Arc<SessionStore>,
23    pub executor: Arc<CommandExecutor>,
24    /// Where execution events are recorded; disabled unless configured.
25    pub audit: Arc<crate::audit::AuditSink>,
26    /// The directory the filesystem API may touch.
27    ///
28    /// `None` means the API is off. Since 0.12.0 the shipped binary no longer
29    /// defaults to this: it always installs `FsRoot::machine_wide()` unless
30    /// `--fs-root` narrows it, on the reasoning that a token holding `fs.*`
31    /// already needs `exec` to matter, and `exec` can already reach anything
32    /// this process can (`src/main.rs`). `None` now only occurs when a
33    /// library consumer builds an `AppState` directly instead of going
34    /// through the binary's startup path.
35    pub fs: Option<Arc<crate::fs::FsRoot>>,
36    /// In-flight uploads. Always present; useless until `fs` is set.
37    pub uploads: Arc<crate::fs::UploadStore>,
38}
39
40impl AppState {
41    pub fn new() -> Self {
42        let store = Arc::new(SessionStore::new());
43        let executor = Arc::new(CommandExecutor::new(Arc::clone(&store)));
44        Self {
45            store,
46            executor,
47            audit: Arc::new(crate::audit::AuditSink::Disabled),
48            fs: None,
49            uploads: Arc::new(crate::fs::UploadStore::new(crate::fs::DEFAULT_CHUNK_SIZE)),
50        }
51    }
52
53    /// Record execution events to `sink`.
54    pub fn with_audit(mut self, sink: Arc<crate::audit::AuditSink>) -> Self {
55        self.audit = sink;
56        self
57    }
58
59    /// Enable the filesystem API, confined to `root`.
60    pub fn with_fs_root(mut self, root: crate::fs::FsRoot) -> Self {
61        self.fs = Some(Arc::new(root));
62        self
63    }
64
65    /// Advertise a different chunk size to upload clients.
66    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
67        self.uploads = Arc::new(crate::fs::UploadStore::new(chunk_size));
68        self
69    }
70}
71
72/// Build the execution event for a finished command.
73///
74/// Written from the handler rather than from middleware because only here are
75/// the command and its outcome both in hand — an entry saying a request reached
76/// `/execute` would say almost nothing about what ran.
77fn execution_event(
78    identity: Option<crate::audit::Identity>,
79    route: &str,
80    command: &str,
81    session_id: Option<u64>,
82    result: &crate::execution::ExecutionResult,
83) -> crate::audit::AuditEvent {
84    let mut event = crate::audit::AuditEvent::new("execute")
85        .with_identity(identity)
86        .with_route(route)
87        .with_command(command)
88        .with_outcome(
89            result.exit_code,
90            result.timed_out,
91            result.duration.as_millis() as u64,
92        )
93        .with_truncated_output(result.truncated, result.total_bytes);
94    if let Some(id) = session_id {
95        event = event.with_session(id);
96    }
97    event
98}
99
100impl Default for AppState {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// Health check endpoint.
107pub async fn health() -> &'static str {
108    "OK"
109}
110
111/// API information endpoint.
112pub async fn api_info() -> Json<serde_json::Value> {
113    Json(serde_json::json!({
114        "name": "shell-tunnel",
115        "version": env!("CARGO_PKG_VERSION"),
116        "status": "running"
117    }))
118}
119
120/// List all sessions.
121pub async fn list_sessions(
122    State(state): State<AppState>,
123) -> Result<Json<ListSessionsResponse>, (StatusCode, Json<ErrorResponse>)> {
124    let ids = state.store.list_ids().map_err(|e| {
125        (
126            StatusCode::INTERNAL_SERVER_ERROR,
127            Json(ErrorResponse::internal_error(e.to_string())),
128        )
129    })?;
130
131    let mut sessions = Vec::with_capacity(ids.len());
132    for id in ids {
133        if let Ok(Some(session)) = state.store.get(&id) {
134            sessions.push(SessionSummary {
135                session_id: session.id.as_u64(),
136                running: session.state == SessionState::Active,
137                idle_seconds: session.idle_duration().as_secs_f64(),
138            });
139        }
140    }
141
142    Ok(Json(ListSessionsResponse {
143        count: sessions.len(),
144        sessions,
145    }))
146}
147
148/// Create a new session.
149/// The body is optional because the request carries no fields: `POST /sessions`
150/// with nothing at all is the natural call. A body that *is* sent still has to
151/// parse, so a caller who passes the old `shell`/`working_dir`/`env` is told so
152/// rather than having them dropped.
153pub async fn create_session(
154    State(state): State<AppState>,
155    _req: Option<Json<CreateSessionRequest>>,
156) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
157    let session_id = state.store.create().map_err(|e| {
158        (
159            StatusCode::INTERNAL_SERVER_ERROR,
160            Json(ErrorResponse::internal_error(e.to_string())),
161        )
162    })?;
163
164    // Transition to Idle state (ready for commands)
165    state
166        .store
167        .update(&session_id, |s| {
168            let _ = s.state.transition_to(SessionState::Idle);
169        })
170        .ok();
171
172    Ok((
173        StatusCode::CREATED,
174        Json(CreateSessionResponse::new(session_id)),
175    ))
176}
177
178/// Get session status.
179pub async fn get_session(
180    State(state): State<AppState>,
181    Path(session_id): Path<u64>,
182) -> Result<Json<SessionStatusResponse>, (StatusCode, Json<ErrorResponse>)> {
183    let id = SessionId::from_raw(session_id);
184
185    let session = state
186        .store
187        .get(&id)
188        .map_err(|e| {
189            (
190                StatusCode::INTERNAL_SERVER_ERROR,
191                Json(ErrorResponse::internal_error(e.to_string())),
192            )
193        })?
194        .ok_or_else(|| {
195            (
196                StatusCode::NOT_FOUND,
197                Json(ErrorResponse::session_not_found(&session_id.to_string())),
198            )
199        })?;
200
201    Ok(Json(SessionStatusResponse::from_session(&session)))
202}
203
204/// Delete a session.
205pub async fn delete_session(
206    State(state): State<AppState>,
207    Path(session_id): Path<u64>,
208) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
209    let id = SessionId::from_raw(session_id);
210
211    // First mark as terminated
212    state
213        .store
214        .update(&id, |s| {
215            let _ = s.state.transition_to(SessionState::Terminated);
216        })
217        .map_err(|_| {
218            (
219                StatusCode::NOT_FOUND,
220                Json(ErrorResponse::session_not_found(&session_id.to_string())),
221            )
222        })?;
223
224    // Then remove from store
225    state.store.remove(&id).map_err(|e| {
226        (
227            StatusCode::INTERNAL_SERVER_ERROR,
228            Json(ErrorResponse::internal_error(e.to_string())),
229        )
230    })?;
231
232    Ok(StatusCode::NO_CONTENT)
233}
234
235/// Execute a command in a session.
236pub async fn execute_command(
237    State(state): State<AppState>,
238    Path(session_id): Path<u64>,
239    identity: Option<axum::Extension<crate::audit::Identity>>,
240    Json(req): Json<ExecuteCommandRequest>,
241) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
242    let id = SessionId::from_raw(session_id);
243
244    // Verify session exists and is in valid state
245    let session = state
246        .store
247        .get(&id)
248        .map_err(|e| {
249            (
250                StatusCode::INTERNAL_SERVER_ERROR,
251                Json(ErrorResponse::internal_error(e.to_string())),
252            )
253        })?
254        .ok_or_else(|| {
255            (
256                StatusCode::NOT_FOUND,
257                Json(ErrorResponse::session_not_found(&session_id.to_string())),
258            )
259        })?;
260
261    if !session.state.can_execute() {
262        return Err((
263            StatusCode::CONFLICT,
264            Json(ErrorResponse::invalid_state(session.state)),
265        ));
266    }
267
268    // Build command
269    let mut cmd = Command::new(&req.command);
270    if let Some(dir) = &req.working_dir {
271        cmd = cmd.working_dir(PathBuf::from(dir));
272    }
273    if let Some(timeout) = req.timeout() {
274        cmd = cmd.timeout(timeout);
275    }
276    if let Some(bytes) = req.max_output_bytes {
277        cmd = cmd.max_output_bytes(bytes);
278    }
279    for (key, value) in &req.env {
280        cmd = cmd.env(key, value);
281    }
282
283    // Execute
284    let result = state
285        .executor
286        .execute_in_session(&id, &cmd)
287        .await
288        .map_err(|e| {
289            (
290                StatusCode::INTERNAL_SERVER_ERROR,
291                Json(ErrorResponse::internal_error(e.to_string())),
292            )
293        })?;
294
295    state
296        .audit
297        .record_async(execution_event(
298            identity.map(|axum::Extension(id)| id),
299            "POST /api/v1/sessions/{id}/execute",
300            &req.command,
301            Some(session_id),
302            &result,
303        ))
304        .await;
305
306    // Update session context
307    state
308        .store
309        .update(&id, |s| {
310            s.context.record_execution(&req.command, result.exit_code);
311        })
312        .ok();
313
314    Ok(Json(ExecuteCommandResponse::from_result(&result)))
315}
316
317/// Execute a command without session (one-shot).
318pub async fn execute_oneshot(
319    State(state): State<AppState>,
320    identity: Option<axum::Extension<crate::audit::Identity>>,
321    Json(req): Json<ExecuteCommandRequest>,
322) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
323    // Build command
324    let mut cmd = Command::new(&req.command);
325    if let Some(dir) = &req.working_dir {
326        cmd = cmd.working_dir(PathBuf::from(dir));
327    }
328    if let Some(timeout) = req.timeout() {
329        cmd = cmd.timeout(timeout);
330    }
331    if let Some(bytes) = req.max_output_bytes {
332        cmd = cmd.max_output_bytes(bytes);
333    }
334    for (key, value) in &req.env {
335        cmd = cmd.env(key, value);
336    }
337
338    // Execute directly without session (off the async runtime workers)
339    let result = state.executor.execute(&cmd).await.map_err(|e| {
340        (
341            StatusCode::INTERNAL_SERVER_ERROR,
342            Json(ErrorResponse::internal_error(e.to_string())),
343        )
344    })?;
345
346    state
347        .audit
348        .record_async(execution_event(
349            identity.map(|axum::Extension(id)| id),
350            "POST /api/v1/execute",
351            &req.command,
352            None,
353            &result,
354        ))
355        .await;
356
357    Ok(Json(ExecuteCommandResponse::from_result(&result)))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_app_state_new() {
366        let state = AppState::new();
367        assert_eq!(state.store.count(), 0);
368    }
369
370    #[tokio::test]
371    async fn test_health_endpoint() {
372        let response = health().await;
373        assert_eq!(response, "OK");
374    }
375
376    #[tokio::test]
377    async fn test_api_info_endpoint() {
378        let response = api_info().await;
379        let json = response.0;
380        assert_eq!(json["name"], "shell-tunnel");
381        assert_eq!(json["status"], "running");
382    }
383}