1use crate::security::{self, Security};
2use axum::{
3 extract::{Path, Query, State},
4 http::StatusCode,
5 middleware::from_fn_with_state,
6 response::{
7 sse::{Event, Sse},
8 IntoResponse,
9 },
10 routing::{get, post},
11 Json, Router,
12};
13use futures_util::{
14 stream::{self, Stream},
15 StreamExt,
16};
17use rpi_extcore::{
18 host::BufferedEvent,
19 protocol::{self, ApiCommand},
20 reply::{IntoReply, ReplyKind, ServiceReply},
21 service::{
22 self, AuthRefreshRequest, BackendConfig, CreateProjectRequest, CreateSessionRequest,
23 FileQuery, ForkRequest, MetadataRequest, ProjectFoldersQuery, RenameRequest, Service,
24 SessionListQuery,
25 },
26};
27use serde::de::DeserializeOwned;
28use serde_json::{json, Value};
29use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
30use tokio::sync::{broadcast, watch};
31#[derive(Clone, Debug, serde::Deserialize)]
32#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
33pub struct AppConfig {
34 #[serde(skip, default = "default_service_name")]
35 pub service_name: &'static str,
36 pub project: PathBuf,
37 pub projects: Vec<PathBuf>,
39 pub projects_root: Option<PathBuf>,
40 pub rpi_bin: PathBuf,
41 pub bind: String,
42 pub port: u16,
43 pub allowed_hosts: Option<Vec<String>>,
45 pub session_dir: Option<PathBuf>,
47 pub registry: PathBuf,
49}
50
51fn default_service_name() -> &'static str {
52 "rpi-http"
53}
54
55impl Default for AppConfig {
56 fn default() -> Self {
57 Self {
58 service_name: "rpi-http",
59 project: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
60 projects: Vec::new(),
61 projects_root: None,
62 rpi_bin: PathBuf::from("rpi"),
63 bind: "127.0.0.1".into(),
64 port: 3030,
65 allowed_hosts: None,
66 session_dir: None,
67 registry: rpi_extcore::registry::default_path(),
68 }
69 }
70}
71
72#[derive(Clone)]
73struct AppState {
74 backend: Arc<Service>,
75 security: Arc<Security>,
76 shutdown: watch::Receiver<bool>,
77 service_name: &'static str,
78}
79pub struct Listening {
80 pub address: SocketAddr,
81 pub hosts: Vec<String>,
82 pub password_enabled: bool,
83}
84struct HttpReply(ServiceReply);
85impl IntoResponse for HttpReply {
86 fn into_response(self) -> axum::response::Response {
87 let status = match self.0.kind {
88 ReplyKind::Success => StatusCode::OK,
89 ReplyKind::Created => StatusCode::CREATED,
90 ReplyKind::InvalidInput => StatusCode::BAD_REQUEST,
91 ReplyKind::NotFound => StatusCode::NOT_FOUND,
92 ReplyKind::AccessDenied => StatusCode::FORBIDDEN,
93 ReplyKind::LimitExceeded => StatusCode::PAYLOAD_TOO_LARGE,
94 ReplyKind::UnsupportedContent => StatusCode::UNSUPPORTED_MEDIA_TYPE,
95 ReplyKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
96 ReplyKind::HostUnavailable => StatusCode::BAD_GATEWAY,
97 ReplyKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
98 ReplyKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
99 ReplyKind::Conflict => StatusCode::CONFLICT,
100 ReplyKind::Empty => StatusCode::NO_CONTENT,
101 ReplyKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
102 };
103 if matches!(self.0.kind, ReplyKind::Empty) {
104 status.into_response()
105 } else {
106 (status, Json(self.0.value)).into_response()
107 }
108 }
109}
110pub async fn run(config: AppConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
111 serve_until(
112 config,
113 Router::new(),
114 |info| println!("rpi HTTP API listening on {}", info.address),
115 async {
116 let _ = tokio::signal::ctrl_c().await;
117 },
118 )
119 .await
120}
121pub async fn serve_until(
122 config: AppConfig,
123 frontend: Router,
124 on_listen: impl FnOnce(Listening),
125 stop: impl std::future::Future<Output = ()> + Send + 'static,
126) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
127 let mut config = config;
128 let requested = SocketAddr::new(config.bind.parse()?, config.port);
129 let listener = tokio::net::TcpListener::bind(requested)
130 .await
131 .map_err(|error| {
132 std::io::Error::new(
133 error.kind(),
134 format!("cannot listen on {requested}: {error}"),
135 )
136 })?;
137 let address = listener.local_addr()?;
138 config.port = address.port();
139 let backend = Service::new(BackendConfig {
140 project: config.project.clone(),
141 projects: config.projects.clone(),
142 projects_root: config.projects_root.clone(),
143 rpi_bin: config.rpi_bin.clone(),
144 session_dir: config.session_dir.clone(),
145 registry: config.registry.clone(),
146 })?;
147 let hosts = allowed_hosts(&config);
148 let password = configured_password(&config);
149 let security = Arc::new(Security::new(hosts.clone(), password.as_deref()));
150 let (shutdown, receiver) = watch::channel(false);
151 let state = AppState {
152 backend,
153 security: security.clone(),
154 shutdown: receiver,
155 service_name: config.service_name,
156 };
157 let router = routes(state.clone())
158 .merge(frontend)
159 .layer(from_fn_with_state(security, crate::security::guard));
160 on_listen(Listening {
161 address,
162 hosts,
163 password_enabled: state.security.enabled(),
164 });
165 std::io::Write::flush(&mut std::io::stdout())?;
166 let mut shutdown_receiver = state.shutdown.clone();
167 let serving = std::future::IntoFuture::into_future(
168 axum::serve(listener, router).with_graceful_shutdown(async move {
169 if !*shutdown_receiver.borrow_and_update() {
170 let _ = shutdown_receiver.changed().await;
171 }
172 }),
173 );
174 tokio::pin!(serving);
175 tokio::select! {
176 result = &mut serving => result?,
177 _ = stop => {
178 println!(" Status Stopping...");
179 let _ = shutdown.send(true);
180 let _ = tokio::time::timeout(std::time::Duration::from_secs(8), async {
181 state.backend.shutdown().await;
182 let _ = serving.await;
183 }).await;
184 }
185 }
186 println!(" Status Stopped");
187 Ok(())
188}
189
190pub fn router(
193 backend: Arc<Service>,
194 config: &AppConfig,
195 shutdown: watch::Receiver<bool>,
196) -> Router {
197 let security = Arc::new(Security::new(
198 allowed_hosts(config),
199 configured_password(config).as_deref(),
200 ));
201 let state = AppState {
202 backend,
203 security: security.clone(),
204 shutdown,
205 service_name: config.service_name,
206 };
207 routes(state).layer(from_fn_with_state(security, crate::security::guard))
208}
209fn routes(state: AppState) -> Router {
210 Router::new()
211 .route("/api/v1/health", get(health))
212 .route("/api/v1/capabilities", get(capabilities))
213 .route("/api/v1/projects", get(list_projects).post(create_project))
214 .route("/api/v1/project-folders", get(list_project_folders))
215 .route("/api/v1/projects/{id}/files", get(project_files))
216 .route("/api/v1/projects/{id}/file", get(project_file))
217 .route("/api/v1/projects/{id}/git/status", get(project_status))
218 .route("/api/v1/projects/{id}/git/diff", get(project_diff))
219 .route(
220 "/api/v1/auth",
221 get(auth_status).post(auth_login).delete(auth_logout),
222 )
223 .route("/api/v1/auth/refresh", post(refresh_auth))
224 .route("/api/v1/sessions", post(create_session).get(list_sessions))
225 .route(
226 "/api/v1/sessions/{id}",
227 get(get_session).delete(delete_session),
228 )
229 .route("/api/v1/sessions/{id}/open", post(open_session))
230 .route("/api/v1/sessions/{id}/fork", post(fork_session))
231 .route("/api/v1/sessions/{id}/clone", post(clone_session))
232 .route("/api/v1/sessions/{id}/rename", post(rename_session))
233 .route("/api/v1/sessions/{id}/metadata", post(session_metadata))
234 .route("/api/v1/sessions/{id}/commands", post(command))
235 .route("/api/v1/sessions/{id}/snapshot", get(snapshot))
236 .route("/api/v1/sessions/{id}/events", get(events))
237 .with_state(state)
238}
239
240fn configured_password(config: &AppConfig) -> Option<String> {
241 let (primary, legacy) = if config.service_name == "rpi-web" {
242 ("RPI_WEB_PASSWORD", "RPI_HTTP_PASSWORD")
243 } else {
244 ("RPI_HTTP_PASSWORD", "RPI_WEB_PASSWORD")
245 };
246 std::env::var(primary)
247 .or_else(|_| std::env::var(legacy))
248 .ok()
249 .map(|value| value.trim().to_string())
250 .filter(|value| !value.is_empty())
251}
252
253fn allowed_hosts(config: &AppConfig) -> Vec<String> {
254 let (primary, legacy) = if config.service_name == "rpi-web" {
255 ("RPI_WEB_ALLOWED_HOSTS", "RPI_HTTP_ALLOWED_HOSTS")
256 } else {
257 ("RPI_HTTP_ALLOWED_HOSTS", "RPI_WEB_ALLOWED_HOSTS")
258 };
259 std::env::var(primary)
260 .or_else(|_| std::env::var(legacy))
261 .ok()
262 .map(|value| {
263 value
264 .split(',')
265 .map(str::trim)
266 .filter(|host| !host.is_empty())
267 .map(str::to_string)
268 .collect()
269 })
270 .filter(|hosts: &Vec<String>| !hosts.is_empty())
271 .or_else(|| config.allowed_hosts.clone())
272 .unwrap_or_else(|| {
273 let mut hosts = vec![
274 format!("127.0.0.1:{}", config.port),
275 format!("localhost:{}", config.port),
276 ];
277 if let Ok(ip) = config.bind.parse::<std::net::IpAddr>() {
278 if ip.is_ipv6() {
279 hosts.push(format!("[::1]:{}", config.port));
280 }
281 if !ip.is_unspecified() {
282 let authority = SocketAddr::new(ip, config.port).to_string();
283 if !hosts.contains(&authority) {
284 hosts.push(authority);
285 }
286 }
287 }
288 hosts
289 })
290}
291
292async fn auth_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Json<Value> {
293 security::status_for(&state.security, &headers).await
294}
295
296async fn auth_login(
297 State(state): State<AppState>,
298 headers: axum::http::HeaderMap,
299 Json(request): Json<security::LoginRequest>,
300) -> impl IntoResponse {
301 security::login_for(&state.security, &headers, request).await
302}
303
304async fn auth_logout(
305 State(state): State<AppState>,
306 headers: axum::http::HeaderMap,
307) -> axum::response::Response {
308 security::logout_for(&state.security, &headers).await
309}
310
311async fn health(State(state): State<AppState>) -> Json<Value> {
312 Json(json!({"ok":true,"service":state.service_name,"protocol":"rpi"}))
313}
314async fn capabilities() -> Json<Value> {
315 Json(protocol::capabilities())
316}
317async fn list_projects(State(state): State<AppState>) -> axum::response::Response {
318 HttpReply(service::list_projects(&state.backend).await.into_reply()).into_response()
319}
320
321async fn list_project_folders(
322 State(state): State<AppState>,
323 Query(query): Query<ProjectFoldersQuery>,
324) -> axum::response::Response {
325 HttpReply(
326 service::list_project_folders(&state.backend, query)
327 .await
328 .into_reply(),
329 )
330 .into_response()
331}
332
333async fn create_project(
334 State(state): State<AppState>,
335 Json(request): Json<CreateProjectRequest>,
336) -> axum::response::Response {
337 HttpReply(
338 service::create_project(&state.backend, request)
339 .await
340 .into_reply(),
341 )
342 .into_response()
343}
344
345async fn project_files(
346 State(state): State<AppState>,
347 Path(id): Path<String>,
348 Query(query): Query<FileQuery>,
349) -> axum::response::Response {
350 HttpReply(
351 service::project_files(&state.backend, id, query)
352 .await
353 .into_reply(),
354 )
355 .into_response()
356}
357
358async fn project_file(
359 State(state): State<AppState>,
360 Path(id): Path<String>,
361 Query(query): Query<FileQuery>,
362) -> axum::response::Response {
363 HttpReply(
364 service::project_file(&state.backend, id, query)
365 .await
366 .into_reply(),
367 )
368 .into_response()
369}
370
371async fn project_status(
372 State(state): State<AppState>,
373 Path(id): Path<String>,
374) -> axum::response::Response {
375 HttpReply(
376 service::project_status(&state.backend, id)
377 .await
378 .into_reply(),
379 )
380 .into_response()
381}
382
383async fn project_diff(
384 State(state): State<AppState>,
385 Path(id): Path<String>,
386 Query(query): Query<FileQuery>,
387) -> axum::response::Response {
388 HttpReply(
389 service::project_diff(&state.backend, id, query)
390 .await
391 .into_reply(),
392 )
393 .into_response()
394}
395
396async fn refresh_auth(
397 State(state): State<AppState>,
398 Json(request): Json<AuthRefreshRequest>,
399) -> axum::response::Response {
400 HttpReply(
401 service::refresh_auth(&state.backend, request)
402 .await
403 .into_reply(),
404 )
405 .into_response()
406}
407
408async fn create_session(
409 State(state): State<AppState>,
410 body: axum::body::Bytes,
411) -> axum::response::Response {
412 let request: CreateSessionRequest = match optional_json(&body) {
413 Ok(request) => request,
414 Err(error) => return error.into_response(),
415 };
416 HttpReply(
417 service::create_session(&state.backend, request)
418 .await
419 .into_reply(),
420 )
421 .into_response()
422}
423
424async fn rename_session(
425 State(state): State<AppState>,
426 Path(id): Path<String>,
427 Json(request): Json<RenameRequest>,
428) -> axum::response::Response {
429 HttpReply(
430 service::rename_session(&state.backend, id, request)
431 .await
432 .into_reply(),
433 )
434 .into_response()
435}
436
437async fn session_metadata(
438 State(state): State<AppState>,
439 Path(id): Path<String>,
440 Json(request): Json<MetadataRequest>,
441) -> axum::response::Response {
442 HttpReply(
443 service::session_metadata(&state.backend, id, request)
444 .await
445 .into_reply(),
446 )
447 .into_response()
448}
449
450async fn fork_session(
451 State(state): State<AppState>,
452 Path(id): Path<String>,
453 Json(request): Json<ForkRequest>,
454) -> axum::response::Response {
455 HttpReply(
456 service::fork_session(&state.backend, id, request)
457 .await
458 .into_reply(),
459 )
460 .into_response()
461}
462
463async fn clone_session(
464 State(state): State<AppState>,
465 Path(id): Path<String>,
466 body: axum::body::Bytes,
467) -> axum::response::Response {
468 let request: ForkRequest = match optional_json(&body) {
469 Ok(request) => request,
470 Err(error) => return error.into_response(),
471 };
472 HttpReply(
473 service::clone_session(&state.backend, id, request)
474 .await
475 .into_reply(),
476 )
477 .into_response()
478}
479
480async fn open_session(
481 State(state): State<AppState>,
482 Path(id): Path<String>,
483) -> axum::response::Response {
484 HttpReply(service::open_session(&state.backend, id).await.into_reply()).into_response()
485}
486
487async fn list_sessions(
488 State(state): State<AppState>,
489 Query(query): Query<SessionListQuery>,
490) -> axum::response::Response {
491 HttpReply(
492 service::list_sessions(&state.backend, query)
493 .await
494 .into_reply(),
495 )
496 .into_response()
497}
498
499async fn get_session(
500 State(state): State<AppState>,
501 Path(id): Path<String>,
502) -> axum::response::Response {
503 HttpReply(service::get_session(&state.backend, id).await.into_reply()).into_response()
504}
505
506async fn delete_session(
507 State(state): State<AppState>,
508 Path(id): Path<String>,
509) -> axum::response::Response {
510 HttpReply(
511 service::delete_session(&state.backend, id)
512 .await
513 .into_reply(),
514 )
515 .into_response()
516}
517
518async fn command(
519 State(state): State<AppState>,
520 Path(id): Path<String>,
521 Json(command): Json<ApiCommand>,
522) -> axum::response::Response {
523 HttpReply(
524 service::command(&state.backend, id, command)
525 .await
526 .into_reply(),
527 )
528 .into_response()
529}
530
531async fn snapshot(
532 State(state): State<AppState>,
533 Path(id): Path<String>,
534 Query(options): Query<service::SnapshotOptions>,
535) -> axum::response::Response {
536 HttpReply(
537 service::snapshot_with_options(&state.backend, id, options)
538 .await
539 .into_reply(),
540 )
541 .into_response()
542}
543
544fn optional_json<T: DeserializeOwned + Default>(
547 body: &[u8],
548) -> Result<T, (StatusCode, Json<Value>)> {
549 if body.is_empty() {
550 return Ok(T::default());
551 }
552 serde_json::from_slice(body).map_err(|_| {
553 (
554 StatusCode::BAD_REQUEST,
555 Json(json!({"error":"invalid JSON request body"})),
556 )
557 })
558}
559
560#[derive(Debug, serde::Deserialize)]
561struct EventQuery {
562 after: Option<u64>,
563}
564
565async fn events(
566 State(state): State<AppState>,
567 Path(id): Path<String>,
568 headers: axum::http::HeaderMap,
569 Query(query): Query<EventQuery>,
570) -> axum::response::Response {
571 let after = headers
572 .get("last-event-id")
573 .and_then(|value| value.to_str().ok())
574 .and_then(|value| value.parse::<u64>().ok())
575 .or(query.after);
576 let Some(subscription) = state.backend.subscribe_events(&id, after).await else {
577 return (
578 StatusCode::NOT_FOUND,
579 Json(json!({"error":"session not found"})),
580 )
581 .into_response();
582 };
583 let mut shutdown = state.shutdown;
584 Sse::new(
585 authorized_events(
586 event_stream(
587 subscription.ready,
588 subscription.replay,
589 subscription.receiver,
590 subscription.gap,
591 subscription.after,
592 ),
593 state.security,
594 headers,
595 )
596 .take_until(async move {
597 if !*shutdown.borrow_and_update() {
598 let _ = shutdown.changed().await;
599 }
600 }),
601 )
602 .keep_alive(axum::response::sse::KeepAlive::default())
603 .into_response()
604}
605
606fn event_stream(
607 ready: Value,
608 replay: Vec<BufferedEvent>,
609 receiver: broadcast::Receiver<BufferedEvent>,
610 gap: bool,
611 after: Option<u64>,
612) -> impl Stream<Item = Result<Event, Infallible>> {
613 let head: Vec<Result<Event, Infallible>> =
614 std::iter::once(Ok(Event::default().event("ready").data(ready.to_string())))
615 .chain(gap.then(|| {
616 Ok(Event::default()
617 .event("resync_required")
618 .data("{\"type\":\"resync_required\"}"))
619 }))
620 .collect();
621 stream::iter(head).chain(rpi_extcore::events::replay_stream(replay,receiver,after).map(|item| {
622 Ok(match item {
623 rpi_extcore::events::StreamItem::Event(event)=>to_sse(event),
624 rpi_extcore::events::StreamItem::ResyncRequired{dropped}=>Event::default().event("resync_required").data(json!({"type":"resync_required","code":"event_stream_lagged","dropped":dropped}).to_string()),
625 })
626 }))
627}
628
629fn authorized_events(
632 events: impl Stream<Item = Result<Event, Infallible>> + Send + 'static,
633 security: Arc<Security>,
634 headers: axum::http::HeaderMap,
635) -> impl Stream<Item = Result<Event, Infallible>> {
636 let expiry_check = tokio::time::interval(std::time::Duration::from_secs(10));
637 stream::unfold(
638 (Box::pin(events), expiry_check),
639 move |(mut events, mut expiry_check)| {
640 let security = security.clone();
641 let headers = headers.clone();
642 async move {
643 loop {
644 tokio::select! {
645 event = events.next() => {
646 let event = event?;
647 if !security.authorized(&headers).await { return None; }
648 return Some((event, (events, expiry_check)));
649 }
650 _ = expiry_check.tick() => {
651 if !security.authorized(&headers).await { return None; }
652 }
653 }
654 }
655 }
656 },
657 )
658}
659
660fn to_sse(mut event: BufferedEvent) -> Event {
661 rpi_extcore::timing::refresh_event(&mut event.value);
662 let event_type = event
663 .value
664 .get("type")
665 .and_then(Value::as_str)
666 .unwrap_or("message");
667 Event::default()
668 .event(event_type)
669 .id(event.seq.to_string())
670 .data(event.value.to_string())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn optional_creation_bodies_reject_invalid_json_and_types() {
679 assert!(optional_json::<CreateSessionRequest>(b"").is_ok());
680 assert!(optional_json::<CreateSessionRequest>(br#"{"projectId":"project-a"}"#).is_ok());
681 for body in [b"{".as_slice(), b"null", br#"{"projectId":42}"#] {
682 assert_eq!(
683 optional_json::<CreateSessionRequest>(body).unwrap_err().0,
684 StatusCode::BAD_REQUEST
685 );
686 }
687 assert!(optional_json::<ForkRequest>(b"").is_ok());
688 assert!(optional_json::<ForkRequest>(br#"{"entryId":42}"#).is_err());
689 }
690
691 #[tokio::test]
692 async fn live_events_do_not_duplicate_replay_or_the_snapshot_cursor() {
693 for replay in [
694 vec![],
695 vec![BufferedEvent {
696 seq: 5,
697 value: json!({"type":"agent_start"}),
698 }],
699 ] {
700 let (sender, receiver) = broadcast::channel(8);
701 for seq in [4, 5, 6] {
702 sender
703 .send(BufferedEvent {
704 seq,
705 value: json!({"type":"agent_start"}),
706 })
707 .unwrap();
708 }
709 drop(sender);
710 let has_replay = !replay.is_empty();
711 let stream = event_stream(json!({"type":"ready"}), replay, receiver, false, Some(5));
712 let body = axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 4096)
713 .await
714 .unwrap();
715 let text = String::from_utf8(body.to_vec()).unwrap();
716 assert!(!text.contains("id: 4\n"));
717 assert_eq!(text.matches("id: 5\n").count(), usize::from(has_replay));
718 assert_eq!(text.matches("id: 6\n").count(), 1);
719 }
720 }
721
722 #[tokio::test]
723 async fn logout_revokes_an_already_open_event_stream() {
724 let security = Arc::new(Security::new(["example.test".into()], Some("secret")));
725 let token = security.login("secret").await.unwrap();
726 let mut headers = axum::http::HeaderMap::new();
727 headers.insert(
728 axum::http::header::COOKIE,
729 format!("rpi_web_session={token}").parse().unwrap(),
730 );
731 let (sender, receiver) = broadcast::channel(8);
732 let stream = authorized_events(
733 event_stream(json!({"type":"ready"}), vec![], receiver, false, None),
734 security.clone(),
735 headers.clone(),
736 );
737 futures_util::pin_mut!(stream);
738 assert!(stream.next().await.is_some());
739 security.logout(&headers).await;
740 sender
741 .send(BufferedEvent {
742 seq: 1,
743 value: json!({"type":"message_end","message":"private"}),
744 })
745 .unwrap();
746 assert!(stream.next().await.is_none());
747 }
748}