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, SessionId, 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: SessionId,
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: SessionId,
89 pub token: String,
91 pub roles: Vec<String>,
93}
94
95pub async fn operators_create(
105 State(state): State<AppState>,
106 Json(req): Json<OperatorsCreateReq>,
107) -> Response {
108 let roles = req.roles;
109 let sid = SessionId::new();
116 let token = mlua_swarm::types::secure_hex(5);
117
118 {
119 let mut map = state.roles_to_sid.lock().await;
120 let conflicts: Vec<String> = roles
121 .iter()
122 .filter(|r| map.contains_key(r.as_str()))
123 .cloned()
124 .collect();
125 if !conflicts.is_empty() {
126 return (
127 StatusCode::CONFLICT,
128 Json(json!({"error": "roles conflict", "conflicts": conflicts})),
129 )
130 .into_response();
131 }
132 for r in &roles {
133 map.insert(r.clone(), sid.clone());
134 }
135 }
136
137 let entry = Arc::new(OperatorSessionEntry {
138 sid: sid.clone(),
139 token: token.clone(),
140 roles: roles.clone(),
141 ws_session: Mutex::new(None),
142 });
143 state
144 .operator_sessions
145 .lock()
146 .await
147 .insert(sid.clone(), entry);
148
149 (
150 StatusCode::OK,
151 Json(OperatorsCreateResp { sid, token, roles }),
152 )
153 .into_response()
154}
155
156fn extract_bearer_token_required(headers: &HeaderMap) -> Result<String, Box<Response>> {
162 let token = headers
163 .get(axum::http::header::AUTHORIZATION)
164 .and_then(|v| v.to_str().ok())
165 .and_then(|s| s.strip_prefix("Bearer "))
166 .map(|s| s.trim().to_string())
167 .filter(|s| !s.is_empty());
168 token.ok_or_else(|| {
169 Box::new((StatusCode::UNAUTHORIZED, "missing or empty Bearer token").into_response())
170 })
171}
172
173pub async fn operators_ws_connect(
179 State(state): State<AppState>,
180 Path(sid): Path<String>,
181 headers: HeaderMap,
182 ws: WebSocketUpgrade,
183) -> Response {
184 let bearer = match extract_bearer_token_required(&headers) {
185 Ok(t) => t,
186 Err(resp) => return *resp,
187 };
188 let Ok(sid) = SessionId::parse(sid) else {
190 return (StatusCode::NOT_FOUND, "unknown sid").into_response();
191 };
192
193 let entry = {
194 let map = state.operator_sessions.lock().await;
195 map.get(&sid).cloned()
196 };
197 let entry = match entry {
198 Some(e) => e,
199 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
200 };
201 if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
202 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
203 }
204
205 ws.on_upgrade(move |socket| handle_operator_socket(socket, state, entry))
206}
207
208async fn handle_operator_socket(
212 socket: WebSocket,
213 state: AppState,
214 entry: Arc<OperatorSessionEntry>,
215) {
216 let (tx, mut rx) = mpsc::unbounded_channel::<ServerMsg>();
217
218 let existing_ws = entry.ws_session.lock().await.clone();
219 let session = match existing_ws {
220 Some(ws_session) => {
221 ws_session.replace_tx(tx.clone()).await;
223 ws_session
224 }
225 None => {
226 let ws_session = Arc::new(WSOperatorSession::new_with_base_url(
227 entry.sid.clone(),
228 tx.clone(),
229 state.base_url.clone(),
230 ));
231 state
232 .engine
233 .register_senior_bridge(
234 entry.sid.clone(),
235 ws_session.clone() as Arc<dyn SeniorBridge>,
236 )
237 .await;
238 state
239 .engine
240 .register_spawn_hook(entry.sid.clone(), ws_session.clone() as Arc<dyn SpawnHook>)
241 .await;
242 state
243 .engine
244 .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>)
245 .await;
246 if let Some(factory) = &state.ws_operator_factory {
247 factory
248 .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>);
249 }
250 for role in &entry.roles {
255 if let Some(factory) = &state.ws_operator_factory {
256 factory
257 .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>);
258 }
259 state
260 .engine
261 .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>)
262 .await;
263 }
264 *entry.ws_session.lock().await = Some(ws_session.clone());
265 ws_session
266 }
267 };
268
269 let (mut ws_sink, mut ws_stream) = socket.split();
270
271 let write_task = tokio::spawn(async move {
273 while let Some(msg) = rx.recv().await {
274 let txt = match serde_json::to_string(&msg) {
275 Ok(s) => s,
276 Err(_) => continue,
277 };
278 if ws_sink.send(Message::Text(txt)).await.is_err() {
279 break;
280 }
281 }
282 let _ = ws_sink.close().await;
283 });
284
285 let session_for_read = session.clone();
287 let read_result: Result<(), String> = async {
288 while let Some(item) = ws_stream.next().await {
289 match item {
290 Ok(Message::Text(t)) => {
291 let parsed: ClientMsg = match serde_json::from_str(&t) {
292 Ok(p) => p,
293 Err(_) => continue,
294 };
295 match parsed {
296 ClientMsg::Answer { req_id, value } => {
297 session_for_read
298 .resolve_pending(&req_id, PendingReply::Answer(value))
299 .await;
300 }
301 ClientMsg::HookAck { req_id, ok, reason } => {
302 session_for_read
303 .resolve_pending(&req_id, PendingReply::HookAck { ok, reason })
304 .await;
305 }
306 ClientMsg::SpawnAck {
307 req_id,
308 value,
309 ok,
310 error,
311 } => {
312 session_for_read
313 .resolve_pending(
314 &req_id,
315 PendingReply::SpawnAck { value, ok, error },
316 )
317 .await;
318 }
319 ClientMsg::SpawnHalt {
320 req_id,
321 value,
322 reason,
323 } => {
324 session_for_read
325 .resolve_pending(&req_id, PendingReply::SpawnHalt { value, reason })
326 .await;
327 }
328 }
329 }
330 Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
331 Ok(Message::Close(_)) | Err(_) => break,
332 _ => {}
333 }
334 }
335 Ok(())
336 }
337 .await;
338
339 session.clear_tx().await;
343 write_task.abort();
344 let _ = read_result;
345}
346
347pub async fn operators_delete(
355 State(state): State<AppState>,
356 Path(sid): Path<String>,
357 headers: HeaderMap,
358) -> Response {
359 let bearer = match extract_bearer_token_required(&headers) {
360 Ok(t) => t,
361 Err(resp) => return *resp,
362 };
363 let Ok(sid) = SessionId::parse(sid) else {
364 return (StatusCode::NOT_FOUND, "unknown sid").into_response();
365 };
366
367 let entry = {
368 let map = state.operator_sessions.lock().await;
369 map.get(&sid).cloned()
370 };
371 let entry = match entry {
372 Some(e) => e,
373 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
374 };
375 if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
376 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
377 }
378
379 state.engine.unregister_senior_bridge(sid.as_str()).await;
380 state.engine.unregister_spawn_hook(sid.as_str()).await;
381 state.engine.unregister_operator(sid.as_str()).await;
382 if let Some(factory) = &state.ws_operator_factory {
383 factory.unregister_operator(sid.as_str());
384 }
385 for role in &entry.roles {
386 state.engine.unregister_operator(role).await;
387 if let Some(factory) = &state.ws_operator_factory {
388 factory.unregister_operator(role);
389 }
390 }
391
392 if let Some(session) = entry.ws_session.lock().await.take() {
393 session.clear_tx().await;
394 }
395
396 state.operator_sessions.lock().await.remove(&sid);
397
398 {
399 let mut map = state.roles_to_sid.lock().await;
400 for role in &entry.roles {
401 if map.get(role) == Some(&sid) {
402 map.remove(role);
403 }
404 }
405 }
406
407 StatusCode::NO_CONTENT.into_response()
408}
409
410#[derive(Debug, Serialize)]
414pub struct OperatorsInfoResp {
415 pub sid: SessionId,
417 pub roles: Vec<String>,
419 pub connected: bool,
421}
422
423pub async fn operators_info(
427 State(state): State<AppState>,
428 Path(sid): Path<String>,
429 headers: HeaderMap,
430) -> Response {
431 let bearer = match extract_bearer_token_required(&headers) {
432 Ok(t) => t,
433 Err(resp) => return *resp,
434 };
435 let Ok(sid) = SessionId::parse(sid) else {
436 return (StatusCode::NOT_FOUND, "unknown sid").into_response();
437 };
438
439 let entry = {
440 let map = state.operator_sessions.lock().await;
441 map.get(&sid).cloned()
442 };
443 let entry = match entry {
444 Some(e) => e,
445 None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
446 };
447 if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
448 return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
449 }
450
451 let connected = entry.ws_session.lock().await.is_some();
452 (
453 StatusCode::OK,
454 Json(OperatorsInfoResp {
455 sid: entry.sid.clone(),
456 roles: entry.roles.clone(),
457 connected,
458 }),
459 )
460 .into_response()
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use axum::http::HeaderValue;
467
468 fn headers_with_bearer(token: &str) -> HeaderMap {
469 let mut h = HeaderMap::new();
470 h.insert(
471 axum::http::header::AUTHORIZATION,
472 HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
473 );
474 h
475 }
476
477 #[test]
478 fn extract_bearer_token_required_accepts_valid() {
479 let h = headers_with_bearer("abc123");
480 assert_eq!(extract_bearer_token_required(&h).unwrap(), "abc123");
481 }
482
483 #[test]
484 fn extract_bearer_token_required_rejects_missing_header() {
485 let h = HeaderMap::new();
486 assert!(extract_bearer_token_required(&h).is_err());
487 }
488
489 #[test]
490 fn extract_bearer_token_required_rejects_empty_token() {
491 let h = headers_with_bearer("");
492 assert!(extract_bearer_token_required(&h).is_err());
493 }
494
495 #[test]
496 fn extract_bearer_token_required_rejects_wrong_scheme() {
497 let mut h = HeaderMap::new();
498 h.insert(
499 axum::http::header::AUTHORIZATION,
500 HeaderValue::from_static("Basic dXNlcjpwYXNz"),
501 );
502 assert!(extract_bearer_token_required(&h).is_err());
503 }
504}