Skip to main content

rpi_http/
server.rs

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    /// Additional workspace roots explicitly made available to clients.
38    pub projects: Vec<PathBuf>,
39    pub projects_root: Option<PathBuf>,
40    pub rpi_bin: PathBuf,
41    pub bind: String,
42    pub port: u16,
43    /// Optional Host allowlist; RPI_WEB_ALLOWED_HOSTS takes precedence.
44    pub allowed_hosts: Option<Vec<String>>,
45    /// Optional `--session-dir` forwarded to the native host.
46    pub session_dir: Option<PathBuf>,
47    /// Where the bridge remembers session ids across restarts.
48    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
190/// Build only the API routes around an existing shared service.
191/// The port must be the actual listener port when validating Host headers.
192pub 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
544// An absent body keeps the legacy default, but a malformed supplied body must
545// never turn into a successful creation with silently discarded parameters.
546fn 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
629// The middleware checks only the initial HTTP request. A long-lived stream must
630// also stop after another tab logs out or the authentication session expires.
631fn 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}