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 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
105pub async fn health() -> &'static str {
107 "OK"
108}
109
110pub 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
119pub 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
147pub 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 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
179pub 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
205pub 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 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 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
236pub 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 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 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 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 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
312pub 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 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 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}