mlua_swarm_server/operator_ws/
login.rs1use axum::{
38 extract::{
39 ws::{Message, WebSocket, WebSocketUpgrade},
40 Path, State,
41 },
42 http::{HeaderMap, StatusCode},
43 response::{IntoResponse, Response},
44 Json,
45};
46use futures_util::{sink::SinkExt, stream::StreamExt};
47use mlua_swarm::{Operator, SeniorBridge, SpawnHook};
48use serde::{Deserialize, Serialize};
49use serde_json::json;
50use std::sync::Arc;
51use tokio::sync::{mpsc, Mutex};
52
53use super::protocol::{ClientMsg, PendingReply, ServerMsg};
54use super::session::WSOperatorSession;
55use crate::AppState;
56
57pub struct OperatorSessionEntry {
63 pub sid: String,
65 pub token: String,
67 pub roles: Vec<String>,
69 pub ws_session: Mutex<Option<Arc<WSOperatorSession>>>,
71}
72
73#[derive(Debug, Deserialize, Default)]
77pub struct OperatorsCreateReq {
78 #[serde(default)]
80 pub roles: Vec<String>,
81}
82
83#[derive(Debug, Serialize)]
85pub struct OperatorsCreateResp {
86 pub sid: String,
88 pub token: String,
90 pub roles: Vec<String>,
92}
93
94pub async fn operators_create(
103 State(state): State<AppState>,
104 Json(req): Json<OperatorsCreateReq>,
105) -> Response {
106 let roles = req.roles;
107 let sid = format!("op-{}", uuid::Uuid::new_v4());
108 let token = mlua_swarm::types::secure_hex(5);
109
110 {
111 let mut map = state.roles_to_sid.lock().await;
112 let conflicts: Vec<String> = roles
113 .iter()
114 .filter(|r| map.contains_key(r.as_str()))
115 .cloned()
116 .collect();
117 if !conflicts.is_empty() {
118 return (
119 StatusCode::CONFLICT,
120 Json(json!({"error": "roles conflict", "conflicts": conflicts})),
121 )
122 .into_response();
123 }
124 for r in &roles {
125 map.insert(r.clone(), sid.clone());
126 }
127 }
128
129 let entry = Arc::new(OperatorSessionEntry {
130 sid: sid.clone(),
131 token: token.clone(),
132 roles: roles.clone(),
133 ws_session: Mutex::new(None),
134 });
135 state
136 .operator_sessions
137 .lock()
138 .await
139 .insert(sid.clone(), entry);
140
141 (
142 StatusCode::OK,
143 Json(OperatorsCreateResp { sid, token, roles }),
144 )
145 .into_response()
146}
147
148fn extract_bearer_token_required(headers: &HeaderMap) -> Result<String, Box<Response>> {
154 let token = headers
155 .get(axum::http::header::AUTHORIZATION)
156 .and_then(|v| v.to_str().ok())
157 .and_then(|s| s.strip_prefix("Bearer "))
158 .map(|s| s.trim().to_string())
159 .filter(|s| !s.is_empty());
160 token.ok_or_else(|| {
161 Box::new((StatusCode::UNAUTHORIZED, "missing or empty Bearer token").into_response())
162 })
163}
164
165pub async fn operators_ws_connect(
171 State(state): State<AppState>,
172 Path(sid): Path<String>,
173 headers: HeaderMap,
174 ws: WebSocketUpgrade,
175) -> Response {
176 let bearer = match extract_bearer_token_required(&headers) {
177 Ok(t) => t,
178 Err(resp) => return *resp,
179 };
180
181 let entry = {
182 let map = state.operator_sessions.lock().await;
183 map.get(&sid).cloned()
184 };
185 let entry = match entry {
186 Some(e) => e,
187 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
188 };
189 if entry.token != bearer {
190 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
191 }
192
193 ws.on_upgrade(move |socket| handle_operator_socket(socket, state, entry))
194}
195
196async fn handle_operator_socket(
200 socket: WebSocket,
201 state: AppState,
202 entry: Arc<OperatorSessionEntry>,
203) {
204 let (tx, mut rx) = mpsc::unbounded_channel::<ServerMsg>();
205
206 let existing_ws = entry.ws_session.lock().await.clone();
207 let session = match existing_ws {
208 Some(ws_session) => {
209 ws_session.replace_tx(tx.clone()).await;
211 ws_session
212 }
213 None => {
214 let ws_session = Arc::new(WSOperatorSession::new_with_base_url(
215 entry.sid.clone(),
216 tx.clone(),
217 state.base_url.clone(),
218 ));
219 state
220 .engine
221 .register_senior_bridge(
222 entry.sid.clone(),
223 ws_session.clone() as Arc<dyn SeniorBridge>,
224 )
225 .await;
226 state
227 .engine
228 .register_spawn_hook(entry.sid.clone(), ws_session.clone() as Arc<dyn SpawnHook>)
229 .await;
230 state
231 .engine
232 .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>)
233 .await;
234 if let Some(factory) = &state.ws_operator_factory {
235 factory
236 .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>);
237 }
238 for role in &entry.roles {
243 if let Some(factory) = &state.ws_operator_factory {
244 factory
245 .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>);
246 }
247 state
248 .engine
249 .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>)
250 .await;
251 }
252 *entry.ws_session.lock().await = Some(ws_session.clone());
253 ws_session
254 }
255 };
256
257 let (mut ws_sink, mut ws_stream) = socket.split();
258
259 let write_task = tokio::spawn(async move {
261 while let Some(msg) = rx.recv().await {
262 let txt = match serde_json::to_string(&msg) {
263 Ok(s) => s,
264 Err(_) => continue,
265 };
266 if ws_sink.send(Message::Text(txt)).await.is_err() {
267 break;
268 }
269 }
270 let _ = ws_sink.close().await;
271 });
272
273 let session_for_read = session.clone();
275 let read_result: Result<(), String> = async {
276 while let Some(item) = ws_stream.next().await {
277 match item {
278 Ok(Message::Text(t)) => {
279 let parsed: ClientMsg = match serde_json::from_str(&t) {
280 Ok(p) => p,
281 Err(_) => continue,
282 };
283 match parsed {
284 ClientMsg::Answer { req_id, value } => {
285 session_for_read
286 .resolve_pending(&req_id, PendingReply::Answer(value))
287 .await;
288 }
289 ClientMsg::HookAck { req_id, ok, reason } => {
290 session_for_read
291 .resolve_pending(&req_id, PendingReply::HookAck { ok, reason })
292 .await;
293 }
294 ClientMsg::SpawnAck {
295 req_id,
296 value,
297 ok,
298 error,
299 } => {
300 session_for_read
301 .resolve_pending(
302 &req_id,
303 PendingReply::SpawnAck { value, ok, error },
304 )
305 .await;
306 }
307 ClientMsg::SpawnHalt {
308 req_id,
309 value,
310 reason,
311 } => {
312 session_for_read
313 .resolve_pending(
314 &req_id,
315 PendingReply::SpawnHalt { value, reason },
316 )
317 .await;
318 }
319 }
320 }
321 Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
322 Ok(Message::Close(_)) | Err(_) => break,
323 _ => {}
324 }
325 }
326 Ok(())
327 }
328 .await;
329
330 session.clear_tx().await;
334 write_task.abort();
335 let _ = read_result;
336}
337
338pub async fn operators_delete(
346 State(state): State<AppState>,
347 Path(sid): Path<String>,
348 headers: HeaderMap,
349) -> Response {
350 let bearer = match extract_bearer_token_required(&headers) {
351 Ok(t) => t,
352 Err(resp) => return *resp,
353 };
354
355 let entry = {
356 let map = state.operator_sessions.lock().await;
357 map.get(&sid).cloned()
358 };
359 let entry = match entry {
360 Some(e) => e,
361 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
362 };
363 if entry.token != bearer {
364 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
365 }
366
367 state.engine.unregister_senior_bridge(&sid).await;
368 state.engine.unregister_spawn_hook(&sid).await;
369 state.engine.unregister_operator(&sid).await;
370 if let Some(factory) = &state.ws_operator_factory {
371 factory.unregister_operator(&sid);
372 }
373 for role in &entry.roles {
374 state.engine.unregister_operator(role).await;
375 if let Some(factory) = &state.ws_operator_factory {
376 factory.unregister_operator(role);
377 }
378 }
379
380 if let Some(session) = entry.ws_session.lock().await.take() {
381 session.clear_tx().await;
382 }
383
384 state.operator_sessions.lock().await.remove(&sid);
385
386 {
387 let mut map = state.roles_to_sid.lock().await;
388 for role in &entry.roles {
389 if map.get(role).map(String::as_str) == Some(sid.as_str()) {
390 map.remove(role);
391 }
392 }
393 }
394
395 StatusCode::NO_CONTENT.into_response()
396}
397
398#[derive(Debug, Serialize)]
402pub struct OperatorsInfoResp {
403 pub sid: String,
405 pub roles: Vec<String>,
407 pub connected: bool,
409}
410
411pub async fn operators_info(
415 State(state): State<AppState>,
416 Path(sid): Path<String>,
417 headers: HeaderMap,
418) -> Response {
419 let bearer = match extract_bearer_token_required(&headers) {
420 Ok(t) => t,
421 Err(resp) => return *resp,
422 };
423
424 let entry = {
425 let map = state.operator_sessions.lock().await;
426 map.get(&sid).cloned()
427 };
428 let entry = match entry {
429 Some(e) => e,
430 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
431 };
432 if entry.token != bearer {
433 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
434 }
435
436 let connected = entry.ws_session.lock().await.is_some();
437 (
438 StatusCode::OK,
439 Json(OperatorsInfoResp {
440 sid: entry.sid.clone(),
441 roles: entry.roles.clone(),
442 connected,
443 }),
444 )
445 .into_response()
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use axum::http::HeaderValue;
452
453 fn headers_with_bearer(token: &str) -> HeaderMap {
454 let mut h = HeaderMap::new();
455 h.insert(
456 axum::http::header::AUTHORIZATION,
457 HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
458 );
459 h
460 }
461
462 #[test]
463 fn extract_bearer_token_required_accepts_valid() {
464 let h = headers_with_bearer("abc123");
465 assert_eq!(extract_bearer_token_required(&h).unwrap(), "abc123");
466 }
467
468 #[test]
469 fn extract_bearer_token_required_rejects_missing_header() {
470 let h = HeaderMap::new();
471 assert!(extract_bearer_token_required(&h).is_err());
472 }
473
474 #[test]
475 fn extract_bearer_token_required_rejects_empty_token() {
476 let h = headers_with_bearer("");
477 assert!(extract_bearer_token_required(&h).is_err());
478 }
479
480 #[test]
481 fn extract_bearer_token_required_rejects_wrong_scheme() {
482 let mut h = HeaderMap::new();
483 h.insert(
484 axum::http::header::AUTHORIZATION,
485 HeaderValue::from_static("Basic dXNlcjpwYXNz"),
486 );
487 assert!(extract_bearer_token_required(&h).is_err());
488 }
489}