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. 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    if let Some(id) = session_id {
94        event = event.with_session(id);
95    }
96    event
97}
98
99impl Default for AppState {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105/// Health check endpoint.
106pub async fn health() -> &'static str {
107    "OK"
108}
109
110/// API information endpoint.
111pub async fn api_info() -> Json<serde_json::Value> {
112    Json(serde_json::json!({
113        "name": "shell-tunnel",
114        "version": env!("CARGO_PKG_VERSION"),
115        "status": "running"
116    }))
117}
118
119/// List all sessions.
120pub async fn list_sessions(
121    State(state): State<AppState>,
122) -> Result<Json<ListSessionsResponse>, (StatusCode, Json<ErrorResponse>)> {
123    let ids = state.store.list_ids().map_err(|e| {
124        (
125            StatusCode::INTERNAL_SERVER_ERROR,
126            Json(ErrorResponse::internal_error(e.to_string())),
127        )
128    })?;
129
130    let mut sessions = Vec::with_capacity(ids.len());
131    for id in ids {
132        if let Ok(Some(session)) = state.store.get(&id) {
133            sessions.push(SessionSummary {
134                session_id: session.id.as_u64(),
135                state: format!("{:?}", session.state),
136                idle_seconds: session.idle_duration().as_secs_f64(),
137            });
138        }
139    }
140
141    Ok(Json(ListSessionsResponse {
142        count: sessions.len(),
143        sessions,
144    }))
145}
146
147/// Create a new session.
148pub async fn create_session(
149    State(state): State<AppState>,
150    Json(req): Json<CreateSessionRequest>,
151) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
152    let config = SessionConfig {
153        shell: req.shell,
154        working_dir: req.working_dir,
155        env: req.env,
156    };
157
158    let session_id = state.store.create(config).map_err(|e| {
159        (
160            StatusCode::INTERNAL_SERVER_ERROR,
161            Json(ErrorResponse::internal_error(e.to_string())),
162        )
163    })?;
164
165    // Transition to Idle state (ready for commands)
166    state
167        .store
168        .update(&session_id, |s| {
169            let _ = s.state.transition_to(SessionState::Idle);
170        })
171        .ok();
172
173    Ok((
174        StatusCode::CREATED,
175        Json(CreateSessionResponse::new(session_id)),
176    ))
177}
178
179/// Get session status.
180pub async fn get_session(
181    State(state): State<AppState>,
182    Path(session_id): Path<u64>,
183) -> Result<Json<SessionStatusResponse>, (StatusCode, Json<ErrorResponse>)> {
184    let id = SessionId::from_raw(session_id);
185
186    let session = state
187        .store
188        .get(&id)
189        .map_err(|e| {
190            (
191                StatusCode::INTERNAL_SERVER_ERROR,
192                Json(ErrorResponse::internal_error(e.to_string())),
193            )
194        })?
195        .ok_or_else(|| {
196            (
197                StatusCode::NOT_FOUND,
198                Json(ErrorResponse::session_not_found(&session_id.to_string())),
199            )
200        })?;
201
202    Ok(Json(SessionStatusResponse::from_session(&session)))
203}
204
205/// Delete a session.
206pub async fn delete_session(
207    State(state): State<AppState>,
208    Path(session_id): Path<u64>,
209) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
210    let id = SessionId::from_raw(session_id);
211
212    // First mark as terminated
213    state
214        .store
215        .update(&id, |s| {
216            let _ = s.state.transition_to(SessionState::Terminated);
217        })
218        .map_err(|_| {
219            (
220                StatusCode::NOT_FOUND,
221                Json(ErrorResponse::session_not_found(&session_id.to_string())),
222            )
223        })?;
224
225    // Then remove from store
226    state.store.remove(&id).map_err(|e| {
227        (
228            StatusCode::INTERNAL_SERVER_ERROR,
229            Json(ErrorResponse::internal_error(e.to_string())),
230        )
231    })?;
232
233    Ok(StatusCode::NO_CONTENT)
234}
235
236/// Execute a command in a session.
237pub async fn execute_command(
238    State(state): State<AppState>,
239    Path(session_id): Path<u64>,
240    identity: Option<axum::Extension<crate::audit::Identity>>,
241    Json(req): Json<ExecuteCommandRequest>,
242) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
243    let id = SessionId::from_raw(session_id);
244
245    // Verify session exists and is in valid state
246    let session = state
247        .store
248        .get(&id)
249        .map_err(|e| {
250            (
251                StatusCode::INTERNAL_SERVER_ERROR,
252                Json(ErrorResponse::internal_error(e.to_string())),
253            )
254        })?
255        .ok_or_else(|| {
256            (
257                StatusCode::NOT_FOUND,
258                Json(ErrorResponse::session_not_found(&session_id.to_string())),
259            )
260        })?;
261
262    if !session.state.can_execute() {
263        return Err((
264            StatusCode::CONFLICT,
265            Json(ErrorResponse::invalid_state(session.state)),
266        ));
267    }
268
269    // Build command
270    let mut cmd = Command::new(&req.command);
271    if let Some(dir) = &req.working_dir {
272        cmd = cmd.working_dir(PathBuf::from(dir));
273    }
274    if let Some(timeout) = req.timeout() {
275        cmd = cmd.timeout(timeout);
276    }
277    for (key, value) in &req.env {
278        cmd = cmd.env(key, value);
279    }
280
281    // Execute
282    let result = state
283        .executor
284        .execute_in_session(&id, &cmd)
285        .await
286        .map_err(|e| {
287            (
288                StatusCode::INTERNAL_SERVER_ERROR,
289                Json(ErrorResponse::internal_error(e.to_string())),
290            )
291        })?;
292
293    state.audit.record(execution_event(
294        identity.map(|axum::Extension(id)| id),
295        "POST /api/v1/sessions/{id}/execute",
296        &req.command,
297        Some(session_id),
298        &result,
299    ));
300
301    // Update session context
302    state
303        .store
304        .update(&id, |s| {
305            s.context.record_execution(&req.command, result.exit_code);
306        })
307        .ok();
308
309    Ok(Json(ExecuteCommandResponse::from_result(&result)))
310}
311
312/// Execute a command without session (one-shot).
313pub async fn execute_oneshot(
314    State(state): State<AppState>,
315    identity: Option<axum::Extension<crate::audit::Identity>>,
316    Json(req): Json<ExecuteCommandRequest>,
317) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
318    // Build command
319    let mut cmd = Command::new(&req.command);
320    if let Some(dir) = &req.working_dir {
321        cmd = cmd.working_dir(PathBuf::from(dir));
322    }
323    if let Some(timeout) = req.timeout() {
324        cmd = cmd.timeout(timeout);
325    }
326    for (key, value) in &req.env {
327        cmd = cmd.env(key, value);
328    }
329
330    // Execute directly without session (off the async runtime workers)
331    let result = state.executor.execute(&cmd).await.map_err(|e| {
332        (
333            StatusCode::INTERNAL_SERVER_ERROR,
334            Json(ErrorResponse::internal_error(e.to_string())),
335        )
336    })?;
337
338    state.audit.record(execution_event(
339        identity.map(|axum::Extension(id)| id),
340        "POST /api/v1/execute",
341        &req.command,
342        None,
343        &result,
344    ));
345
346    Ok(Json(ExecuteCommandResponse::from_result(&result)))
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_app_state_new() {
355        let state = AppState::new();
356        assert_eq!(state.store.count(), 0);
357    }
358
359    #[tokio::test]
360    async fn test_health_endpoint() {
361        let response = health().await;
362        assert_eq!(response, "OK");
363    }
364
365    #[tokio::test]
366    async fn test_api_info_endpoint() {
367        let response = api_info().await;
368        let json = response.0;
369        assert_eq!(json["name"], "shell-tunnel");
370        assert_eq!(json["status"], "running");
371    }
372}