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