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::{SessionConfig, 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 state: format!("{:?}", session.state),
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(
150 State(state): State<AppState>,
151 Json(req): Json<CreateSessionRequest>,
152) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
153 let config = SessionConfig {
154 shell: req.shell,
155 working_dir: req.working_dir,
156 env: req.env,
157 };
158
159 let session_id = state.store.create(config).map_err(|e| {
160 (
161 StatusCode::INTERNAL_SERVER_ERROR,
162 Json(ErrorResponse::internal_error(e.to_string())),
163 )
164 })?;
165
166 state
168 .store
169 .update(&session_id, |s| {
170 let _ = s.state.transition_to(SessionState::Idle);
171 })
172 .ok();
173
174 Ok((
175 StatusCode::CREATED,
176 Json(CreateSessionResponse::new(session_id)),
177 ))
178}
179
180pub async fn get_session(
182 State(state): State<AppState>,
183 Path(session_id): Path<u64>,
184) -> Result<Json<SessionStatusResponse>, (StatusCode, Json<ErrorResponse>)> {
185 let id = SessionId::from_raw(session_id);
186
187 let session = state
188 .store
189 .get(&id)
190 .map_err(|e| {
191 (
192 StatusCode::INTERNAL_SERVER_ERROR,
193 Json(ErrorResponse::internal_error(e.to_string())),
194 )
195 })?
196 .ok_or_else(|| {
197 (
198 StatusCode::NOT_FOUND,
199 Json(ErrorResponse::session_not_found(&session_id.to_string())),
200 )
201 })?;
202
203 Ok(Json(SessionStatusResponse::from_session(&session)))
204}
205
206pub async fn delete_session(
208 State(state): State<AppState>,
209 Path(session_id): Path<u64>,
210) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
211 let id = SessionId::from_raw(session_id);
212
213 state
215 .store
216 .update(&id, |s| {
217 let _ = s.state.transition_to(SessionState::Terminated);
218 })
219 .map_err(|_| {
220 (
221 StatusCode::NOT_FOUND,
222 Json(ErrorResponse::session_not_found(&session_id.to_string())),
223 )
224 })?;
225
226 state.store.remove(&id).map_err(|e| {
228 (
229 StatusCode::INTERNAL_SERVER_ERROR,
230 Json(ErrorResponse::internal_error(e.to_string())),
231 )
232 })?;
233
234 Ok(StatusCode::NO_CONTENT)
235}
236
237pub async fn execute_command(
239 State(state): State<AppState>,
240 Path(session_id): Path<u64>,
241 identity: Option<axum::Extension<crate::audit::Identity>>,
242 Json(req): Json<ExecuteCommandRequest>,
243) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
244 let id = SessionId::from_raw(session_id);
245
246 let session = state
248 .store
249 .get(&id)
250 .map_err(|e| {
251 (
252 StatusCode::INTERNAL_SERVER_ERROR,
253 Json(ErrorResponse::internal_error(e.to_string())),
254 )
255 })?
256 .ok_or_else(|| {
257 (
258 StatusCode::NOT_FOUND,
259 Json(ErrorResponse::session_not_found(&session_id.to_string())),
260 )
261 })?;
262
263 if !session.state.can_execute() {
264 return Err((
265 StatusCode::CONFLICT,
266 Json(ErrorResponse::invalid_state(session.state)),
267 ));
268 }
269
270 let mut cmd = Command::new(&req.command);
272 if let Some(dir) = &req.working_dir {
273 cmd = cmd.working_dir(PathBuf::from(dir));
274 }
275 if let Some(timeout) = req.timeout() {
276 cmd = cmd.timeout(timeout);
277 }
278 if let Some(bytes) = req.max_output_bytes {
279 cmd = cmd.max_output_bytes(bytes);
280 }
281 for (key, value) in &req.env {
282 cmd = cmd.env(key, value);
283 }
284
285 let result = state
287 .executor
288 .execute_in_session(&id, &cmd)
289 .await
290 .map_err(|e| {
291 (
292 StatusCode::INTERNAL_SERVER_ERROR,
293 Json(ErrorResponse::internal_error(e.to_string())),
294 )
295 })?;
296
297 state
298 .audit
299 .record_async(execution_event(
300 identity.map(|axum::Extension(id)| id),
301 "POST /api/v1/sessions/{id}/execute",
302 &req.command,
303 Some(session_id),
304 &result,
305 ))
306 .await;
307
308 state
310 .store
311 .update(&id, |s| {
312 s.context.record_execution(&req.command, result.exit_code);
313 })
314 .ok();
315
316 Ok(Json(ExecuteCommandResponse::from_result(&result)))
317}
318
319pub async fn execute_oneshot(
321 State(state): State<AppState>,
322 identity: Option<axum::Extension<crate::audit::Identity>>,
323 Json(req): Json<ExecuteCommandRequest>,
324) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
325 let mut cmd = Command::new(&req.command);
327 if let Some(dir) = &req.working_dir {
328 cmd = cmd.working_dir(PathBuf::from(dir));
329 }
330 if let Some(timeout) = req.timeout() {
331 cmd = cmd.timeout(timeout);
332 }
333 if let Some(bytes) = req.max_output_bytes {
334 cmd = cmd.max_output_bytes(bytes);
335 }
336 for (key, value) in &req.env {
337 cmd = cmd.env(key, value);
338 }
339
340 let result = state.executor.execute(&cmd).await.map_err(|e| {
342 (
343 StatusCode::INTERNAL_SERVER_ERROR,
344 Json(ErrorResponse::internal_error(e.to_string())),
345 )
346 })?;
347
348 state
349 .audit
350 .record_async(execution_event(
351 identity.map(|axum::Extension(id)| id),
352 "POST /api/v1/execute",
353 &req.command,
354 None,
355 &result,
356 ))
357 .await;
358
359 Ok(Json(ExecuteCommandResponse::from_result(&result)))
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_app_state_new() {
368 let state = AppState::new();
369 assert_eq!(state.store.count(), 0);
370 }
371
372 #[tokio::test]
373 async fn test_health_endpoint() {
374 let response = health().await;
375 assert_eq!(response, "OK");
376 }
377
378 #[tokio::test]
379 async fn test_api_info_endpoint() {
380 let response = api_info().await;
381 let json = response.0;
382 assert_eq!(json["name"], "shell-tunnel");
383 assert_eq!(json["status"], "running");
384 }
385}