1use 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#[derive(Clone)]
21pub struct AppState {
22 pub store: Arc<SessionStore>,
23 pub executor: Arc<CommandExecutor>,
24 pub audit: Arc<crate::audit::AuditSink>,
26 pub fs: Option<Arc<crate::fs::FsRoot>>,
36 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 pub fn with_audit(mut self, sink: Arc<crate::audit::AuditSink>) -> Self {
55 self.audit = sink;
56 self
57 }
58
59 pub fn with_fs_root(mut self, root: crate::fs::FsRoot) -> Self {
61 self.fs = Some(Arc::new(root));
62 self
63 }
64
65 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
72fn 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
106pub async fn health() -> &'static str {
108 "OK"
109}
110
111pub 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
120pub 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
148pub 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 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
178pub 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
204pub 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 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 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
235pub 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 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 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 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 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
317pub 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 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 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}