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_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 pub fn with_fs_root(mut self, root: crate::fs::FsRoot) -> Self {
70 self.fs = Some(Arc::new(root));
71 self
72 }
73
74 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
81fn 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
115pub async fn health() -> &'static str {
117 "OK"
118}
119
120pub 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
129pub 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
157pub 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 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
187pub 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
213pub 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 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 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
244pub 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 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 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 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 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
326pub 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 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 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}