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