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>>,
31 pub uploads: Arc<crate::fs::UploadStore>,
33}
34
35impl AppState {
36 pub fn new() -> Self {
37 let store = Arc::new(SessionStore::new());
38 let executor = Arc::new(CommandExecutor::new(Arc::clone(&store)));
39 Self {
40 store,
41 executor,
42 audit: Arc::new(crate::audit::AuditSink::Disabled),
43 fs: None,
44 uploads: Arc::new(crate::fs::UploadStore::new(crate::fs::DEFAULT_CHUNK_SIZE)),
45 }
46 }
47
48 pub fn with_audit(mut self, sink: Arc<crate::audit::AuditSink>) -> Self {
50 self.audit = sink;
51 self
52 }
53
54 pub fn with_fs_root(mut self, root: crate::fs::FsRoot) -> Self {
56 self.fs = Some(Arc::new(root));
57 self
58 }
59
60 pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
62 self.uploads = Arc::new(crate::fs::UploadStore::new(chunk_size));
63 self
64 }
65}
66
67fn execution_event(
73 identity: Option<crate::audit::Identity>,
74 route: &str,
75 command: &str,
76 session_id: Option<u64>,
77 result: &crate::execution::ExecutionResult,
78) -> crate::audit::AuditEvent {
79 let mut event = crate::audit::AuditEvent::new("execute")
80 .with_identity(identity)
81 .with_route(route)
82 .with_command(command)
83 .with_outcome(
84 result.exit_code,
85 result.timed_out,
86 result.duration.as_millis() as u64,
87 );
88 if let Some(id) = session_id {
89 event = event.with_session(id);
90 }
91 event
92}
93
94impl Default for AppState {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100pub async fn health() -> &'static str {
102 "OK"
103}
104
105pub async fn api_info() -> Json<serde_json::Value> {
107 Json(serde_json::json!({
108 "name": "shell-tunnel",
109 "version": env!("CARGO_PKG_VERSION"),
110 "status": "running"
111 }))
112}
113
114pub async fn list_sessions(
116 State(state): State<AppState>,
117) -> Result<Json<ListSessionsResponse>, (StatusCode, Json<ErrorResponse>)> {
118 let ids = state.store.list_ids().map_err(|e| {
119 (
120 StatusCode::INTERNAL_SERVER_ERROR,
121 Json(ErrorResponse::internal_error(e.to_string())),
122 )
123 })?;
124
125 let mut sessions = Vec::with_capacity(ids.len());
126 for id in ids {
127 if let Ok(Some(session)) = state.store.get(&id) {
128 sessions.push(SessionSummary {
129 session_id: session.id.as_u64(),
130 state: format!("{:?}", session.state),
131 idle_seconds: session.idle_duration().as_secs_f64(),
132 });
133 }
134 }
135
136 Ok(Json(ListSessionsResponse {
137 count: sessions.len(),
138 sessions,
139 }))
140}
141
142pub async fn create_session(
144 State(state): State<AppState>,
145 Json(req): Json<CreateSessionRequest>,
146) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
147 let config = SessionConfig {
148 shell: req.shell,
149 working_dir: req.working_dir,
150 env: req.env,
151 };
152
153 let session_id = state.store.create(config).map_err(|e| {
154 (
155 StatusCode::INTERNAL_SERVER_ERROR,
156 Json(ErrorResponse::internal_error(e.to_string())),
157 )
158 })?;
159
160 state
162 .store
163 .update(&session_id, |s| {
164 let _ = s.state.transition_to(SessionState::Idle);
165 })
166 .ok();
167
168 Ok((
169 StatusCode::CREATED,
170 Json(CreateSessionResponse::new(session_id)),
171 ))
172}
173
174pub async fn get_session(
176 State(state): State<AppState>,
177 Path(session_id): Path<u64>,
178) -> Result<Json<SessionStatusResponse>, (StatusCode, Json<ErrorResponse>)> {
179 let id = SessionId::from_raw(session_id);
180
181 let session = state
182 .store
183 .get(&id)
184 .map_err(|e| {
185 (
186 StatusCode::INTERNAL_SERVER_ERROR,
187 Json(ErrorResponse::internal_error(e.to_string())),
188 )
189 })?
190 .ok_or_else(|| {
191 (
192 StatusCode::NOT_FOUND,
193 Json(ErrorResponse::session_not_found(&session_id.to_string())),
194 )
195 })?;
196
197 Ok(Json(SessionStatusResponse::from_session(&session)))
198}
199
200pub async fn delete_session(
202 State(state): State<AppState>,
203 Path(session_id): Path<u64>,
204) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
205 let id = SessionId::from_raw(session_id);
206
207 state
209 .store
210 .update(&id, |s| {
211 let _ = s.state.transition_to(SessionState::Terminated);
212 })
213 .map_err(|_| {
214 (
215 StatusCode::NOT_FOUND,
216 Json(ErrorResponse::session_not_found(&session_id.to_string())),
217 )
218 })?;
219
220 state.store.remove(&id).map_err(|e| {
222 (
223 StatusCode::INTERNAL_SERVER_ERROR,
224 Json(ErrorResponse::internal_error(e.to_string())),
225 )
226 })?;
227
228 Ok(StatusCode::NO_CONTENT)
229}
230
231pub async fn execute_command(
233 State(state): State<AppState>,
234 Path(session_id): Path<u64>,
235 identity: Option<axum::Extension<crate::audit::Identity>>,
236 Json(req): Json<ExecuteCommandRequest>,
237) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
238 let id = SessionId::from_raw(session_id);
239
240 let session = state
242 .store
243 .get(&id)
244 .map_err(|e| {
245 (
246 StatusCode::INTERNAL_SERVER_ERROR,
247 Json(ErrorResponse::internal_error(e.to_string())),
248 )
249 })?
250 .ok_or_else(|| {
251 (
252 StatusCode::NOT_FOUND,
253 Json(ErrorResponse::session_not_found(&session_id.to_string())),
254 )
255 })?;
256
257 if !session.state.can_execute() {
258 return Err((
259 StatusCode::CONFLICT,
260 Json(ErrorResponse::invalid_state(session.state)),
261 ));
262 }
263
264 let mut cmd = Command::new(&req.command);
266 if let Some(dir) = &req.working_dir {
267 cmd = cmd.working_dir(PathBuf::from(dir));
268 }
269 if let Some(timeout) = req.timeout() {
270 cmd = cmd.timeout(timeout);
271 }
272 for (key, value) in &req.env {
273 cmd = cmd.env(key, value);
274 }
275
276 let result = state
278 .executor
279 .execute_in_session(&id, &cmd)
280 .await
281 .map_err(|e| {
282 (
283 StatusCode::INTERNAL_SERVER_ERROR,
284 Json(ErrorResponse::internal_error(e.to_string())),
285 )
286 })?;
287
288 state.audit.record(execution_event(
289 identity.map(|axum::Extension(id)| id),
290 "POST /api/v1/sessions/{id}/execute",
291 &req.command,
292 Some(session_id),
293 &result,
294 ));
295
296 state
298 .store
299 .update(&id, |s| {
300 s.context.record_execution(&req.command, result.exit_code);
301 })
302 .ok();
303
304 Ok(Json(ExecuteCommandResponse::from_result(&result)))
305}
306
307pub async fn execute_oneshot(
309 State(state): State<AppState>,
310 identity: Option<axum::Extension<crate::audit::Identity>>,
311 Json(req): Json<ExecuteCommandRequest>,
312) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
313 let mut cmd = Command::new(&req.command);
315 if let Some(dir) = &req.working_dir {
316 cmd = cmd.working_dir(PathBuf::from(dir));
317 }
318 if let Some(timeout) = req.timeout() {
319 cmd = cmd.timeout(timeout);
320 }
321 for (key, value) in &req.env {
322 cmd = cmd.env(key, value);
323 }
324
325 let result = state.executor.execute(&cmd).await.map_err(|e| {
327 (
328 StatusCode::INTERNAL_SERVER_ERROR,
329 Json(ErrorResponse::internal_error(e.to_string())),
330 )
331 })?;
332
333 state.audit.record(execution_event(
334 identity.map(|axum::Extension(id)| id),
335 "POST /api/v1/execute",
336 &req.command,
337 None,
338 &result,
339 ));
340
341 Ok(Json(ExecuteCommandResponse::from_result(&result)))
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_app_state_new() {
350 let state = AppState::new();
351 assert_eq!(state.store.count(), 0);
352 }
353
354 #[tokio::test]
355 async fn test_health_endpoint() {
356 let response = health().await;
357 assert_eq!(response, "OK");
358 }
359
360 #[tokio::test]
361 async fn test_api_info_endpoint() {
362 let response = api_info().await;
363 let json = response.0;
364 assert_eq!(json["name"], "shell-tunnel");
365 assert_eq!(json["status"], "running");
366 }
367}