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