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}