Skip to main content

blit_proxy/
lib.rs

1/// blit-proxy library — all proxy logic, usable in-process or as a binary.
2///
3/// Call [`proxy_socket_path`] to find the socket, then [`run`] to start the
4/// proxy on the current thread (blocking, runs its own tokio runtime).
5use std::collections::{HashMap, VecDeque};
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};
8use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
9use tokio::sync::{Mutex, RwLock};
10
11static VERBOSE: AtomicBool = AtomicBool::new(false);
12
13macro_rules! log {
14    ($($arg:tt)*) => {
15        if VERBOSE.load(Ordering::Relaxed) {
16            eprintln!($($arg)*);
17        }
18    };
19}
20
21type BoxRead = Box<dyn AsyncRead + Unpin + Send>;
22type BoxWrite = Box<dyn AsyncWrite + Unpin + Send>;
23
24// ---------------------------------------------------------------------------
25// Proxy socket path (single stable path for the whole process)
26// ---------------------------------------------------------------------------
27
28pub fn proxy_socket_path() -> String {
29    if let Ok(p) = std::env::var("BLIT_PROXY_SOCK") {
30        return p;
31    }
32    #[cfg(unix)]
33    {
34        let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
35        format!("{dir}/blit-proxy.sock")
36    }
37    #[cfg(windows)]
38    {
39        r"\\.\pipe\blit-proxy".into()
40    }
41}
42
43// ---------------------------------------------------------------------------
44// Upstream connection
45// ---------------------------------------------------------------------------
46
47struct UpstreamConn {
48    reader: BoxRead,
49    writer: BoxWrite,
50}
51
52// ---------------------------------------------------------------------------
53// Per-target pool
54// ---------------------------------------------------------------------------
55
56struct Pool {
57    idle: Mutex<VecDeque<UpstreamConn>>,
58    /// Number of currently active (proxied) downstream clients.
59    active: AtomicUsize,
60    /// Unix seconds of last client connect or disconnect.
61    /// `i64::MAX` while any client is active.
62    last_activity: AtomicI64,
63    pool_size: usize,
64    upstream_uri: String,
65}
66
67impl Pool {
68    fn new(upstream_uri: String, pool_size: usize) -> Arc<Self> {
69        Arc::new(Self {
70            idle: Mutex::new(VecDeque::new()),
71            active: AtomicUsize::new(0),
72            last_activity: AtomicI64::new(now_secs()),
73            pool_size,
74            upstream_uri,
75        })
76    }
77
78    async fn acquire(&self) -> Result<UpstreamConn, String> {
79        {
80            let mut idle = self.idle.lock().await;
81            if let Some(conn) = idle.pop_front() {
82                return Ok(conn);
83            }
84        }
85        self.connect_one().await
86    }
87
88    async fn connect_one(&self) -> Result<UpstreamConn, String> {
89        connect_upstream(&self.upstream_uri).await
90    }
91
92    fn client_connected(&self) {
93        self.active.fetch_add(1, Ordering::Relaxed);
94        self.last_activity.store(i64::MAX, Ordering::Relaxed);
95    }
96
97    fn client_disconnected(&self) {
98        let prev = self.active.fetch_sub(1, Ordering::Relaxed);
99        if prev == 1 {
100            self.last_activity.store(now_secs(), Ordering::Relaxed);
101        }
102    }
103
104    /// Background task: keep idle slots full.
105    async fn refill_loop(self: Arc<Self>) {
106        loop {
107            let need = {
108                let idle = self.idle.lock().await;
109                self.pool_size.saturating_sub(idle.len())
110            };
111            for _ in 0..need {
112                match self.connect_one().await {
113                    Ok(conn) => {
114                        self.idle.lock().await.push_back(conn);
115                    }
116                    Err(e) => {
117                        log!(
118                            "blit-proxy: [{uri}] upstream connect failed: {e}",
119                            uri = self.upstream_uri
120                        );
121                        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
122                        break;
123                    }
124                }
125            }
126            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
127        }
128    }
129}
130
131fn now_secs() -> i64 {
132    std::time::SystemTime::now()
133        .duration_since(std::time::UNIX_EPOCH)
134        .unwrap_or_default()
135        .as_secs() as i64
136}
137
138// ---------------------------------------------------------------------------
139// Pool registry: one pool per distinct upstream URI
140// ---------------------------------------------------------------------------
141
142struct Registry {
143    pools: RwLock<HashMap<String, Arc<Pool>>>,
144    pool_size: usize,
145}
146
147impl Registry {
148    fn new(pool_size: usize) -> Arc<Self> {
149        Arc::new(Self {
150            pools: RwLock::new(HashMap::new()),
151            pool_size,
152        })
153    }
154
155    /// Get or create the pool for `uri`, spawning its refill task if new.
156    async fn get_or_create(self: &Arc<Self>, uri: &str) -> Arc<Pool> {
157        // Fast path: pool already exists.
158        {
159            let pools = self.pools.read().await;
160            if let Some(p) = pools.get(uri) {
161                return p.clone();
162            }
163        }
164        // Slow path: create and seed.
165        let mut pools = self.pools.write().await;
166        // Re-check under write lock.
167        if let Some(p) = pools.get(uri) {
168            return p.clone();
169        }
170        let pool = Pool::new(uri.to_string(), self.pool_size);
171        pools.insert(uri.to_string(), pool.clone());
172        drop(pools);
173
174        // Seed eagerly in a background task.
175        let pool_seed = pool.clone();
176        tokio::spawn(async move {
177            for _ in 0..pool_seed.pool_size {
178                match pool_seed.connect_one().await {
179                    Ok(conn) => pool_seed.idle.lock().await.push_back(conn),
180                    Err(e) => {
181                        log!(
182                            "blit-proxy: [{uri}] initial connect: {e}",
183                            uri = pool_seed.upstream_uri
184                        );
185                        break;
186                    }
187                }
188            }
189            pool_seed.refill_loop().await;
190        });
191
192        pool
193    }
194
195    /// Returns the most-recent `last_activity` across all pools.
196    /// Returns `i64::MAX` if any pool has an active client.
197    async fn latest_activity(&self) -> i64 {
198        let pools = self.pools.read().await;
199        if pools.is_empty() {
200            return now_secs();
201        }
202        pools
203            .values()
204            .map(|p| p.last_activity.load(Ordering::Relaxed))
205            .max()
206            .unwrap_or_else(now_secs)
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Upstream transport implementations
212// ---------------------------------------------------------------------------
213
214async fn connect_upstream(uri: &str) -> Result<UpstreamConn, String> {
215    if let Some(rest) = uri.strip_prefix("share:") {
216        return connect_share(rest).await;
217    }
218
219    #[cfg(unix)]
220    if let Some(rest) = uri.strip_prefix("ssh:") {
221        return connect_ssh(rest).await;
222    }
223
224    // Extract query parameters from URIs that support them.
225    let (base_uri, passphrase, cert_hash) = extract_uri_params(uri);
226
227    if let Some(path) = base_uri.strip_prefix("socket:") {
228        return connect_socket(path).await;
229    }
230    if let Some(addr) = base_uri.strip_prefix("tcp:") {
231        return connect_tcp(addr).await;
232    }
233    if base_uri.starts_with("ws://") || base_uri.starts_with("wss://") {
234        return connect_ws(&base_uri, passphrase.as_deref()).await;
235    }
236    if let Some(rest) = base_uri.strip_prefix("wt://") {
237        let cert_bytes = cert_hash.as_deref().and_then(parse_hex);
238        return connect_wt(rest, passphrase.as_deref(), &cert_bytes).await;
239    }
240    Err(format!(
241        "unknown upstream URI scheme in '{uri}' \
242         (expected socket:, tcp:, ws://, wss://, wt://, share:, or ssh:)"
243    ))
244}
245
246/// Split `share:` URI rest into (passphrase, hub_url).
247///
248/// Accepted forms:
249///   `myphrase`                       — use default hub
250///   `myphrase?hub=wss://custom.hub`  — use specific hub
251fn parse_share_uri(rest: &str) -> (String, String) {
252    let (passphrase_raw, hub) = if let Some(q_pos) = rest.find('?') {
253        let phrase = &rest[..q_pos];
254        let query = &rest[q_pos + 1..];
255        let hub = query
256            .split('&')
257            .find_map(|kv| kv.strip_prefix("hub=").map(percent_decode))
258            .unwrap_or_else(|| blit_webrtc_forwarder::DEFAULT_HUB_URL.to_string());
259        (phrase.to_string(), hub)
260    } else {
261        (
262            rest.to_string(),
263            blit_webrtc_forwarder::DEFAULT_HUB_URL.to_string(),
264        )
265    };
266    let passphrase = percent_decode(&passphrase_raw);
267    let hub = blit_webrtc_forwarder::normalize_hub(&hub);
268    (passphrase, hub)
269}
270
271async fn connect_share(rest: &str) -> Result<UpstreamConn, String> {
272    let (passphrase, hub) = parse_share_uri(rest);
273    let stream = blit_webrtc_forwarder::client::connect(&passphrase, &hub)
274        .await
275        .map_err(|e| format!("share:{rest}: {e}"))?;
276    let (r, w) = tokio::io::split(stream);
277    Ok(UpstreamConn {
278        reader: Box::new(r),
279        writer: Box::new(w),
280    })
281}
282
283// ---------------------------------------------------------------------------
284// SSH stdio bridge
285// ---------------------------------------------------------------------------
286
287/// Shell snippet that locates the blit-server Unix socket on the remote host.
288#[cfg(unix)]
289const SSH_SOCK_SEARCH: &str = r#"if [ -n "$BLIT_SOCK" ]; then S="$BLIT_SOCK"; elif [ -n "$TMPDIR" ] && [ -S "$TMPDIR/blit.sock" ]; then S="$TMPDIR/blit.sock"; elif [ -S "/tmp/blit-$(id -un).sock" ]; then S="/tmp/blit-$(id -un).sock"; elif [ -S "/run/blit/$(id -un).sock" ]; then S="/run/blit/$(id -un).sock"; elif [ -n "$XDG_RUNTIME_DIR" ] && [ -S "$XDG_RUNTIME_DIR/blit.sock" ]; then S="$XDG_RUNTIME_DIR/blit.sock"; else S=/tmp/blit.sock; fi"#;
290
291/// Shell snippet that starts blit-server on the remote if the socket is absent/stale.
292#[cfg(unix)]
293const SSH_AUTOSTART: &str = "if [ -S \"$S\" ]; then if command -v nc >/dev/null 2>&1; then nc -z -U \"$S\" 2>/dev/null || rm -f \"$S\"; elif command -v socat >/dev/null 2>&1; then socat /dev/null \"UNIX-CONNECT:$S\" 2>/dev/null || rm -f \"$S\"; fi; fi; if ! [ -S \"$S\" ]; then if command -v blit >/dev/null 2>&1; then blit server & i=0; while ! [ -S \"$S\" ] && [ $i -lt 50 ]; do sleep 0.1; i=$((i+1)); done; elif command -v blit-server >/dev/null 2>&1; then blit-server & i=0; while ! [ -S \"$S\" ] && [ $i -lt 50 ]; do sleep 0.1; i=$((i+1)); done; fi; fi";
294
295/// Escape a string for use inside double quotes in a POSIX shell.
296/// Handles `\`, `$`, `` ` ``, and `"`.  Used instead of single-quote
297/// escaping because the result is embedded inside an outer `sh -c '…'`
298/// wrapper where nested single quotes would break.
299#[cfg(unix)]
300fn dq_escape(s: &str) -> String {
301    let mut out = String::with_capacity(s.len());
302    for ch in s.chars() {
303        match ch {
304            '\\' | '$' | '`' | '"' => {
305                out.push('\\');
306                out.push(ch);
307            }
308            _ => out.push(ch),
309        }
310    }
311    out
312}
313
314/// Parse `[user@]host[:socket_path]` from an `ssh:` URI remainder.
315/// Returns `(ssh_target, remote_socket)`.
316#[cfg(unix)]
317fn parse_ssh_target(rest: &str) -> (&str, Option<&str>) {
318    let colon_search_start = rest.find('@').map(|a| a + 1).unwrap_or(0);
319    if let Some(rel) = rest[colon_search_start..].find(':') {
320        let pos = colon_search_start + rel;
321        let path = &rest[pos + 1..];
322        if path.is_empty() {
323            (rest, None)
324        } else {
325            (&rest[..pos], Some(path))
326        }
327    } else {
328        (rest, None)
329    }
330}
331
332/// Connect to a remote blit-server via an SSH stdio bridge.
333///
334/// Spawns `ssh <host> -- sh -c '…find-socket…; nc -U "$S"'` and wraps
335/// the child's stdin/stdout as an `UpstreamConn`.
336#[cfg(unix)]
337async fn connect_ssh(rest: &str) -> Result<UpstreamConn, String> {
338    if rest.is_empty() {
339        return Err("ssh: destination requires a host".into());
340    }
341    let (host, remote_socket) = parse_ssh_target(rest);
342    if host.starts_with('-') {
343        return Err(format!(
344            "invalid ssh host '{host}': must not start with '-'"
345        ));
346    }
347    let resolve = match remote_socket {
348        Some(path) => format!("S=\"{}\"", dq_escape(path)),
349        None => SSH_SOCK_SEARCH.to_string(),
350    };
351    let bridge = format!(
352        "sh -c '{resolve}; {SSH_AUTOSTART}; if command -v nc >/dev/null 2>&1; then exec nc -U \"$S\"; else exec socat - \"UNIX-CONNECT:$S\"; fi'"
353    );
354    let mut child = tokio::process::Command::new("ssh")
355        .arg("-T")
356        .arg("-o")
357        .arg("ControlMaster=auto")
358        .arg("-o")
359        .arg("ControlPath=/tmp/blit-ssh-%r@%h:%p")
360        .arg("-o")
361        .arg("ControlPersist=300")
362        .arg(host)
363        .arg("--")
364        .arg(&bridge)
365        .stdin(std::process::Stdio::piped())
366        .stdout(std::process::Stdio::piped())
367        .stderr(std::process::Stdio::inherit())
368        .spawn()
369        .map_err(|e| format!("ssh: {e}"))?;
370    let stdin = child
371        .stdin
372        .take()
373        .ok_or_else(|| "ssh: could not get stdin".to_string())?;
374    let stdout = child
375        .stdout
376        .take()
377        .ok_or_else(|| "ssh: could not get stdout".to_string())?;
378    // Keep the child alive by moving it into a background task that waits on it.
379    tokio::spawn(async move {
380        let _ = child.wait().await;
381    });
382    Ok(UpstreamConn {
383        reader: Box::new(stdout),
384        writer: Box::new(stdin),
385    })
386}
387
388/// Split a URI into (base, passphrase, certHash) by parsing query params.
389/// Only applies to ws://, wss://, wt:// — socket: and tcp: are returned as-is.
390fn extract_uri_params(uri: &str) -> (String, Option<String>, Option<String>) {
391    if !uri.starts_with("ws://") && !uri.starts_with("wss://") && !uri.starts_with("wt://") {
392        return (uri.to_string(), None, None);
393    }
394    // Find '?'.
395    let (base, query) = match uri.find('?') {
396        Some(pos) => (&uri[..pos], Some(&uri[pos + 1..])),
397        None => (uri, None),
398    };
399    let mut passphrase = None;
400    let mut cert_hash = None;
401    if let Some(q) = query {
402        for param in q.split('&') {
403            if let Some(v) = param.strip_prefix("passphrase=") {
404                passphrase = Some(percent_decode(v));
405            } else if let Some(v) = param.strip_prefix("certHash=") {
406                cert_hash = Some(v.to_string());
407            }
408        }
409    }
410    (base.to_string(), passphrase, cert_hash)
411}
412
413fn percent_decode(s: &str) -> String {
414    // Minimal %XX decoder sufficient for passphrases.
415    let mut out = String::with_capacity(s.len());
416    let mut chars = s.chars().peekable();
417    while let Some(c) = chars.next() {
418        if c == '%' {
419            let h1 = chars.next().unwrap_or('0');
420            let h2 = chars.next().unwrap_or('0');
421            if let Ok(b) = u8::from_str_radix(&format!("{h1}{h2}"), 16) {
422                out.push(b as char);
423                continue;
424            }
425        }
426        out.push(c);
427    }
428    out
429}
430
431async fn connect_socket(path: &str) -> Result<UpstreamConn, String> {
432    #[cfg(unix)]
433    {
434        let stream = tokio::net::UnixStream::connect(path)
435            .await
436            .map_err(|e| format!("socket:{path}: {e}"))?;
437        let (r, w) = tokio::io::split(stream);
438        Ok(UpstreamConn {
439            reader: Box::new(r),
440            writer: Box::new(w),
441        })
442    }
443    #[cfg(not(unix))]
444    {
445        use tokio::net::windows::named_pipe::ClientOptions;
446        let pipe = ClientOptions::new()
447            .open(path)
448            .map_err(|e| format!("socket:{path}: {e}"))?;
449        let (r, w) = tokio::io::split(pipe);
450        Ok(UpstreamConn {
451            reader: Box::new(r),
452            writer: Box::new(w),
453        })
454    }
455}
456
457async fn connect_tcp(addr: &str) -> Result<UpstreamConn, String> {
458    let stream = tokio::net::TcpStream::connect(addr)
459        .await
460        .map_err(|e| format!("tcp:{addr}: {e}"))?;
461    let _ = stream.set_nodelay(true);
462    let (r, w) = tokio::io::split(stream);
463    Ok(UpstreamConn {
464        reader: Box::new(r),
465        writer: Box::new(w),
466    })
467}
468
469async fn connect_ws(uri: &str, passphrase: Option<&str>) -> Result<UpstreamConn, String> {
470    use futures_util::{SinkExt, StreamExt};
471    use tokio_tungstenite::tungstenite::Message;
472
473    let (mut ws, _) = tokio_tungstenite::connect_async(uri)
474        .await
475        .map_err(|e| format!("{uri}: {e}"))?;
476
477    let pass = passphrase.unwrap_or("");
478    ws.send(Message::Text(pass.into()))
479        .await
480        .map_err(|e| format!("{uri}: auth send: {e}"))?;
481    match ws.next().await {
482        Some(Ok(Message::Text(t))) if t.trim() == "ok" => {}
483        Some(Ok(Message::Text(t))) => {
484            return Err(format!("{uri}: auth rejected: {}", t.trim()));
485        }
486        other => {
487            return Err(format!("{uri}: unexpected auth response: {other:?}"));
488        }
489    }
490
491    let (ws_write, ws_read) = ws.split();
492    Ok(UpstreamConn {
493        reader: Box::new(WsFrameReader {
494            inner: ws_read,
495            buf: bytes::Bytes::new(),
496        }),
497        writer: Box::new(WsFrameWriter { inner: ws_write }),
498    })
499}
500
501async fn connect_wt(
502    rest: &str,
503    passphrase: Option<&str>,
504    cert_hash: &Option<Vec<u8>>,
505) -> Result<UpstreamConn, String> {
506    use web_transport_quinn as wt;
507
508    // Build the URL for the WT session (must use https: scheme).
509    let (host, port) = parse_wt_host_port(rest)?;
510    let url: url::Url = format!("https://{host}:{port}/")
511        .parse()
512        .map_err(|e| format!("wt: url: {e}"))?;
513
514    // Build the client with appropriate certificate verification.
515    let client: wt::Client = if let Some(hash) = cert_hash {
516        wt::ClientBuilder::new()
517            .with_server_certificate_hashes(vec![hash.clone()])
518            .map_err(|e| format!("wt: client build: {e}"))?
519    } else {
520        wt::ClientBuilder::new()
521            .with_system_roots()
522            .map_err(|e| format!("wt: client build: {e}"))?
523    };
524
525    let session = client
526        .connect(url)
527        .await
528        .map_err(|e| format!("wt: connect {host}:{port}: {e}"))?;
529
530    let (mut send, mut recv) = session
531        .open_bi()
532        .await
533        .map_err(|e| format!("wt: open_bi: {e}"))?;
534
535    // Auth: 2-byte-LE passphrase length + passphrase bytes, then read 1-byte response.
536    let pass = passphrase.unwrap_or("").as_bytes();
537    let mut auth_buf = Vec::with_capacity(2 + pass.len());
538    auth_buf.extend_from_slice(&(pass.len() as u16).to_le_bytes());
539    auth_buf.extend_from_slice(pass);
540    send.write_all(&auth_buf)
541        .await
542        .map_err(|e| format!("wt: auth send: {e}"))?;
543
544    let mut resp = [0u8; 1];
545    recv.read_exact(&mut resp)
546        .await
547        .map_err(|e| format!("wt: auth recv: {e}"))?;
548    if resp[0] != 1 {
549        return Err(format!(
550            "wt: auth rejected (response byte {:#04x})",
551            resp[0]
552        ));
553    }
554
555    Ok(UpstreamConn {
556        reader: Box::new(recv),
557        writer: Box::new(send),
558    })
559}
560
561fn parse_wt_host_port(rest: &str) -> Result<(String, u16), String> {
562    let without_path = rest.split('/').next().unwrap_or(rest);
563    if let Some(colon) = without_path.rfind(':') {
564        let host = without_path[..colon].to_string();
565        let port: u16 = without_path[colon + 1..]
566            .parse()
567            .map_err(|_| format!("wt: invalid port in '{rest}'"))?;
568        Ok((host, port))
569    } else {
570        Ok((without_path.to_string(), 443))
571    }
572}
573
574fn parse_hex(s: &str) -> Option<Vec<u8>> {
575    if !s.len().is_multiple_of(2) {
576        return None;
577    }
578    (0..s.len())
579        .step_by(2)
580        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
581        .collect()
582}
583
584// ---------------------------------------------------------------------------
585// WS ↔ raw blit-frame adapters
586// ---------------------------------------------------------------------------
587
588use futures_util::{SinkExt, StreamExt};
589use tokio_tungstenite::tungstenite::Message;
590
591type WsSink = futures_util::stream::SplitSink<
592    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
593    Message,
594>;
595type WsStream = futures_util::stream::SplitStream<
596    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
597>;
598
599struct WsFrameReader {
600    inner: WsStream,
601    buf: bytes::Bytes,
602}
603
604impl AsyncRead for WsFrameReader {
605    fn poll_read(
606        mut self: std::pin::Pin<&mut Self>,
607        cx: &mut std::task::Context<'_>,
608        out: &mut tokio::io::ReadBuf<'_>,
609    ) -> std::task::Poll<std::io::Result<()>> {
610        loop {
611            if !self.buf.is_empty() {
612                let n = out.remaining().min(self.buf.len());
613                out.put_slice(&self.buf[..n]);
614                self.buf = self.buf.slice(n..);
615                return std::task::Poll::Ready(Ok(()));
616            }
617            match self.inner.poll_next_unpin(cx) {
618                std::task::Poll::Pending => return std::task::Poll::Pending,
619                std::task::Poll::Ready(None) => return std::task::Poll::Ready(Ok(())),
620                std::task::Poll::Ready(Some(Err(e))) => {
621                    return std::task::Poll::Ready(Err(std::io::Error::other(e)));
622                }
623                std::task::Poll::Ready(Some(Ok(msg))) => {
624                    let data = match msg {
625                        Message::Binary(d) => d,
626                        Message::Close(_) => return std::task::Poll::Ready(Ok(())),
627                        _ => continue,
628                    };
629                    let len = data.len() as u32;
630                    let mut framed = Vec::with_capacity(4 + data.len());
631                    framed.extend_from_slice(&len.to_le_bytes());
632                    framed.extend_from_slice(&data);
633                    self.buf = bytes::Bytes::from(framed);
634                }
635            }
636        }
637    }
638}
639
640struct WsFrameWriter {
641    inner: WsSink,
642}
643
644impl AsyncWrite for WsFrameWriter {
645    fn poll_write(
646        mut self: std::pin::Pin<&mut Self>,
647        cx: &mut std::task::Context<'_>,
648        buf: &[u8],
649    ) -> std::task::Poll<std::io::Result<usize>> {
650        if buf.len() < 4 {
651            return std::task::Poll::Ready(Err(std::io::Error::new(
652                std::io::ErrorKind::InvalidData,
653                "ws frame writer: buffer too small for length prefix",
654            )));
655        }
656        let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize;
657        if buf.len() < 4 + len {
658            return std::task::Poll::Ready(Err(std::io::Error::new(
659                std::io::ErrorKind::InvalidData,
660                format!(
661                    "ws frame writer: buffer ({}) too small for frame payload ({})",
662                    buf.len(),
663                    4 + len
664                ),
665            )));
666        }
667        let payload = buf[4..4 + len].to_vec();
668        match self.inner.poll_ready_unpin(cx) {
669            std::task::Poll::Pending => return std::task::Poll::Pending,
670            std::task::Poll::Ready(Err(e)) => {
671                return std::task::Poll::Ready(Err(std::io::Error::other(e)));
672            }
673            std::task::Poll::Ready(Ok(())) => {}
674        }
675        match self.inner.start_send_unpin(Message::Binary(payload.into())) {
676            Err(e) => std::task::Poll::Ready(Err(std::io::Error::other(e))),
677            Ok(()) => std::task::Poll::Ready(Ok(4 + len)),
678        }
679    }
680    fn poll_flush(
681        mut self: std::pin::Pin<&mut Self>,
682        cx: &mut std::task::Context<'_>,
683    ) -> std::task::Poll<std::io::Result<()>> {
684        self.inner
685            .poll_flush_unpin(cx)
686            .map_err(std::io::Error::other)
687    }
688    fn poll_shutdown(
689        mut self: std::pin::Pin<&mut Self>,
690        cx: &mut std::task::Context<'_>,
691    ) -> std::task::Poll<std::io::Result<()>> {
692        self.inner
693            .poll_close_unpin(cx)
694            .map_err(std::io::Error::other)
695    }
696}
697
698// ---------------------------------------------------------------------------
699// Downstream listener
700// ---------------------------------------------------------------------------
701
702/// Read one line from the downstream socket (up to 4 KiB).
703#[cfg(unix)]
704async fn read_line(stream: &mut tokio::net::UnixStream) -> Option<String> {
705    let mut buf = Vec::with_capacity(128);
706    let mut byte = [0u8; 1];
707    loop {
708        match stream.read(&mut byte).await {
709            Ok(0) | Err(_) => return None,
710            Ok(_) => {
711                if byte[0] == b'\n' {
712                    break;
713                }
714                buf.push(byte[0]);
715                if buf.len() > 4096 {
716                    return None;
717                }
718            }
719        }
720    }
721    Some(
722        String::from_utf8_lossy(&buf)
723            .trim_end_matches('\r')
724            .to_string(),
725    )
726}
727
728#[cfg(unix)]
729async fn run_listener(registry: Arc<Registry>, sock_path: &str) {
730    let _ = std::fs::remove_file(sock_path);
731    let listener = tokio::net::UnixListener::bind(sock_path).unwrap_or_else(|e| {
732        eprintln!("blit-proxy: cannot bind to {sock_path}: {e}");
733        std::process::exit(1);
734    });
735    log!("blit-proxy: listening on {sock_path}");
736    loop {
737        match listener.accept().await {
738            Ok((stream, _)) => {
739                let registry = registry.clone();
740                tokio::spawn(async move {
741                    handle_downstream(registry, stream).await;
742                });
743            }
744            Err(e) => log!("blit-proxy: accept error: {e}"),
745        }
746    }
747}
748
749#[cfg(unix)]
750async fn handle_downstream(registry: Arc<Registry>, mut downstream: tokio::net::UnixStream) {
751    // Handshake: read "target <uri>\n" or "shutdown\n"
752    let line = match read_line(&mut downstream).await {
753        Some(l) => l,
754        None => return,
755    };
756    if line == "shutdown" {
757        let _ = downstream.write_all(b"ok\n").await;
758        log!("blit-proxy: shutdown requested, exiting");
759        std::process::exit(0);
760    }
761    let uri = match line.strip_prefix("target ") {
762        Some(u) if !u.is_empty() => u.to_string(),
763        _ => {
764            let _ = downstream.write_all(b"error invalid handshake\n").await;
765            return;
766        }
767    };
768
769    let pool = registry.get_or_create(&uri).await;
770
771    let upstream = match pool.acquire().await {
772        Ok(u) => u,
773        Err(e) => {
774            log!("blit-proxy: [{uri}] upstream unavailable: {e}");
775            let msg = format!("error {e}\n");
776            let _ = downstream.write_all(msg.as_bytes()).await;
777            return;
778        }
779    };
780
781    if downstream.write_all(b"ok\n").await.is_err() {
782        return;
783    }
784
785    pool.client_connected();
786
787    let (mut ds_read, mut ds_write) = tokio::io::split(downstream);
788    let (mut us_read, mut us_write) = (upstream.reader, upstream.writer);
789
790    let mut d2u = tokio::spawn(async move { tokio::io::copy(&mut ds_read, &mut us_write).await });
791    let mut u2d = tokio::spawn(async move { tokio::io::copy(&mut us_read, &mut ds_write).await });
792
793    tokio::select! { _ = &mut d2u => { u2d.abort(); }, _ = &mut u2d => { d2u.abort(); } }
794
795    pool.client_disconnected();
796}
797
798// ---------------------------------------------------------------------------
799// Public entry point
800// ---------------------------------------------------------------------------
801
802/// Run the proxy on the current thread (blocks until the process exits).
803///
804/// Reads `BLIT_PROXY_SOCK`, `BLIT_PROXY_POOL`, and `BLIT_PROXY_IDLE` from the
805/// environment. When called from within the `blit` binary instead of the
806/// standalone `blit-proxy` binary, `verbose` should be `false`.
807pub fn run(verbose: bool) {
808    if verbose {
809        VERBOSE.store(true, Ordering::Relaxed);
810    }
811
812    let sock_path = proxy_socket_path();
813
814    let pool_size: usize = std::env::var("BLIT_PROXY_POOL")
815        .ok()
816        .and_then(|v| v.parse().ok())
817        .unwrap_or(4);
818
819    let idle_secs: Option<u64> = std::env::var("BLIT_PROXY_IDLE")
820        .ok()
821        .and_then(|v| v.parse().ok());
822
823    tokio::runtime::Builder::new_multi_thread()
824        .enable_all()
825        .build()
826        .expect("blit-proxy: tokio runtime")
827        .block_on(async move {
828            rustls::crypto::ring::default_provider()
829                .install_default()
830                .ok(); // may already be installed by the CLI's runtime
831
832            let registry = Registry::new(pool_size);
833
834            // Idle-timeout watcher.
835            if let Some(idle) = idle_secs {
836                let reg = registry.clone();
837                let sock = sock_path.clone();
838                tokio::spawn(async move {
839                    loop {
840                        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
841                        let last = reg.latest_activity().await;
842                        if last == i64::MAX {
843                            continue;
844                        }
845                        let elapsed = now_secs().saturating_sub(last) as u64;
846                        if elapsed >= idle {
847                            log!("blit-proxy: idle for {elapsed}s (limit {idle}s), exiting");
848                            let _ = std::fs::remove_file(&sock);
849                            std::process::exit(0);
850                        }
851                    }
852                });
853            }
854
855            #[cfg(unix)]
856            {
857                let sock_cleanup = sock_path.clone();
858                tokio::spawn(async move {
859                    let _ = tokio::signal::ctrl_c().await;
860                    let _ = std::fs::remove_file(&sock_cleanup);
861                    std::process::exit(0);
862                });
863                run_listener(registry, &sock_path).await;
864            }
865            #[cfg(not(unix))]
866            {
867                eprintln!("blit-proxy: Windows not yet supported");
868                std::process::exit(1);
869            }
870        });
871}
872
873// ---------------------------------------------------------------------------
874// Tests
875// ---------------------------------------------------------------------------
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn proxy_socket_path_default() {
883        // Just verify it produces a non-empty string without panicking.
884        let p = proxy_socket_path();
885        assert!(!p.is_empty());
886        assert!(p.contains("blit-proxy"));
887    }
888
889    #[test]
890    fn parse_hex_valid() {
891        assert_eq!(parse_hex("deadbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
892        assert_eq!(parse_hex(""), Some(vec![]));
893    }
894
895    #[test]
896    fn parse_hex_odd_length() {
897        assert_eq!(parse_hex("abc"), None);
898    }
899
900    #[test]
901    fn parse_hex_invalid_char() {
902        assert_eq!(parse_hex("zz"), None);
903    }
904
905    #[test]
906    fn extract_uri_params_no_query() {
907        let (base, pass, cert) = extract_uri_params("wss://host:3264/");
908        assert_eq!(base, "wss://host:3264/");
909        assert_eq!(pass, None);
910        assert_eq!(cert, None);
911    }
912
913    #[test]
914    fn extract_uri_params_passphrase() {
915        let (base, pass, cert) = extract_uri_params("wss://host:3264/?passphrase=secret");
916        assert_eq!(base, "wss://host:3264/");
917        assert_eq!(pass, Some("secret".into()));
918        assert_eq!(cert, None);
919    }
920
921    #[test]
922    fn extract_uri_params_both() {
923        let (base, pass, cert) =
924            extract_uri_params("wt://host:4433/?passphrase=abc&certHash=deadbeef");
925        assert_eq!(base, "wt://host:4433/");
926        assert_eq!(pass, Some("abc".into()));
927        assert_eq!(cert, Some("deadbeef".into()));
928    }
929
930    #[test]
931    fn extract_uri_params_socket_unchanged() {
932        let (base, pass, cert) = extract_uri_params("socket:/tmp/blit.sock");
933        assert_eq!(base, "socket:/tmp/blit.sock");
934        assert_eq!(pass, None);
935        assert_eq!(cert, None);
936    }
937
938    #[test]
939    fn parse_wt_host_port_with_port() {
940        assert_eq!(parse_wt_host_port("host:4433"), Ok(("host".into(), 4433)));
941    }
942
943    #[test]
944    fn parse_wt_host_port_default() {
945        assert_eq!(parse_wt_host_port("host"), Ok(("host".into(), 443)));
946    }
947
948    #[test]
949    fn percent_decode_basic() {
950        assert_eq!(percent_decode("hello%20world"), "hello world");
951        assert_eq!(percent_decode("plain"), "plain");
952    }
953
954    #[test]
955    fn parse_share_uri_no_hub() {
956        let (pass, hub) = parse_share_uri("myphrase");
957        assert_eq!(pass, "myphrase");
958        assert_eq!(
959            hub,
960            blit_webrtc_forwarder::normalize_hub(blit_webrtc_forwarder::DEFAULT_HUB_URL)
961        );
962    }
963
964    #[test]
965    fn parse_share_uri_with_hub() {
966        let (pass, hub) = parse_share_uri("myphrase?hub=wss://custom.example.com");
967        assert_eq!(pass, "myphrase");
968        assert_eq!(hub, "wss://custom.example.com");
969    }
970
971    #[test]
972    fn parse_share_uri_hub_normalized() {
973        let (pass, hub) = parse_share_uri("myphrase?hub=custom.example.com");
974        assert_eq!(pass, "myphrase");
975        assert_eq!(hub, "wss://custom.example.com");
976    }
977
978    #[test]
979    fn parse_share_uri_percent_encoded_passphrase() {
980        let (pass, hub) = parse_share_uri("my%3Fphrase");
981        assert_eq!(pass, "my?phrase");
982        assert_eq!(
983            hub,
984            blit_webrtc_forwarder::normalize_hub(blit_webrtc_forwarder::DEFAULT_HUB_URL)
985        );
986    }
987}