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