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, AsyncWrite, AsyncWriteExt};
9use tokio::sync::{Mutex, Notify, 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// Auto-start helpers (shared by blit-cli and blit-gateway)
45// ---------------------------------------------------------------------------
46
47/// Returns true if a proxy is already listening at `path`.
48pub async fn proxy_alive(path: &str) -> bool {
49    #[cfg(unix)]
50    {
51        std::path::Path::new(path).exists() && tokio::net::UnixStream::connect(path).await.is_ok()
52    }
53    #[cfg(windows)]
54    {
55        use tokio::net::windows::named_pipe::ClientOptions;
56        ClientOptions::new().open(path).is_ok()
57    }
58    #[cfg(not(any(unix, windows)))]
59    {
60        let _ = path;
61        false
62    }
63}
64
65/// Resolve the blit binary path for re-exec.
66pub fn blit_exe() -> std::path::PathBuf {
67    std::env::current_exe().unwrap_or_default()
68}
69
70/// Ensure a blit-proxy daemon is running, spawning one if necessary.
71///
72/// `proxy_bin` is the path to the binary that accepts a `proxy-daemon`
73/// subcommand (typically [`blit_exe()`]).  When the binary is the
74/// standalone `blit-proxy` it should be invoked without arguments;
75/// when it is the `blit` CLI it needs the `proxy-daemon` subcommand.
76///
77/// Returns the socket path on success.
78pub async fn ensure_proxy(
79    proxy_bin: &std::path::Path,
80    use_subcommand: bool,
81) -> Result<String, String> {
82    let sock = proxy_socket_path();
83
84    if proxy_alive(&sock).await {
85        return Ok(sock);
86    }
87
88    #[cfg(unix)]
89    let _ = std::fs::remove_file(&sock);
90
91    #[cfg(unix)]
92    {
93        use std::os::unix::process::CommandExt;
94        let mut cmd = std::process::Command::new(proxy_bin);
95        if use_subcommand {
96            cmd.arg("proxy-daemon");
97        }
98        // SAFETY: pre_exec runs in the child between fork and exec.
99        // setsid() is async-signal-safe per POSIX.
100        unsafe {
101            cmd.env("BLIT_PROXY_IDLE", "300")
102                .stdin(std::process::Stdio::null())
103                .stdout(std::process::Stdio::null())
104                .stderr(std::process::Stdio::null())
105                .pre_exec(|| {
106                    libc::setsid();
107                    Ok(())
108                })
109                .spawn()
110                .map_err(|e| format!("blit-proxy: spawn failed: {e}"))?;
111        }
112    }
113
114    #[cfg(windows)]
115    {
116        use std::os::windows::process::CommandExt;
117        const DETACHED_PROCESS: u32 = 0x0000_0008;
118        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
119        let mut cmd = std::process::Command::new(proxy_bin);
120        if use_subcommand {
121            cmd.arg("proxy-daemon");
122        }
123        cmd.env("BLIT_PROXY_IDLE", "300")
124            .stdin(std::process::Stdio::null())
125            .stdout(std::process::Stdio::null())
126            .stderr(std::process::Stdio::null())
127            .creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)
128            .spawn()
129            .map_err(|e| format!("blit-proxy: spawn failed: {e}"))?;
130    }
131
132    #[cfg(not(any(unix, windows)))]
133    return Err("blit-proxy auto-start is not supported on this platform".into());
134
135    // Wait up to 5 s for the socket/pipe to appear.
136    for _ in 0..100 {
137        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
138        if proxy_alive(&sock).await {
139            return Ok(sock);
140        }
141    }
142    Err(format!("blit-proxy did not become ready at {sock} in time"))
143}
144
145// ---------------------------------------------------------------------------
146// Upstream connection
147// ---------------------------------------------------------------------------
148
149struct UpstreamConn {
150    reader: BoxRead,
151    writer: BoxWrite,
152}
153
154// ---------------------------------------------------------------------------
155// Share session cache — one multiplexed WebRTC session per remote
156// ---------------------------------------------------------------------------
157
158/// Maintains a single multiplexed WebRTC session per remote.  The heavy
159/// ICE+DTLS+SCTP setup is done once; subsequent `acquire` calls open a
160/// virtual stream on the existing mux DataChannel — a purely local
161/// operation with zero network round-trips.
162struct SharePool {
163    mux: Mutex<Option<blit_webrtc_forwarder::client::MuxSession>>,
164    passphrase: String,
165    hub: String,
166    active: AtomicUsize,
167    last_activity: AtomicI64,
168}
169
170impl SharePool {
171    fn new(passphrase: String, hub: String) -> Arc<Self> {
172        Arc::new(Self {
173            mux: Mutex::new(None),
174            passphrase,
175            hub,
176            active: AtomicUsize::new(0),
177            last_activity: AtomicI64::new(now_secs()),
178        })
179    }
180
181    /// Open a virtual stream on the mux session, establishing the
182    /// underlying WebRTC session if needed.
183    ///
184    /// When the session already exists this is a **local operation** —
185    /// no network round-trip, typically <1 ms.
186    async fn acquire(&self) -> Result<(u16, UpstreamConn), String> {
187        // Fast path: open a stream on the existing mux session.
188        {
189            let mux = self.mux.lock().await;
190            if let Some(ref m) = *mux
191                && m.is_alive()
192            {
193                let (id, stream) = m.open_stream()?;
194                let (r, w) = tokio::io::split(stream);
195                return Ok((
196                    id,
197                    UpstreamConn {
198                        reader: Box::new(r),
199                        writer: Box::new(w),
200                    },
201                ));
202            }
203        }
204
205        // Slow path: establish a new mux session.
206        let hub_url = blit_webrtc_forwarder::normalize_hub(&self.hub);
207        let new_mux =
208            blit_webrtc_forwarder::client::MuxSession::establish(&self.passphrase, &hub_url)
209                .await
210                .map_err(|e| format!("{e}"))?;
211
212        let (id, stream) = new_mux.open_stream()?;
213        *self.mux.lock().await = Some(new_mux);
214
215        let (r, w) = tokio::io::split(stream);
216        Ok((
217            id,
218            UpstreamConn {
219                reader: Box::new(r),
220                writer: Box::new(w),
221            },
222        ))
223    }
224
225    /// Close a virtual stream.  The mux session stays alive.
226    fn recycle(&self, stream_id: u16) {
227        if let Ok(mux) = self.mux.try_lock()
228            && let Some(ref m) = *mux
229        {
230            m.close_stream(stream_id);
231        }
232    }
233
234    fn client_connected(&self) {
235        self.active.fetch_add(1, Ordering::Relaxed);
236        self.last_activity.store(i64::MAX, Ordering::Relaxed);
237    }
238
239    fn client_disconnected(&self) {
240        let prev = self.active.fetch_sub(1, Ordering::Relaxed);
241        if prev == 1 {
242            self.last_activity.store(now_secs(), Ordering::Relaxed);
243        }
244    }
245}
246
247struct ShareRegistry {
248    pools: RwLock<HashMap<String, Arc<SharePool>>>,
249}
250
251impl ShareRegistry {
252    fn new() -> Arc<Self> {
253        Arc::new(Self {
254            pools: RwLock::new(HashMap::new()),
255        })
256    }
257
258    async fn get_or_create(&self, uri: &str) -> Arc<SharePool> {
259        {
260            let pools = self.pools.read().await;
261            if let Some(p) = pools.get(uri) {
262                return p.clone();
263            }
264        }
265        let mut pools = self.pools.write().await;
266        if let Some(p) = pools.get(uri) {
267            return p.clone();
268        }
269        let (passphrase, hub) = parse_share_uri(uri.strip_prefix("share:").unwrap_or(uri));
270        let pool = SharePool::new(passphrase, hub);
271        pools.insert(uri.to_string(), pool.clone());
272        pool
273    }
274
275    async fn latest_activity(&self) -> i64 {
276        let pools = self.pools.read().await;
277        if pools.is_empty() {
278            return now_secs();
279        }
280        pools
281            .values()
282            .map(|p| p.last_activity.load(Ordering::Relaxed))
283            .max()
284            .unwrap_or_else(now_secs)
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Per-target pool
290// ---------------------------------------------------------------------------
291
292struct Pool {
293    idle: Mutex<VecDeque<UpstreamConn>>,
294    /// Number of currently active (proxied) downstream clients.
295    active: AtomicUsize,
296    /// Unix seconds of last client connect or disconnect.
297    /// `i64::MAX` while any client is active.
298    last_activity: AtomicI64,
299    pool_size: usize,
300    upstream_uri: String,
301    /// Wakes the refill loop immediately when a connection is consumed.
302    refill_notify: Notify,
303}
304
305impl Pool {
306    fn new(upstream_uri: String, pool_size: usize) -> Arc<Self> {
307        Arc::new(Self {
308            idle: Mutex::new(VecDeque::new()),
309            active: AtomicUsize::new(0),
310            last_activity: AtomicI64::new(now_secs()),
311            pool_size,
312            upstream_uri,
313            refill_notify: Notify::new(),
314        })
315    }
316
317    async fn acquire(&self) -> Result<UpstreamConn, String> {
318        {
319            let mut idle = self.idle.lock().await;
320            if let Some(conn) = idle.pop_front() {
321                self.refill_notify.notify_one();
322                return Ok(conn);
323            }
324        }
325        self.connect_one().await
326    }
327
328    async fn connect_one(&self) -> Result<UpstreamConn, String> {
329        connect_upstream(&self.upstream_uri).await
330    }
331
332    fn client_connected(&self) {
333        self.active.fetch_add(1, Ordering::Relaxed);
334        self.last_activity.store(i64::MAX, Ordering::Relaxed);
335    }
336
337    fn client_disconnected(&self) {
338        let prev = self.active.fetch_sub(1, Ordering::Relaxed);
339        if prev == 1 {
340            self.last_activity.store(now_secs(), Ordering::Relaxed);
341        }
342    }
343
344    /// Background task: keep idle slots full.
345    ///
346    /// Wakes immediately when notified (a connection was consumed) or
347    /// falls back to polling every 200 ms.  Launches refill connections
348    /// concurrently so a burst of consumes is replenished quickly.
349    async fn refill_loop(self: Arc<Self>) {
350        loop {
351            let need = {
352                let idle = self.idle.lock().await;
353                self.pool_size.saturating_sub(idle.len())
354            };
355            if need > 0 {
356                let mut tasks = Vec::with_capacity(need);
357                for _ in 0..need {
358                    let pool = self.clone();
359                    tasks.push(tokio::spawn(async move { pool.connect_one().await }));
360                }
361                let mut any_failed = false;
362                for task in tasks {
363                    match task.await {
364                        Ok(Ok(conn)) => {
365                            self.idle.lock().await.push_back(conn);
366                        }
367                        Ok(Err(e)) => {
368                            log!(
369                                "blit-proxy: [{uri}] upstream connect failed: {e}",
370                                uri = self.upstream_uri
371                            );
372                            any_failed = true;
373                        }
374                        Err(_) => {
375                            any_failed = true;
376                        }
377                    }
378                }
379                if any_failed {
380                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
381                }
382            }
383            // Wait for a notify (connection consumed) or poll interval.
384            tokio::select! {
385                _ = self.refill_notify.notified() => {}
386                _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {}
387            }
388        }
389    }
390}
391
392fn now_secs() -> i64 {
393    std::time::SystemTime::now()
394        .duration_since(std::time::UNIX_EPOCH)
395        .unwrap_or_default()
396        .as_secs() as i64
397}
398
399// ---------------------------------------------------------------------------
400// Pool registry: one pool per distinct upstream URI
401// ---------------------------------------------------------------------------
402
403struct Registry {
404    pools: RwLock<HashMap<String, Arc<Pool>>>,
405    pool_size: usize,
406}
407
408impl Registry {
409    fn new(pool_size: usize) -> Arc<Self> {
410        Arc::new(Self {
411            pools: RwLock::new(HashMap::new()),
412            pool_size,
413        })
414    }
415
416    /// Get or create the pool for `uri`, spawning its refill task if new.
417    async fn get_or_create(self: &Arc<Self>, uri: &str) -> Arc<Pool> {
418        // Fast path: pool already exists.
419        {
420            let pools = self.pools.read().await;
421            if let Some(p) = pools.get(uri) {
422                return p.clone();
423            }
424        }
425        // Slow path: create and seed.
426        let mut pools = self.pools.write().await;
427        // Re-check under write lock.
428        if let Some(p) = pools.get(uri) {
429            return p.clone();
430        }
431
432        // SSH connections multiplex channels over a single cached TCP+SSH
433        // session, so opening a channel on demand is cheap (~1 RTT).
434        // Pre-warming would waste server-side resources (each idle
435        // connection allocates a full ClientState).
436        let effective_pool_size = if uri.starts_with("ssh:") {
437            0
438        } else {
439            self.pool_size
440        };
441
442        let pool = Pool::new(uri.to_string(), effective_pool_size);
443        pools.insert(uri.to_string(), pool.clone());
444        drop(pools);
445
446        // Seed eagerly in a background task (skipped when pool_size is 0).
447        if effective_pool_size > 0 {
448            let pool_seed = pool.clone();
449            tokio::spawn(async move {
450                let mut tasks = Vec::with_capacity(pool_seed.pool_size);
451                for _ in 0..pool_seed.pool_size {
452                    let p = pool_seed.clone();
453                    tasks.push(tokio::spawn(async move { p.connect_one().await }));
454                }
455                for task in tasks {
456                    match task.await {
457                        Ok(Ok(conn)) => pool_seed.idle.lock().await.push_back(conn),
458                        Ok(Err(e)) => {
459                            log!(
460                                "blit-proxy: [{uri}] initial connect: {e}",
461                                uri = pool_seed.upstream_uri
462                            );
463                        }
464                        Err(_) => {}
465                    }
466                }
467                pool_seed.refill_loop().await;
468            });
469        }
470
471        pool
472    }
473
474    /// Returns the most-recent `last_activity` across all pools.
475    /// Returns `i64::MAX` if any pool has an active client.
476    async fn latest_activity(&self) -> i64 {
477        let pools = self.pools.read().await;
478        if pools.is_empty() {
479            return now_secs();
480        }
481        pools
482            .values()
483            .map(|p| p.last_activity.load(Ordering::Relaxed))
484            .max()
485            .unwrap_or_else(now_secs)
486    }
487}
488
489// ---------------------------------------------------------------------------
490// Upstream transport implementations
491// ---------------------------------------------------------------------------
492
493async fn connect_upstream(uri: &str) -> Result<UpstreamConn, String> {
494    if let Some(rest) = uri.strip_prefix("share:") {
495        return connect_share(rest).await;
496    }
497
498    if let Some(rest) = uri.strip_prefix("ssh:") {
499        return connect_ssh(rest).await;
500    }
501
502    // Extract query parameters from URIs that support them.
503    let (base_uri, passphrase, cert_hash) = extract_uri_params(uri);
504
505    if let Some(path) = base_uri.strip_prefix("socket:") {
506        return connect_socket(path).await;
507    }
508    if let Some(addr) = base_uri.strip_prefix("tcp:") {
509        return connect_tcp(addr).await;
510    }
511    if base_uri.starts_with("ws://") || base_uri.starts_with("wss://") {
512        return connect_ws(&base_uri, passphrase.as_deref()).await;
513    }
514    if let Some(rest) = base_uri.strip_prefix("wt://") {
515        let cert_bytes = cert_hash.as_deref().and_then(parse_hex);
516        return connect_wt(rest, passphrase.as_deref(), &cert_bytes).await;
517    }
518    Err(format!(
519        "unknown upstream URI scheme in '{uri}' \
520         (expected socket:, tcp:, ws://, wss://, wt://, share:, or ssh:)"
521    ))
522}
523
524/// Split `share:` URI rest into (passphrase, hub_url).
525///
526/// Accepted forms:
527///   `myphrase`                       — use default hub
528///   `myphrase?hub=wss://custom.hub`  — use specific hub
529fn parse_share_uri(rest: &str) -> (String, String) {
530    let (passphrase_raw, hub) = if let Some(q_pos) = rest.find('?') {
531        let phrase = &rest[..q_pos];
532        let query = &rest[q_pos + 1..];
533        let hub = query
534            .split('&')
535            .find_map(|kv| kv.strip_prefix("hub=").map(percent_decode))
536            .unwrap_or_else(|| blit_webrtc_forwarder::DEFAULT_HUB_URL.to_string());
537        (phrase.to_string(), hub)
538    } else {
539        (
540            rest.to_string(),
541            blit_webrtc_forwarder::DEFAULT_HUB_URL.to_string(),
542        )
543    };
544    let passphrase = percent_decode(&passphrase_raw);
545    let hub = blit_webrtc_forwarder::normalize_hub(&hub);
546    (passphrase, hub)
547}
548
549async fn connect_share(rest: &str) -> Result<UpstreamConn, String> {
550    let (passphrase, hub) = parse_share_uri(rest);
551    let stream = blit_webrtc_forwarder::client::connect(&passphrase, &hub)
552        .await
553        .map_err(|e| format!("share:{rest}: {e}"))?;
554    let (r, w) = tokio::io::split(stream);
555    Ok(UpstreamConn {
556        reader: Box::new(r),
557        writer: Box::new(w),
558    })
559}
560
561// ---------------------------------------------------------------------------
562// SSH via embedded client (cross-platform)
563// ---------------------------------------------------------------------------
564
565/// Shared SSH connection pool for the proxy.  Connections are multiplexed
566/// over a single TCP+SSH session per host, matching the gateway's behaviour.
567fn ssh_pool() -> &'static blit_ssh::SshPool {
568    static POOL: std::sync::OnceLock<blit_ssh::SshPool> = std::sync::OnceLock::new();
569    POOL.get_or_init(blit_ssh::SshPool::new)
570}
571
572/// Connect to a remote blit-server via the embedded SSH client.
573///
574/// Uses `direct-streamlocal` channel forwarding (no external `ssh`, `nc`, or
575/// `socat` required).  The connection is multiplexed and pooled so subsequent
576/// calls to the same host reuse the TCP+SSH session.
577async fn connect_ssh(rest: &str) -> Result<UpstreamConn, String> {
578    if rest.is_empty() {
579        return Err("ssh: destination requires a host".into());
580    }
581    let (user, host, socket) = blit_ssh::parse_ssh_uri(rest);
582    let stream = ssh_pool()
583        .connect(&host, user.as_deref(), socket.as_deref())
584        .await
585        .map_err(|e| format!("ssh:{rest}: {e}"))?;
586    let (r, w) = tokio::io::split(stream);
587    Ok(UpstreamConn {
588        reader: Box::new(r),
589        writer: Box::new(w),
590    })
591}
592
593/// Split a URI into (base, passphrase, certHash) by parsing query params.
594/// Only applies to ws://, wss://, wt:// — socket: and tcp: are returned as-is.
595fn extract_uri_params(uri: &str) -> (String, Option<String>, Option<String>) {
596    if !uri.starts_with("ws://") && !uri.starts_with("wss://") && !uri.starts_with("wt://") {
597        return (uri.to_string(), None, None);
598    }
599    // Find '?'.
600    let (base, query) = match uri.find('?') {
601        Some(pos) => (&uri[..pos], Some(&uri[pos + 1..])),
602        None => (uri, None),
603    };
604    let mut passphrase = None;
605    let mut cert_hash = None;
606    if let Some(q) = query {
607        for param in q.split('&') {
608            if let Some(v) = param.strip_prefix("passphrase=") {
609                passphrase = Some(percent_decode(v));
610            } else if let Some(v) = param.strip_prefix("certHash=") {
611                cert_hash = Some(v.to_string());
612            }
613        }
614    }
615    (base.to_string(), passphrase, cert_hash)
616}
617
618fn percent_decode(s: &str) -> String {
619    // Minimal %XX decoder sufficient for passphrases.
620    let mut out = String::with_capacity(s.len());
621    let mut chars = s.chars().peekable();
622    while let Some(c) = chars.next() {
623        if c == '%' {
624            let h1 = chars.next().unwrap_or('0');
625            let h2 = chars.next().unwrap_or('0');
626            if let Ok(b) = u8::from_str_radix(&format!("{h1}{h2}"), 16) {
627                out.push(b as char);
628                continue;
629            }
630        }
631        out.push(c);
632    }
633    out
634}
635
636async fn connect_socket(path: &str) -> Result<UpstreamConn, String> {
637    #[cfg(unix)]
638    {
639        let stream = tokio::net::UnixStream::connect(path)
640            .await
641            .map_err(|e| format!("socket:{path}: {e}"))?;
642        let (r, w) = tokio::io::split(stream);
643        Ok(UpstreamConn {
644            reader: Box::new(r),
645            writer: Box::new(w),
646        })
647    }
648    #[cfg(not(unix))]
649    {
650        use tokio::net::windows::named_pipe::ClientOptions;
651        let pipe = ClientOptions::new()
652            .open(path)
653            .map_err(|e| format!("socket:{path}: {e}"))?;
654        let (r, w) = tokio::io::split(pipe);
655        Ok(UpstreamConn {
656            reader: Box::new(r),
657            writer: Box::new(w),
658        })
659    }
660}
661
662async fn connect_tcp(addr: &str) -> Result<UpstreamConn, String> {
663    let stream = tokio::net::TcpStream::connect(addr)
664        .await
665        .map_err(|e| format!("tcp:{addr}: {e}"))?;
666    let _ = stream.set_nodelay(true);
667    let (r, w) = tokio::io::split(stream);
668    Ok(UpstreamConn {
669        reader: Box::new(r),
670        writer: Box::new(w),
671    })
672}
673
674async fn connect_ws(uri: &str, passphrase: Option<&str>) -> Result<UpstreamConn, String> {
675    use futures_util::{SinkExt, StreamExt};
676    use tokio_tungstenite::tungstenite::Message;
677
678    let (mut ws, _) = tokio_tungstenite::connect_async(uri)
679        .await
680        .map_err(|e| format!("{uri}: {e}"))?;
681
682    let pass = passphrase.unwrap_or("");
683    ws.send(Message::Text(pass.into()))
684        .await
685        .map_err(|e| format!("{uri}: auth send: {e}"))?;
686    match ws.next().await {
687        Some(Ok(Message::Text(t))) if t.trim() == "ok" => {}
688        Some(Ok(Message::Text(t))) => {
689            return Err(format!("{uri}: auth rejected: {}", t.trim()));
690        }
691        other => {
692            return Err(format!("{uri}: unexpected auth response: {other:?}"));
693        }
694    }
695
696    let (ws_write, ws_read) = ws.split();
697    Ok(UpstreamConn {
698        reader: Box::new(WsFrameReader {
699            inner: ws_read,
700            buf: bytes::Bytes::new(),
701        }),
702        writer: Box::new(WsFrameWriter { inner: ws_write }),
703    })
704}
705
706async fn connect_wt(
707    rest: &str,
708    passphrase: Option<&str>,
709    cert_hash: &Option<Vec<u8>>,
710) -> Result<UpstreamConn, String> {
711    use web_transport_quinn as wt;
712
713    // Build the URL for the WT session (must use https: scheme).
714    let (host, port) = parse_wt_host_port(rest)?;
715    let url: url::Url = format!("https://{host}:{port}/")
716        .parse()
717        .map_err(|e| format!("wt: url: {e}"))?;
718
719    // Build the client with appropriate certificate verification.
720    let client: wt::Client = if let Some(hash) = cert_hash {
721        wt::ClientBuilder::new()
722            .with_server_certificate_hashes(vec![hash.clone()])
723            .map_err(|e| format!("wt: client build: {e}"))?
724    } else {
725        wt::ClientBuilder::new()
726            .with_system_roots()
727            .map_err(|e| format!("wt: client build: {e}"))?
728    };
729
730    let session = client
731        .connect(url)
732        .await
733        .map_err(|e| format!("wt: connect {host}:{port}: {e}"))?;
734
735    let (mut send, mut recv) = session
736        .open_bi()
737        .await
738        .map_err(|e| format!("wt: open_bi: {e}"))?;
739
740    // Auth: 2-byte-LE passphrase length + passphrase bytes, then read 1-byte response.
741    let pass = passphrase.unwrap_or("").as_bytes();
742    let mut auth_buf = Vec::with_capacity(2 + pass.len());
743    auth_buf.extend_from_slice(&(pass.len() as u16).to_le_bytes());
744    auth_buf.extend_from_slice(pass);
745    send.write_all(&auth_buf)
746        .await
747        .map_err(|e| format!("wt: auth send: {e}"))?;
748
749    let mut resp = [0u8; 1];
750    recv.read_exact(&mut resp)
751        .await
752        .map_err(|e| format!("wt: auth recv: {e}"))?;
753    if resp[0] != 1 {
754        return Err(format!(
755            "wt: auth rejected (response byte {:#04x})",
756            resp[0]
757        ));
758    }
759
760    Ok(UpstreamConn {
761        reader: Box::new(recv),
762        writer: Box::new(send),
763    })
764}
765
766fn parse_wt_host_port(rest: &str) -> Result<(String, u16), String> {
767    let without_path = rest.split('/').next().unwrap_or(rest);
768    if let Some(colon) = without_path.rfind(':') {
769        let host = without_path[..colon].to_string();
770        let port: u16 = without_path[colon + 1..]
771            .parse()
772            .map_err(|_| format!("wt: invalid port in '{rest}'"))?;
773        Ok((host, port))
774    } else {
775        Ok((without_path.to_string(), 443))
776    }
777}
778
779fn parse_hex(s: &str) -> Option<Vec<u8>> {
780    if !s.len().is_multiple_of(2) {
781        return None;
782    }
783    (0..s.len())
784        .step_by(2)
785        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
786        .collect()
787}
788
789// ---------------------------------------------------------------------------
790// WS ↔ raw blit-frame adapters
791// ---------------------------------------------------------------------------
792
793use futures_util::{SinkExt, StreamExt};
794use tokio_tungstenite::tungstenite::Message;
795
796type WsSink = futures_util::stream::SplitSink<
797    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
798    Message,
799>;
800type WsStream = futures_util::stream::SplitStream<
801    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
802>;
803
804struct WsFrameReader {
805    inner: WsStream,
806    buf: bytes::Bytes,
807}
808
809impl AsyncRead for WsFrameReader {
810    fn poll_read(
811        mut self: std::pin::Pin<&mut Self>,
812        cx: &mut std::task::Context<'_>,
813        out: &mut tokio::io::ReadBuf<'_>,
814    ) -> std::task::Poll<std::io::Result<()>> {
815        loop {
816            if !self.buf.is_empty() {
817                let n = out.remaining().min(self.buf.len());
818                out.put_slice(&self.buf[..n]);
819                self.buf = self.buf.slice(n..);
820                return std::task::Poll::Ready(Ok(()));
821            }
822            match self.inner.poll_next_unpin(cx) {
823                std::task::Poll::Pending => return std::task::Poll::Pending,
824                std::task::Poll::Ready(None) => return std::task::Poll::Ready(Ok(())),
825                std::task::Poll::Ready(Some(Err(e))) => {
826                    return std::task::Poll::Ready(Err(std::io::Error::other(e)));
827                }
828                std::task::Poll::Ready(Some(Ok(msg))) => {
829                    let data = match msg {
830                        Message::Binary(d) => d,
831                        Message::Close(_) => return std::task::Poll::Ready(Ok(())),
832                        _ => continue,
833                    };
834                    let len = data.len() as u32;
835                    let mut framed = Vec::with_capacity(4 + data.len());
836                    framed.extend_from_slice(&len.to_le_bytes());
837                    framed.extend_from_slice(&data);
838                    self.buf = bytes::Bytes::from(framed);
839                }
840            }
841        }
842    }
843}
844
845struct WsFrameWriter {
846    inner: WsSink,
847}
848
849impl AsyncWrite for WsFrameWriter {
850    fn poll_write(
851        mut self: std::pin::Pin<&mut Self>,
852        cx: &mut std::task::Context<'_>,
853        buf: &[u8],
854    ) -> std::task::Poll<std::io::Result<usize>> {
855        if buf.len() < 4 {
856            return std::task::Poll::Ready(Err(std::io::Error::new(
857                std::io::ErrorKind::InvalidData,
858                "ws frame writer: buffer too small for length prefix",
859            )));
860        }
861        let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize;
862        if buf.len() < 4 + len {
863            return std::task::Poll::Ready(Err(std::io::Error::new(
864                std::io::ErrorKind::InvalidData,
865                format!(
866                    "ws frame writer: buffer ({}) too small for frame payload ({})",
867                    buf.len(),
868                    4 + len
869                ),
870            )));
871        }
872        let payload = buf[4..4 + len].to_vec();
873        match self.inner.poll_ready_unpin(cx) {
874            std::task::Poll::Pending => return std::task::Poll::Pending,
875            std::task::Poll::Ready(Err(e)) => {
876                return std::task::Poll::Ready(Err(std::io::Error::other(e)));
877            }
878            std::task::Poll::Ready(Ok(())) => {}
879        }
880        match self.inner.start_send_unpin(Message::Binary(payload.into())) {
881            Err(e) => std::task::Poll::Ready(Err(std::io::Error::other(e))),
882            Ok(()) => std::task::Poll::Ready(Ok(4 + len)),
883        }
884    }
885    fn poll_flush(
886        mut self: std::pin::Pin<&mut Self>,
887        cx: &mut std::task::Context<'_>,
888    ) -> std::task::Poll<std::io::Result<()>> {
889        self.inner
890            .poll_flush_unpin(cx)
891            .map_err(std::io::Error::other)
892    }
893    fn poll_shutdown(
894        mut self: std::pin::Pin<&mut Self>,
895        cx: &mut std::task::Context<'_>,
896    ) -> std::task::Poll<std::io::Result<()>> {
897        self.inner
898            .poll_close_unpin(cx)
899            .map_err(std::io::Error::other)
900    }
901}
902
903// ---------------------------------------------------------------------------
904// Downstream listener
905// ---------------------------------------------------------------------------
906
907/// Read one line from the downstream socket (up to 4 KiB).
908async fn read_line<S>(stream: &mut S) -> Option<String>
909where
910    S: tokio::io::AsyncRead + Unpin,
911{
912    use tokio::io::AsyncReadExt;
913    let mut buf = Vec::with_capacity(128);
914    let mut byte = [0u8; 1];
915    loop {
916        match stream.read(&mut byte).await {
917            Ok(0) | Err(_) => return None,
918            Ok(_) => {
919                if byte[0] == b'\n' {
920                    break;
921                }
922                buf.push(byte[0]);
923                if buf.len() > 4096 {
924                    return None;
925                }
926            }
927        }
928    }
929    Some(
930        String::from_utf8_lossy(&buf)
931            .trim_end_matches('\r')
932            .to_string(),
933    )
934}
935
936#[cfg(unix)]
937async fn run_listener(
938    registry: Arc<Registry>,
939    share_registry: Arc<ShareRegistry>,
940    sock_path: &str,
941) {
942    let _ = std::fs::remove_file(sock_path);
943    let listener = tokio::net::UnixListener::bind(sock_path).unwrap_or_else(|e| {
944        eprintln!("blit-proxy: cannot bind to {sock_path}: {e}");
945        std::process::exit(1);
946    });
947    log!("blit-proxy: listening on {sock_path}");
948    loop {
949        match listener.accept().await {
950            Ok((stream, _)) => {
951                let registry = registry.clone();
952                let share_registry = share_registry.clone();
953                tokio::spawn(async move {
954                    handle_downstream(registry, share_registry, stream).await;
955                });
956            }
957            Err(e) => log!("blit-proxy: accept error: {e}"),
958        }
959    }
960}
961
962#[cfg(windows)]
963async fn run_listener(
964    registry: Arc<Registry>,
965    share_registry: Arc<ShareRegistry>,
966    pipe_path: &str,
967    first: tokio::net::windows::named_pipe::NamedPipeServer,
968) {
969    use tokio::net::windows::named_pipe::ServerOptions;
970    log!("blit-proxy: listening on {pipe_path}");
971    // Use the pre-created first instance (created with first_pipe_instance(true)
972    // by the caller) to avoid a race window where the pipe name is unowned.
973    let mut next_server = Some(first);
974    loop {
975        // Prepare the next server instance before awaiting the current connection,
976        // so there is always a waiting server end after handoff.
977        let server = match next_server.take() {
978            Some(s) => s,
979            None => match ServerOptions::new()
980                .first_pipe_instance(false)
981                .create(pipe_path)
982            {
983                Ok(s) => s,
984                Err(e) => {
985                    log!("blit-proxy: create pipe instance: {e}");
986                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
987                    continue;
988                }
989            },
990        };
991        // Create the *next* instance before connecting, so the pipe name is
992        // never unowned between connections.
993        let upcoming = ServerOptions::new()
994            .first_pipe_instance(false)
995            .create(pipe_path);
996        if let Err(e) = server.connect().await {
997            log!("blit-proxy: pipe connect: {e}");
998            // Put the upcoming server back for the next iteration.
999            if let Ok(u) = upcoming {
1000                next_server = Some(u);
1001            }
1002            continue;
1003        }
1004        if let Ok(u) = upcoming {
1005            next_server = Some(u);
1006        }
1007        let registry = registry.clone();
1008        let share_registry = share_registry.clone();
1009        tokio::spawn(async move {
1010            handle_downstream(registry, share_registry, server).await;
1011        });
1012    }
1013}
1014
1015async fn handle_downstream<S>(
1016    registry: Arc<Registry>,
1017    share_registry: Arc<ShareRegistry>,
1018    mut downstream: S,
1019) where
1020    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1021{
1022    // Handshake: read "target <uri>\n" or "shutdown\n"
1023    let line = match read_line(&mut downstream).await {
1024        Some(l) => l,
1025        None => return,
1026    };
1027    if line == "shutdown" {
1028        let _ = downstream.write_all(b"ok\n").await;
1029        log!("blit-proxy: shutdown requested, exiting");
1030        std::process::exit(0);
1031    }
1032    let uri = match line.strip_prefix("target ") {
1033        Some(u) if !u.is_empty() => u.to_string(),
1034        _ => {
1035            let _ = downstream.write_all(b"error invalid handshake\n").await;
1036            return;
1037        }
1038    };
1039
1040    if uri.starts_with("share:") {
1041        // Route through mux session pool.
1042        let pool = share_registry.get_or_create(&uri).await;
1043
1044        let (stream_id, upstream) = match pool.acquire().await {
1045            Ok(t) => t,
1046            Err(e) => {
1047                log!("blit-proxy: [{uri}] share upstream unavailable: {e}");
1048                let msg = format!("error {e}\n");
1049                let _ = downstream.write_all(msg.as_bytes()).await;
1050                return;
1051            }
1052        };
1053
1054        if downstream.write_all(b"ok\n").await.is_err() {
1055            pool.recycle(stream_id);
1056            return;
1057        }
1058
1059        pool.client_connected();
1060
1061        let (mut ds_read, mut ds_write) = tokio::io::split(downstream);
1062        let (mut us_read, mut us_write) = (upstream.reader, upstream.writer);
1063
1064        let mut d2u =
1065            tokio::spawn(async move { tokio::io::copy(&mut ds_read, &mut us_write).await });
1066        let mut u2d =
1067            tokio::spawn(async move { tokio::io::copy(&mut us_read, &mut ds_write).await });
1068
1069        // Wait for the aborted task to complete so its half of the
1070        // downstream socket is dropped before we return.  Without this
1071        // the CLI's `finish()` may spin for up to 2 s waiting for EOF
1072        // that only arrives once the runtime asynchronously drops the
1073        // aborted task's future (and the `ds_write` it owns).
1074        tokio::select! {
1075            _ = &mut d2u => { u2d.abort(); let _ = u2d.await; },
1076            _ = &mut u2d => { d2u.abort(); let _ = d2u.await; },
1077        }
1078
1079        pool.recycle(stream_id);
1080        pool.client_disconnected();
1081        return;
1082    }
1083
1084    let pool = registry.get_or_create(&uri).await;
1085
1086    let upstream = match pool.acquire().await {
1087        Ok(u) => u,
1088        Err(e) => {
1089            log!("blit-proxy: [{uri}] upstream unavailable: {e}");
1090            let msg = format!("error {e}\n");
1091            let _ = downstream.write_all(msg.as_bytes()).await;
1092            return;
1093        }
1094    };
1095
1096    if downstream.write_all(b"ok\n").await.is_err() {
1097        return;
1098    }
1099
1100    pool.client_connected();
1101
1102    let (mut ds_read, mut ds_write) = tokio::io::split(downstream);
1103    let (mut us_read, mut us_write) = (upstream.reader, upstream.writer);
1104
1105    let mut d2u = tokio::spawn(async move { tokio::io::copy(&mut ds_read, &mut us_write).await });
1106    let mut u2d = tokio::spawn(async move { tokio::io::copy(&mut us_read, &mut ds_write).await });
1107
1108    // Await the aborted task so its socket half is dropped promptly (see
1109    // comment in the share: arm above).
1110    tokio::select! {
1111        _ = &mut d2u => { u2d.abort(); let _ = u2d.await; },
1112        _ = &mut u2d => { d2u.abort(); let _ = d2u.await; },
1113    }
1114
1115    pool.client_disconnected();
1116}
1117
1118// ---------------------------------------------------------------------------
1119// Public entry point
1120// ---------------------------------------------------------------------------
1121
1122/// Run the proxy on the current thread (blocks until the process exits).
1123///
1124/// Reads `BLIT_PROXY_SOCK`, `BLIT_PROXY_POOL`, and `BLIT_PROXY_IDLE` from the
1125/// environment. When called from within the `blit` binary instead of the
1126/// standalone `blit-proxy` binary, `verbose` should be `false`.
1127pub fn run(verbose: bool) {
1128    if verbose {
1129        VERBOSE.store(true, Ordering::Relaxed);
1130    }
1131
1132    let sock_path = proxy_socket_path();
1133
1134    let pool_size: usize = std::env::var("BLIT_PROXY_POOL")
1135        .ok()
1136        .and_then(|v| v.parse().ok())
1137        .unwrap_or(4);
1138
1139    let idle_secs: Option<u64> = std::env::var("BLIT_PROXY_IDLE")
1140        .ok()
1141        .and_then(|v| v.parse().ok());
1142
1143    tokio::runtime::Builder::new_multi_thread()
1144        .enable_all()
1145        .build()
1146        .expect("blit-proxy: tokio runtime")
1147        .block_on(async move {
1148            rustls::crypto::ring::default_provider()
1149                .install_default()
1150                .ok(); // may already be installed by the CLI's runtime
1151
1152            let registry = Registry::new(pool_size);
1153            let share_registry = ShareRegistry::new();
1154
1155            // Idle-timeout watcher.
1156            if let Some(idle) = idle_secs {
1157                let reg = registry.clone();
1158                let sreg = share_registry.clone();
1159                let sock = sock_path.clone();
1160                tokio::spawn(async move {
1161                    loop {
1162                        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1163                        let last = reg
1164                            .latest_activity()
1165                            .await
1166                            .min(sreg.latest_activity().await);
1167                        if last == i64::MAX {
1168                            continue;
1169                        }
1170                        let elapsed = now_secs().saturating_sub(last) as u64;
1171                        if elapsed >= idle {
1172                            log!("blit-proxy: idle for {elapsed}s (limit {idle}s), exiting");
1173                            let _ = std::fs::remove_file(&sock);
1174                            std::process::exit(0);
1175                        }
1176                    }
1177                });
1178            }
1179
1180            #[cfg(unix)]
1181            {
1182                let sock_cleanup = sock_path.clone();
1183                tokio::spawn(async move {
1184                    let _ = tokio::signal::ctrl_c().await;
1185                    let _ = std::fs::remove_file(&sock_cleanup);
1186                    std::process::exit(0);
1187                });
1188                run_listener(registry, share_registry, &sock_path).await;
1189            }
1190            #[cfg(windows)]
1191            {
1192                // Create the first pipe instance before signalling readiness
1193                // so that clients polling the pipe path see it immediately.
1194                use tokio::net::windows::named_pipe::ServerOptions;
1195                let first = ServerOptions::new()
1196                    .first_pipe_instance(true)
1197                    .create(&sock_path)
1198                    .unwrap_or_else(|e| {
1199                        eprintln!("blit-proxy: cannot create pipe {sock_path}: {e}");
1200                        std::process::exit(1);
1201                    });
1202                // Pass `first` into run_listener so the pipe name is never
1203                // unowned between creation and the first client connection.
1204                run_listener(registry, share_registry, &sock_path, first).await;
1205            }
1206        });
1207}
1208
1209// ---------------------------------------------------------------------------
1210// Tests
1211// ---------------------------------------------------------------------------
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::*;
1216
1217    #[test]
1218    fn proxy_socket_path_default() {
1219        // Just verify it produces a non-empty string without panicking.
1220        let p = proxy_socket_path();
1221        assert!(!p.is_empty());
1222        assert!(p.contains("blit-proxy"));
1223    }
1224
1225    #[test]
1226    fn parse_hex_valid() {
1227        assert_eq!(parse_hex("deadbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
1228        assert_eq!(parse_hex(""), Some(vec![]));
1229    }
1230
1231    #[test]
1232    fn parse_hex_odd_length() {
1233        assert_eq!(parse_hex("abc"), None);
1234    }
1235
1236    #[test]
1237    fn parse_hex_invalid_char() {
1238        assert_eq!(parse_hex("zz"), None);
1239    }
1240
1241    #[test]
1242    fn extract_uri_params_no_query() {
1243        let (base, pass, cert) = extract_uri_params("wss://host:3264/");
1244        assert_eq!(base, "wss://host:3264/");
1245        assert_eq!(pass, None);
1246        assert_eq!(cert, None);
1247    }
1248
1249    #[test]
1250    fn extract_uri_params_passphrase() {
1251        let (base, pass, cert) = extract_uri_params("wss://host:3264/?passphrase=secret");
1252        assert_eq!(base, "wss://host:3264/");
1253        assert_eq!(pass, Some("secret".into()));
1254        assert_eq!(cert, None);
1255    }
1256
1257    #[test]
1258    fn extract_uri_params_both() {
1259        let (base, pass, cert) =
1260            extract_uri_params("wt://host:4433/?passphrase=abc&certHash=deadbeef");
1261        assert_eq!(base, "wt://host:4433/");
1262        assert_eq!(pass, Some("abc".into()));
1263        assert_eq!(cert, Some("deadbeef".into()));
1264    }
1265
1266    #[test]
1267    fn extract_uri_params_socket_unchanged() {
1268        let (base, pass, cert) = extract_uri_params("socket:/tmp/blit.sock");
1269        assert_eq!(base, "socket:/tmp/blit.sock");
1270        assert_eq!(pass, None);
1271        assert_eq!(cert, None);
1272    }
1273
1274    #[test]
1275    fn parse_wt_host_port_with_port() {
1276        assert_eq!(parse_wt_host_port("host:4433"), Ok(("host".into(), 4433)));
1277    }
1278
1279    #[test]
1280    fn parse_wt_host_port_default() {
1281        assert_eq!(parse_wt_host_port("host"), Ok(("host".into(), 443)));
1282    }
1283
1284    #[test]
1285    fn percent_decode_basic() {
1286        assert_eq!(percent_decode("hello%20world"), "hello world");
1287        assert_eq!(percent_decode("plain"), "plain");
1288    }
1289
1290    #[test]
1291    fn parse_share_uri_no_hub() {
1292        let (pass, hub) = parse_share_uri("myphrase");
1293        assert_eq!(pass, "myphrase");
1294        assert_eq!(
1295            hub,
1296            blit_webrtc_forwarder::normalize_hub(blit_webrtc_forwarder::DEFAULT_HUB_URL)
1297        );
1298    }
1299
1300    #[test]
1301    fn parse_share_uri_with_hub() {
1302        let (pass, hub) = parse_share_uri("myphrase?hub=wss://custom.example.com");
1303        assert_eq!(pass, "myphrase");
1304        assert_eq!(hub, "wss://custom.example.com");
1305    }
1306
1307    #[test]
1308    fn parse_share_uri_hub_normalized() {
1309        let (pass, hub) = parse_share_uri("myphrase?hub=custom.example.com");
1310        assert_eq!(pass, "myphrase");
1311        assert_eq!(hub, "wss://custom.example.com");
1312    }
1313
1314    #[test]
1315    fn parse_share_uri_percent_encoded_passphrase() {
1316        let (pass, hub) = parse_share_uri("my%3Fphrase");
1317        assert_eq!(pass, "my?phrase");
1318        assert_eq!(
1319            hub,
1320            blit_webrtc_forwarder::normalize_hub(blit_webrtc_forwarder::DEFAULT_HUB_URL)
1321        );
1322    }
1323}