Skip to main content

aev_bridge/
lib.rs

1use anyhow::{anyhow, Context, Result};
2use axum::body::Body;
3use axum::extract::{Path, Query, State};
4use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode, Uri};
5use axum::middleware::{self, Next};
6use axum::response::sse::{Event, KeepAlive, Sse};
7use axum::response::{Html, IntoResponse, Response};
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use clap::Parser;
11use futures_core::Stream;
12use reqwest::Method;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::convert::Infallible;
16use std::ffi::c_char;
17use std::net::{SocketAddr, UdpSocket};
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::net::TcpListener;
21use uuid::Uuid;
22
23pub const PLUGIN_ID: &str = "aev-bridge";
24pub const PLUGIN_NAME: &str = "aev-bridge";
25pub const CRATE_NAME: &str = "aev-bridge";
26pub const ENTRYPOINT: &str = "aev_plugin_init";
27pub const PROTOCOL: &str = "aev.plugin.v1";
28pub const DEFAULT_BIND: &str = "0.0.0.0:8780";
29pub const DEFAULT_UPSTREAM: &str = "http://127.0.0.1:8765";
30pub const PLUGIN_INIT_JSON: &str = r#"{"id":"aev-bridge","name":"aev-bridge","package":"aev-bridge","entrypoint":"aev_plugin_init","protocol":"aev.plugin.v1","capabilities":["tool","bridge","mobile","http","sse","bearer-auth"]}"#;
31const PLUGIN_INIT_JSON_NUL: &str = "{\"id\":\"aev-bridge\",\"name\":\"aev-bridge\",\"package\":\"aev-bridge\",\"entrypoint\":\"aev_plugin_init\",\"protocol\":\"aev.plugin.v1\",\"capabilities\":[\"tool\",\"bridge\",\"mobile\",\"http\",\"sse\",\"bearer-auth\"]}\0";
32
33#[no_mangle]
34pub extern "C" fn aev_plugin_init() -> *const c_char {
35    PLUGIN_INIT_JSON_NUL.as_ptr().cast()
36}
37
38#[derive(Parser, Debug, Clone)]
39#[command(
40    name = "aev-bridge",
41    about = "Start the private Aev phone bridge for lios-web."
42)]
43pub struct Cli {
44    #[arg(long, env = "AEV_BRIDGE_BIND", default_value = DEFAULT_BIND)]
45    pub bind: SocketAddr,
46    #[arg(long, env = "AEV_BRIDGE_TOKEN")]
47    pub token: Option<String>,
48    #[arg(long, env = "AEV_BRIDGE_UPSTREAM", default_value = DEFAULT_UPSTREAM)]
49    pub upstream: String,
50    #[arg(long, env = "AEV_BRIDGE_UPSTREAM_TOKEN")]
51    pub upstream_token: Option<String>,
52    #[arg(long, env = "AEV_BRIDGE_PUBLIC_URL")]
53    pub public_url: Option<String>,
54    #[arg(long, env = "AEV_BRIDGE_POLL_MS", default_value_t = 300)]
55    pub poll_ms: u64,
56}
57
58#[derive(Debug, Clone)]
59pub struct ServerConfig {
60    pub bind: SocketAddr,
61    pub token: String,
62    pub upstream: String,
63    pub upstream_token: String,
64    pub public_url: Option<String>,
65    pub poll_ms: u64,
66}
67
68impl From<Cli> for ServerConfig {
69    fn from(cli: Cli) -> Self {
70        let upstream_token = cli
71            .upstream_token
72            .or_else(|| std::env::var("LIOS_WEB_TOKEN").ok())
73            .unwrap_or_default();
74        Self {
75            bind: cli.bind,
76            token: normalize_token(cli.token),
77            upstream: normalize_upstream(&cli.upstream),
78            upstream_token: upstream_token.trim().to_string(),
79            public_url: cli
80                .public_url
81                .map(|url| url.trim().trim_end_matches('/').to_string()),
82            poll_ms: cli.poll_ms.clamp(100, 5_000),
83        }
84    }
85}
86
87#[derive(Debug, Clone, Serialize)]
88pub struct PluginManifest {
89    pub id: &'static str,
90    pub name: &'static str,
91    pub package: &'static str,
92    pub entrypoint: &'static str,
93    pub protocol: &'static str,
94    pub capabilities: Vec<&'static str>,
95    pub default_bind: &'static str,
96    pub default_upstream: &'static str,
97}
98
99pub fn plugin_manifest() -> PluginManifest {
100    PluginManifest {
101        id: PLUGIN_ID,
102        name: PLUGIN_NAME,
103        package: CRATE_NAME,
104        entrypoint: ENTRYPOINT,
105        protocol: PROTOCOL,
106        capabilities: vec!["tool", "bridge", "mobile", "http", "sse", "bearer-auth"],
107        default_bind: DEFAULT_BIND,
108        default_upstream: DEFAULT_UPSTREAM,
109    }
110}
111
112pub async fn serve(config: ServerConfig) -> Result<()> {
113    if config.upstream_token.trim().is_empty() {
114        return Err(anyhow!(
115            "missing upstream lios-web token; set LIOS_WEB_TOKEN or --upstream-token"
116        ));
117    }
118
119    let bridge_url = phone_url(&config);
120    let state = AppState::new(config)?;
121    let app = router(state.clone());
122    let listener = TcpListener::bind(state.bind)
123        .await
124        .with_context(|| format!("failed to bind aev-bridge to {}", state.bind))?;
125    let local_addr = listener
126        .local_addr()
127        .context("failed to read aev-bridge listener address")?;
128
129    eprintln!("aev-bridge listening on http://{local_addr}");
130    eprintln!("phone URL: {bridge_url}/?token={}", state.token);
131    eprintln!("upstream: {}", state.upstream);
132
133    axum::serve(listener, app)
134        .with_graceful_shutdown(shutdown_signal())
135        .await
136        .context("aev-bridge server failed")
137}
138
139pub fn router(state: AppState) -> Router {
140    let auth_token = state.token.clone();
141    let api = Router::new()
142        .route("/manifest", get(manifest_handler))
143        .route("/upstream/health", get(upstream_health_handler))
144        .route(
145            "/sessions",
146            get(list_sessions_handler).post(create_session_handler),
147        )
148        .route(
149            "/sessions/{id}",
150            get(get_session_handler).delete(delete_session_handler),
151        )
152        .route("/sessions/{id}/events", get(output_events_handler))
153        .route("/sessions/{id}/input", post(write_input_handler))
154        .route_layer(middleware::from_fn_with_state(auth_token, require_token));
155
156    Router::new()
157        .route("/", get(index_handler))
158        .route("/health", get(health_handler))
159        .nest("/api", api)
160        .with_state(state)
161}
162
163#[derive(Clone)]
164pub struct AppState {
165    bind: SocketAddr,
166    token: Arc<str>,
167    upstream: Arc<str>,
168    upstream_token: Arc<str>,
169    poll: Duration,
170    client: reqwest::Client,
171}
172
173impl AppState {
174    pub fn new(config: ServerConfig) -> Result<Self> {
175        Ok(Self {
176            bind: config.bind,
177            token: Arc::<str>::from(config.token),
178            upstream: Arc::<str>::from(normalize_upstream(&config.upstream)),
179            upstream_token: Arc::<str>::from(config.upstream_token),
180            poll: Duration::from_millis(config.poll_ms.clamp(100, 5_000)),
181            client: reqwest::Client::builder()
182                .build()
183                .context("failed to build bridge HTTP client")?,
184        })
185    }
186
187    fn upstream_url(&self, path: &str) -> String {
188        format!("{}{}", self.upstream, path)
189    }
190
191    fn upstream_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
192        request.bearer_auth(self.upstream_token.as_ref())
193    }
194}
195
196#[derive(Debug, Deserialize)]
197pub struct EventQuery {
198    pub since: Option<u64>,
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct UpstreamOutput {
203    pub id: Uuid,
204    pub cursor: u64,
205    pub next: u64,
206    pub data: String,
207    pub dropped: bool,
208    pub alive: bool,
209}
210
211#[derive(Debug, Serialize)]
212struct HealthResponse {
213    id: &'static str,
214    status: &'static str,
215    protocol: &'static str,
216    upstream: String,
217}
218
219#[derive(Debug, Serialize)]
220struct ErrorResponse {
221    error: String,
222}
223
224#[derive(Debug)]
225struct ApiError {
226    status: StatusCode,
227    message: String,
228}
229
230impl ApiError {
231    fn bad_gateway(message: impl Into<String>) -> Self {
232        Self {
233            status: StatusCode::BAD_GATEWAY,
234            message: message.into(),
235        }
236    }
237
238    fn upstream(error: reqwest::Error) -> Self {
239        Self::bad_gateway(error.to_string())
240    }
241}
242
243impl IntoResponse for ApiError {
244    fn into_response(self) -> Response {
245        (
246            self.status,
247            Json(ErrorResponse {
248                error: self.message,
249            }),
250        )
251            .into_response()
252    }
253}
254
255async fn index_handler() -> Html<&'static str> {
256    Html(INDEX_HTML)
257}
258
259async fn health_handler(State(state): State<AppState>) -> Json<HealthResponse> {
260    Json(HealthResponse {
261        id: PLUGIN_ID,
262        status: "ok",
263        protocol: PROTOCOL,
264        upstream: state.upstream.to_string(),
265    })
266}
267
268async fn manifest_handler() -> Json<PluginManifest> {
269    Json(plugin_manifest())
270}
271
272async fn upstream_health_handler(State(state): State<AppState>) -> Result<Response, ApiError> {
273    proxy_to_upstream(&state, Method::GET, "/health", None).await
274}
275
276async fn list_sessions_handler(State(state): State<AppState>) -> Result<Response, ApiError> {
277    proxy_to_upstream(&state, Method::GET, "/api/sessions", None).await
278}
279
280async fn create_session_handler(
281    State(state): State<AppState>,
282    Json(body): Json<Value>,
283) -> Result<Response, ApiError> {
284    proxy_to_upstream(&state, Method::POST, "/api/sessions", Some(body)).await
285}
286
287async fn get_session_handler(
288    State(state): State<AppState>,
289    Path(id): Path<Uuid>,
290) -> Result<Response, ApiError> {
291    proxy_to_upstream(&state, Method::GET, &format!("/api/sessions/{id}"), None).await
292}
293
294async fn write_input_handler(
295    State(state): State<AppState>,
296    Path(id): Path<Uuid>,
297    Json(body): Json<Value>,
298) -> Result<Response, ApiError> {
299    proxy_to_upstream(
300        &state,
301        Method::POST,
302        &format!("/api/sessions/{id}/input"),
303        Some(body),
304    )
305    .await
306}
307
308async fn delete_session_handler(
309    State(state): State<AppState>,
310    Path(id): Path<Uuid>,
311) -> Result<Response, ApiError> {
312    proxy_to_upstream(&state, Method::DELETE, &format!("/api/sessions/{id}"), None).await
313}
314
315async fn output_events_handler(
316    State(state): State<AppState>,
317    Path(id): Path<Uuid>,
318    Query(query): Query<EventQuery>,
319) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, ApiError> {
320    let mut cursor = query.since.unwrap_or_default();
321    let stream = async_stream::stream! {
322        loop {
323            match read_upstream_output(&state, id, cursor).await {
324                Ok(output) => {
325                    cursor = output.next;
326                    if output.dropped || !output.data.is_empty() || !output.alive {
327                        match serde_json::to_string(&output) {
328                            Ok(json) => yield Ok(Event::default().event("output").data(json)),
329                            Err(error) => {
330                                let event = Event::default()
331                                    .event("error")
332                                    .data(error.to_string());
333                                yield Ok(event);
334                                break;
335                            }
336                        }
337                    }
338                    if !output.alive {
339                        break;
340                    }
341                }
342                Err(error) => {
343                    let event = Event::default().event("error").data(error.message);
344                    yield Ok(event);
345                    break;
346                }
347            }
348            tokio::time::sleep(state.poll).await;
349        }
350    };
351
352    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
353}
354
355async fn proxy_to_upstream(
356    state: &AppState,
357    method: Method,
358    path: &str,
359    body: Option<Value>,
360) -> Result<Response, ApiError> {
361    let mut request = state.upstream_auth(state.client.request(method, state.upstream_url(path)));
362    if let Some(body) = body {
363        request = request.json(&body);
364    }
365
366    let response = request.send().await.map_err(ApiError::upstream)?;
367    let status =
368        StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
369    let content_type = response
370        .headers()
371        .get(reqwest::header::CONTENT_TYPE)
372        .and_then(|value| value.to_str().ok())
373        .and_then(|value| HeaderValue::from_str(value).ok());
374    let text = response.text().await.map_err(ApiError::upstream)?;
375    let mut response = (status, text).into_response();
376    if let Some(content_type) = content_type {
377        response
378            .headers_mut()
379            .insert(header::CONTENT_TYPE, content_type);
380    }
381    Ok(response)
382}
383
384async fn read_upstream_output(
385    state: &AppState,
386    id: Uuid,
387    since: u64,
388) -> Result<UpstreamOutput, ApiError> {
389    let response = state
390        .upstream_auth(
391            state
392                .client
393                .get(state.upstream_url(&format!("/api/sessions/{id}/output?since={since}"))),
394        )
395        .send()
396        .await
397        .map_err(ApiError::upstream)?;
398    if !response.status().is_success() {
399        return Err(ApiError::bad_gateway(format!(
400            "upstream output read failed: {}",
401            response.status()
402        )));
403    }
404    response
405        .json::<UpstreamOutput>()
406        .await
407        .map_err(ApiError::upstream)
408}
409
410async fn require_token(State(token): State<Arc<str>>, req: Request<Body>, next: Next) -> Response {
411    if request_token_matches(req.headers(), req.uri(), token.as_ref()) {
412        return next.run(req).await;
413    }
414    (
415        StatusCode::UNAUTHORIZED,
416        Json(ErrorResponse {
417            error: "missing or invalid bridge token".to_string(),
418        }),
419    )
420        .into_response()
421}
422
423pub fn request_token_matches(headers: &HeaderMap, uri: &Uri, token: &str) -> bool {
424    bearer_token_matches(headers, token) || query_token_matches(uri, token)
425}
426
427pub fn bearer_token_matches(headers: &HeaderMap, token: &str) -> bool {
428    let Some(value) = headers
429        .get(header::AUTHORIZATION)
430        .and_then(|value| value.to_str().ok())
431    else {
432        return false;
433    };
434    let Some(candidate) = value.strip_prefix("Bearer ") else {
435        return false;
436    };
437    constant_time_eq(candidate.as_bytes(), token.as_bytes())
438}
439
440fn query_token_matches(uri: &Uri, token: &str) -> bool {
441    uri.query()
442        .and_then(|query| {
443            query.split('&').find_map(|part| {
444                let (key, value) = part.split_once('=')?;
445                (key == "token").then_some(value)
446            })
447        })
448        .map(|candidate| constant_time_eq(candidate.as_bytes(), token.as_bytes()))
449        .unwrap_or(false)
450}
451
452fn normalize_token(token: Option<String>) -> String {
453    token
454        .map(|token| token.trim().to_string())
455        .filter(|token| !token.is_empty())
456        .unwrap_or_else(|| Uuid::now_v7().to_string())
457}
458
459pub fn normalize_upstream(upstream: &str) -> String {
460    upstream.trim().trim_end_matches('/').to_string()
461}
462
463fn phone_url(config: &ServerConfig) -> String {
464    if let Some(public_url) = &config.public_url {
465        return public_url.clone();
466    }
467    if config.bind.ip().is_unspecified() {
468        if let Some(url) = lan_url(config.bind.port()) {
469            return url;
470        }
471    }
472    format!("http://{}", config.bind)
473}
474
475fn lan_url(port: u16) -> Option<String> {
476    let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
477    socket.connect("8.8.8.8:80").ok()?;
478    let local_addr = socket.local_addr().ok()?;
479    Some(format!("http://{}:{port}", local_addr.ip()))
480}
481
482fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
483    if left.len() != right.len() {
484        return false;
485    }
486    left.iter()
487        .zip(right)
488        .fold(0_u8, |diff, (left, right)| diff | (left ^ right))
489        == 0
490}
491
492async fn shutdown_signal() {
493    let _ = tokio::signal::ctrl_c().await;
494}
495
496const INDEX_HTML: &str = r#"<!doctype html>
497<html lang="en">
498<head>
499  <meta charset="utf-8">
500  <meta name="viewport" content="width=device-width, initial-scale=1">
501  <title>aev-bridge</title>
502  <style>
503    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07090f; color: #f5f7fb; }
504    body { margin: 0; min-height: 100vh; background: linear-gradient(135deg, #07111f, #140b24 55%, #05070b); }
505    main { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; }
506    header { padding: 22px 18px 14px; display: flex; justify-content: space-between; gap: 12px; align-items: end; }
507    h1 { margin: 0; font-size: clamp(28px, 10vw, 58px); letter-spacing: -0.06em; }
508    .tag { color: #9da7bd; font-size: 13px; }
509    .bar { display: grid; grid-template-columns: 1fr auto; gap: 9px; padding: 0 14px 12px; }
510    input, textarea, button { border: 1px solid #2f3a50; border-radius: 14px; background: #0e1420; color: #f5f7fb; font: inherit; }
511    input, textarea { padding: 13px 14px; min-width: 0; }
512    button { padding: 13px 15px; background: #6d5dfc; border-color: #877cff; font-weight: 800; }
513    button.secondary { background: #182033; border-color: #2f3a50; }
514    pre { margin: 0 14px; border: 1px solid #253047; border-radius: 18px; background: #02040a; padding: 14px; overflow: auto; white-space: pre-wrap; word-break: break-word; font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #cbffd8; }
515    footer { padding: 12px 14px 16px; display: grid; grid-template-columns: 1fr auto auto; gap: 9px; }
516    textarea { min-height: 46px; resize: vertical; }
517    @media (max-width: 620px) { footer, .bar { grid-template-columns: 1fr; } header { display: block; } }
518  </style>
519</head>
520<body>
521  <main>
522    <header>
523      <div>
524        <h1>aev-bridge</h1>
525        <div class="tag">HTTP + SSE bridge to lios-web</div>
526      </div>
527      <div id="status" class="tag">offline</div>
528    </header>
529    <section class="bar">
530      <input id="token" type="password" autocomplete="off" placeholder="Bridge token">
531      <button id="start">Start Session</button>
532    </section>
533    <pre id="screen"></pre>
534    <footer>
535      <textarea id="input" placeholder="Command, then Enter"></textarea>
536      <button id="send">Send</button>
537      <button id="ctrlc" class="secondary">Ctrl-C</button>
538    </footer>
539  </main>
540  <script>
541    const statusEl = document.querySelector('#status');
542    const screenEl = document.querySelector('#screen');
543    const tokenEl = document.querySelector('#token');
544    const inputEl = document.querySelector('#input');
545    let sessionId = null;
546    let events = null;
547
548    tokenEl.value = new URLSearchParams(location.search).get('token') || localStorage.getItem('aevBridgeToken') || '';
549
550    function token() {
551      const value = tokenEl.value.trim();
552      if (value) localStorage.setItem('aevBridgeToken', value);
553      return value;
554    }
555
556    async function api(path, options = {}) {
557      const headers = Object.assign({ 'Authorization': `Bearer ${token()}` }, options.headers || {});
558      if (options.body) headers['Content-Type'] = 'application/json';
559      const response = await fetch(`/api${path}`, Object.assign({}, options, { headers }));
560      const body = await response.json().catch(() => ({}));
561      if (!response.ok) throw new Error(body.error || response.statusText);
562      return body;
563    }
564
565    function connectEvents() {
566      if (events) events.close();
567      events = new EventSource(`/api/sessions/${sessionId}/events?token=${encodeURIComponent(token())}`);
568      events.addEventListener('output', event => {
569        const output = JSON.parse(event.data);
570        if (output.dropped) screenEl.textContent += '\n[bridge: output truncated]\n';
571        if (output.data) {
572          screenEl.textContent += output.data;
573          screenEl.scrollTop = screenEl.scrollHeight;
574        }
575        if (!output.alive) statusEl.textContent = `session ${sessionId} exited`;
576      });
577      events.addEventListener('error', () => { statusEl.textContent = 'event stream disconnected'; });
578    }
579
580    async function start() {
581      if (!token()) {
582        statusEl.textContent = 'token required';
583        return;
584      }
585      const result = await api('/sessions', { method: 'POST', body: JSON.stringify({}) });
586      sessionId = result.session.id;
587      screenEl.textContent = '';
588      statusEl.textContent = `session ${sessionId}`;
589      connectEvents();
590    }
591
592    async function send(data) {
593      if (!sessionId) await start();
594      if (!sessionId || !data) return;
595      await api(`/sessions/${sessionId}/input`, { method: 'POST', body: JSON.stringify({ data }) });
596    }
597
598    document.querySelector('#start').addEventListener('click', () => start().catch(error => statusEl.textContent = error.message));
599    document.querySelector('#send').addEventListener('click', () => {
600      const data = inputEl.value;
601      inputEl.value = '';
602      send(data).catch(error => statusEl.textContent = error.message);
603    });
604    document.querySelector('#ctrlc').addEventListener('click', () => send('\u0003').catch(error => statusEl.textContent = error.message));
605    inputEl.addEventListener('keydown', event => {
606      if (event.key === 'Enter' && !event.shiftKey) {
607        event.preventDefault();
608        const data = `${inputEl.value}\r`;
609        inputEl.value = '';
610        send(data).catch(error => statusEl.textContent = error.message);
611      }
612    });
613  </script>
614</body>
615</html>"#;
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use axum::http::HeaderValue;
621    use std::ffi::CStr;
622
623    #[test]
624    fn manifest_matches_plugin_contract() {
625        let manifest = plugin_manifest();
626
627        assert_eq!(manifest.id, "aev-bridge");
628        assert_eq!(manifest.package, "aev-bridge");
629        assert_eq!(manifest.entrypoint, "aev_plugin_init");
630        assert_eq!(manifest.protocol, "aev.plugin.v1");
631        assert!(manifest.capabilities.contains(&"sse"));
632    }
633
634    #[test]
635    fn ffi_entrypoint_returns_static_manifest_json() {
636        assert!(!aev_plugin_init().is_null());
637        let json = unsafe { CStr::from_ptr(aev_plugin_init()) }
638            .to_str()
639            .expect("plugin manifest is utf-8");
640        assert_eq!(json, PLUGIN_INIT_JSON);
641    }
642
643    #[test]
644    fn auth_accepts_bearer_or_query_token() {
645        let mut headers = HeaderMap::new();
646        let uri = Uri::from_static("/api/sessions?token=secret");
647        assert!(request_token_matches(&headers, &uri, "secret"));
648
649        headers.insert(
650            header::AUTHORIZATION,
651            HeaderValue::from_static("Bearer secret"),
652        );
653        let uri = Uri::from_static("/api/sessions");
654        assert!(request_token_matches(&headers, &uri, "secret"));
655        assert!(!request_token_matches(&headers, &uri, "different"));
656    }
657
658    #[test]
659    fn upstream_base_url_is_normalized() {
660        assert_eq!(
661            normalize_upstream(" http://127.0.0.1:8765/// "),
662            "http://127.0.0.1:8765"
663        );
664    }
665
666    #[test]
667    fn router_constructs_with_capture_syntax() {
668        let config = ServerConfig {
669            bind: DEFAULT_BIND.parse().expect("bind parses"),
670            token: "bridge".to_string(),
671            upstream: DEFAULT_UPSTREAM.to_string(),
672            upstream_token: "upstream".to_string(),
673            public_url: None,
674            poll_ms: 300,
675        };
676        let state = AppState::new(config).expect("state builds");
677        let _ = router(state);
678    }
679}