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