From 568c85e5974553ea77af57a02bc9a974e3634471 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:05:12 +0400 Subject: [PATCH 01/11] feat: added google drive mode in cli --- Cargo.toml | 4 + README.md | 17 + config.drive.example.json | 17 + src/bin/drive_node.rs | 102 ++++ src/bin/ui.rs | 10 + src/config.rs | 149 ++++- src/drive_tunnel.rs | 1121 +++++++++++++++++++++++++++++++++++++ src/google_drive.rs | 1054 ++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- src/main.rs | 31 +- src/proxy_server.rs | 10 +- src/test_cmd.rs | 7 +- 12 files changed, 2512 insertions(+), 14 deletions(-) create mode 100644 config.drive.example.json create mode 100644 src/bin/drive_node.rs create mode 100644 src/drive_tunnel.rs create mode 100644 src/google_drive.rs diff --git a/Cargo.toml b/Cargo.toml index 3aebc1f..ecbe746 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ name = "mhrv-rs-ui" path = "src/bin/ui.rs" required-features = ["ui"] +[[bin]] +name = "mhrv-drive-node" +path = "src/bin/drive_node.rs" + [features] default = [] ui = ["dep:eframe"] diff --git a/README.md b/README.md index 353bd0f..bda05b3 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,23 @@ More deployments = more total concurrency = lower per-session latency. Each batc } ``` +## Google Drive queue mode + +Experimental `google_drive` mode ports the FlowDriver idea: Google Drive is used as a shared file queue instead of Apps Script. The client listens as a SOCKS5 proxy, writes multiplexed request envelopes into a Drive folder, and `mhrv-drive-node` polls the same folder, opens the real TCP connections, and writes response envelopes back. + +Use [`config.drive.example.json`](config.drive.example.json) on both machines. Create a Google OAuth desktop credential JSON with the Drive API enabled, set `drive_credentials_path`, then start the node first: + +```bash +mhrv-drive-node -c config.drive.json +mhrv-rs -c config.drive.json +``` + +On first run each side prints a Google OAuth URL and saves `.token` (the client tries to chmod 0600 on Unix — the file holds a long-lived OAuth refresh token, treat it like a credential). If `drive_folder_id` is empty, the program finds or creates `drive_folder_name`; for different machines/accounts, copy the resulting folder ID into both configs. This mode is SOCKS5-only and does not support UDP. + +Tune `drive_idle_timeout_secs` (default 300) upward if you tunnel long-poll HTTP, idle WebSockets, or anything else that can go quiet for minutes — sessions silent past this window are force-closed. + +> **Security note:** `mhrv-drive-node` is effectively an open TCP relay for whoever has read/write access to the shared Drive folder — anything that can drop a `req-…mux-…bin` file in there can open arbitrary `host:port` connections through the node. Keep the folder narrowly scoped (one OAuth account, no link sharing) and don't run the node on a machine you don't control. + ## Running on OpenWRT (or any musl distro) The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpine, and any libc-less Linux userland. Put the binary on the router and start it as a service: diff --git a/config.drive.example.json b/config.drive.example.json new file mode 100644 index 0000000..36f3e88 --- /dev/null +++ b/config.drive.example.json @@ -0,0 +1,17 @@ +{ + "mode": "google_drive", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "socks5_port": 8086, + "log_level": "info", + "verify_ssl": true, + "drive_credentials_path": "credentials.json", + "drive_folder_id": "", + "drive_folder_name": "MHRV-Drive", + "drive_client_id": "client1", + "drive_poll_ms": 500, + "drive_flush_ms": 300, + "drive_idle_timeout_secs": 300 +} diff --git a/src/bin/drive_node.rs b/src/bin/drive_node.rs new file mode 100644 index 0000000..59026f7 --- /dev/null +++ b/src/bin/drive_node.rs @@ -0,0 +1,102 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use mhrv_rs::config::{Config, Mode}; +use tracing_subscriber::EnvFilter; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn print_help() { + println!( + "mhrv-drive-node {} — Google Drive queue tunnel server + +USAGE: + mhrv-drive-node [OPTIONS] + +OPTIONS: + -c, --config PATH Path to config.json (default: ./config.json) + -h, --help Show this message + -V, --version Show version + +ENV: + RUST_LOG Override log level (e.g. info, debug) +", + VERSION + ); +} + +fn parse_args() -> Result, String> { + let mut config_path = None; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + "-V" | "--version" => { + println!("mhrv-drive-node {}", VERSION); + std::process::exit(0); + } + "-c" | "--config" => { + let v = it + .next() + .ok_or_else(|| "--config needs a path".to_string())?; + config_path = Some(PathBuf::from(v)); + } + other => return Err(format!("unknown argument: {}", other)), + } + } + Ok(config_path) +} + +fn init_logging(level: &str) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .try_init(); +} + +#[tokio::main] +async fn main() -> ExitCode { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let config_path = match parse_args() { + Ok(path) => path, + Err(e) => { + eprintln!("{}", e); + print_help(); + return ExitCode::from(2); + } + }; + let config_path = mhrv_rs::data_dir::resolve_config_path(config_path.as_deref()); + let config = match Config::load(&config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{}", e); + return ExitCode::FAILURE; + } + }; + init_logging(&config.log_level); + + if config.mode_kind().ok() != Some(Mode::GoogleDrive) { + eprintln!("mhrv-drive-node requires config mode \"google_drive\""); + return ExitCode::from(2); + } + + tracing::warn!("mhrv-drive-node {} starting", VERSION); + let run = mhrv_rs::drive_tunnel::run_server(&config); + tokio::select! { + r = run => { + if let Err(e) = r { + eprintln!("drive node error: {}", e); + return ExitCode::FAILURE; + } + } + _ = tokio::signal::ctrl_c() => { + tracing::warn!("Ctrl+C — shutting down drive node."); + } + } + ExitCode::SUCCESS +} diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 1f8b282..fea1796 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -500,6 +500,16 @@ impl FormState { // control yet). Round-trip through the file so save // doesn't drop a user-set true. block_quic: self.block_quic, + // Google Drive mode is CLI-only for now; keep defaults here so + // the desktop UI's Config initializer stays in sync. + drive_credentials_path: "credentials.json".into(), + drive_token_path: None, + drive_folder_id: String::new(), + drive_folder_name: "MHRV-Drive".into(), + drive_client_id: String::new(), + drive_poll_ms: 500, + drive_flush_ms: 300, + drive_idle_timeout_secs: 300, }) } } diff --git a/src/config.rs b/src/config.rs index 57f240e..d024c31 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,6 +23,7 @@ pub enum Mode { AppsScript, GoogleOnly, Full, + GoogleDrive, } impl Mode { @@ -31,6 +32,7 @@ impl Mode { Mode::AppsScript => "apps_script", Mode::GoogleOnly => "google_only", Mode::Full => "full", + Mode::GoogleDrive => "google_drive", } } } @@ -111,7 +113,7 @@ pub struct Config { pub max_ips_to_scan: usize, #[serde(default = "default_scan_batch_size")] - pub scan_batch_size:usize, + pub scan_batch_size: usize, #[serde(default = "default_google_ip_validation")] pub google_ip_validation: bool, @@ -190,12 +192,67 @@ pub struct Config { /// failure modes later. Issue #213. #[serde(default)] pub block_quic: bool, + + /// Google Drive queue mode (`mode = "google_drive"`). This is the + /// FlowDriver-style transport: both client and `mhrv-drive-node` poll + /// a shared Drive folder and exchange multiplexed binary envelopes as + /// short-lived files. It does not use Apps Script, `script_id`, or + /// `auth_key`; OAuth credentials are loaded from this desktop-client + /// JSON instead. + #[serde(default = "default_drive_credentials_path")] + pub drive_credentials_path: String, + /// Optional override for the cached OAuth refresh token path. When + /// omitted, `.token` is used. + #[serde(default)] + pub drive_token_path: Option, + /// Shared Google Drive folder ID. If empty, the client/node will find + /// or create `drive_folder_name` in the authorized account. + #[serde(default)] + pub drive_folder_id: String, + #[serde(default = "default_drive_folder_name")] + pub drive_folder_name: String, + /// Stable client ID used in Drive filenames. If empty, a short random + /// ID is generated for this process. + #[serde(default)] + pub drive_client_id: String, + #[serde(default = "default_drive_poll_ms")] + pub drive_poll_ms: u64, + #[serde(default = "default_drive_flush_ms")] + pub drive_flush_ms: u64, + /// Per-session inactivity cutoff. Long-poll HTTP, idle WebSockets and + /// the like need this above their own keepalive interval; the FlowDriver + /// default of 15 s was too aggressive for real protocols. + #[serde(default = "default_drive_idle_timeout_secs")] + pub drive_idle_timeout_secs: u64, } -fn default_fetch_ips_from_api() -> bool { false } -fn default_max_ips_to_scan() -> usize { 100 } -fn default_scan_batch_size() -> usize {500} -fn default_google_ip_validation() -> bool {true} +fn default_fetch_ips_from_api() -> bool { + false +} +fn default_max_ips_to_scan() -> usize { + 100 +} +fn default_scan_batch_size() -> usize { + 500 +} +fn default_google_ip_validation() -> bool { + true +} +fn default_drive_credentials_path() -> String { + "credentials.json".into() +} +fn default_drive_folder_name() -> String { + "MHRV-Drive".into() +} +fn default_drive_poll_ms() -> u64 { + 500 +} +fn default_drive_flush_ms() -> u64 { + 300 +} +fn default_drive_idle_timeout_secs() -> u64 { + 300 +} fn default_google_ip() -> String { "216.239.38.120".into() @@ -247,6 +304,49 @@ impl Config { } } } + if mode == Mode::GoogleDrive { + if self.drive_credentials_path.trim().is_empty() { + return Err(ConfigError::Invalid( + "drive_credentials_path is required in google_drive mode".into(), + )); + } + if self.drive_poll_ms == 0 || self.drive_flush_ms == 0 { + return Err(ConfigError::Invalid( + "drive_poll_ms and drive_flush_ms must be greater than 0".into(), + )); + } + if self.drive_idle_timeout_secs == 0 { + return Err(ConfigError::Invalid( + "drive_idle_timeout_secs must be greater than 0".into(), + )); + } + // The id is concatenated unsanitised into Drive filenames and + // the `name contains '...'` query, so reject anything that + // could break the wire format or query string. + let cid = self.drive_client_id.trim(); + if !cid.is_empty() + && (cid.len() > 32 + || !cid + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) + { + return Err(ConfigError::Invalid( + "drive_client_id must be <=32 chars, ASCII alphanumeric / '-' / '_'".into(), + )); + } + // Folder name shows up inside a single-quoted Drive query; the + // helper escapes \\ and ', but a stray newline could still + // throw the query off. Disallow control chars defensively. + if self + .drive_folder_name + .chars() + .any(|c| c.is_control() || c == '\r' || c == '\n') + { + return Err(ConfigError::Invalid( + "drive_folder_name must not contain control characters".into(), + )); + } + } if self.scan_batch_size == 0 { return Err(ConfigError::Invalid( "scan_batch_size must be greater than 0".into(), @@ -265,8 +365,9 @@ impl Config { "apps_script" => Ok(Mode::AppsScript), "google_only" => Ok(Mode::GoogleOnly), "full" => Ok(Mode::Full), + "google_drive" => Ok(Mode::GoogleDrive), other => Err(ConfigError::Invalid(format!( - "unknown mode '{}' (expected 'apps_script', 'google_only', or 'full')", + "unknown mode '{}' (expected 'apps_script', 'google_only', 'full', or 'google_drive')", other ))), } @@ -340,7 +441,8 @@ mod tests { "mode": "google_only" }"#; let cfg: Config = serde_json::from_str(s).unwrap(); - cfg.validate().expect("google_only must validate without script_id / auth_key"); + cfg.validate() + .expect("google_only must validate without script_id / auth_key"); assert_eq!(cfg.mode_kind().unwrap(), Mode::GoogleOnly); } @@ -379,6 +481,39 @@ mod tests { assert!(cfg.validate().is_err()); } + #[test] + fn parses_google_drive_without_apps_script_fields() { + let s = r#"{ + "mode": "google_drive", + "drive_credentials_path": "credentials.json" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate().unwrap(); + assert_eq!(cfg.mode_kind().unwrap(), Mode::GoogleDrive); + assert_eq!(cfg.drive_folder_name, "MHRV-Drive"); + assert_eq!(cfg.drive_poll_ms, 500); + assert_eq!(cfg.drive_flush_ms, 300); + assert_eq!(cfg.drive_idle_timeout_secs, 300); + } + + #[test] + fn rejects_google_drive_client_id_with_special_chars() { + let s = r#"{ + "mode": "google_drive", + "drive_credentials_path": "credentials.json", + "drive_client_id": "bad client id" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn rejects_google_drive_folder_name_with_control_chars() { + let s = "{\"mode\":\"google_drive\",\"drive_credentials_path\":\"c.json\",\"drive_folder_name\":\"bad\\nname\"}"; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert!(cfg.validate().is_err()); + } + #[test] fn rejects_unknown_mode_value() { let s = r#"{ diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs new file mode 100644 index 0000000..0d7216f --- /dev/null +++ b/src/drive_tunnel.rs @@ -0,0 +1,1121 @@ +//! FlowDriver-style Google Drive tunnel mode. +//! +//! The Drive folder acts as a lossy, short-lived message queue. Client-side +//! SOCKS5 CONNECT streams become sessions. Both sides periodically flush +//! buffered bytes into multiplexed `req-...-mux-...bin` / `res-...` files, +//! poll for peer files, process envelopes in sequence, then delete them. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use rand::RngCore; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, Mutex}; + +use crate::config::Config; +use crate::google_drive::{DriveError, GoogleDriveBackend}; + +const MAGIC_BYTE: u8 = 0x1f; +/// Bumped whenever the wire format changes. v1 added a flags byte +/// (replacing the old `close` bool) and gained the FLAG_OPEN_OK bit +/// so the server can confirm a successful upstream connect to the +/// SOCKS5 client before it returns success to its caller. +const ENVELOPE_VERSION: u8 = 0x01; + +const FLAG_CLOSE: u8 = 0x01; +const FLAG_OPEN_OK: u8 = 0x02; + +const MAX_ENVELOPE_PAYLOAD: usize = 10 * 1024 * 1024; +const MAX_TX_BUFFER: usize = 2 * 1024 * 1024; +/// Garbage-collect own files whose Drive `createdTime` is older than +/// this. The peer should normally consume + delete within seconds; if +/// it doesn't (peer down, network outage), this is the failsafe so the +/// shared folder doesn't fill up. Compared against Drive's clock, not +/// the local clock, so multi-machine setups don't false-positive on +/// clock skew. +const OLD_FILE_TTL: Duration = Duration::from_secs(60); +/// Drop files we find on first poll that look ancient — most likely +/// leftovers from a previous run on the other side. Same Drive-clock +/// comparison as OLD_FILE_TTL. +const STARTUP_STALE_TTL: Duration = Duration::from_secs(5 * 60); +/// How long a SOCKS5 client waits for the server's connect result +/// before giving up and returning a SOCKS5 reply error. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Recently-closed session IDs are remembered for this long; envelopes +/// that arrive for a closed ID are dropped instead of resurrecting it. +const CLOSED_SESSION_TTL: Duration = Duration::from_secs(120); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Direction { + Req, + Res, +} + +impl Direction { + fn as_str(self) -> &'static str { + match self { + Direction::Req => "req", + Direction::Res => "res", + } + } +} + +#[derive(Debug)] +struct Envelope { + session_id: String, + seq: u64, + target_addr: String, + payload: Vec, + flags: u8, +} + +impl Envelope { + fn encode(&self, out: &mut Vec) -> Result<(), DriveError> { + if self.session_id.len() > u8::MAX as usize { + return Err(DriveError::BadResponse("session id too long".into())); + } + if self.target_addr.len() > u8::MAX as usize { + return Err(DriveError::BadResponse("target address too long".into())); + } + if self.payload.len() > u32::MAX as usize { + return Err(DriveError::BadResponse("payload too large".into())); + } + out.push(MAGIC_BYTE); + out.push(ENVELOPE_VERSION); + out.push(self.session_id.len() as u8); + out.extend_from_slice(self.session_id.as_bytes()); + out.extend_from_slice(&self.seq.to_be_bytes()); + out.push(self.target_addr.len() as u8); + out.extend_from_slice(self.target_addr.as_bytes()); + out.push(self.flags); + out.extend_from_slice(&(self.payload.len() as u32).to_be_bytes()); + out.extend_from_slice(&self.payload); + Ok(()) + } + + fn decode_one(buf: &[u8], pos: &mut usize) -> Result, DriveError> { + if *pos >= buf.len() { + return Ok(None); + } + if read_u8(buf, pos)? != MAGIC_BYTE { + return Err(DriveError::BadResponse("bad Drive envelope magic".into())); + } + let version = read_u8(buf, pos)?; + if version != ENVELOPE_VERSION { + return Err(DriveError::BadResponse(format!( + "unsupported Drive envelope version {}", + version + ))); + } + let sid_len = read_u8(buf, pos)? as usize; + let session_id = read_string(buf, pos, sid_len)?; + let seq = read_u64(buf, pos)?; + let target_len = read_u8(buf, pos)? as usize; + let target_addr = read_string(buf, pos, target_len)?; + let flags = read_u8(buf, pos)?; + let payload_len = read_u32(buf, pos)? as usize; + if payload_len > MAX_ENVELOPE_PAYLOAD { + return Err(DriveError::BadResponse(format!( + "Drive envelope payload too large: {}", + payload_len + ))); + } + if buf.len().saturating_sub(*pos) < payload_len { + return Err(DriveError::BadResponse( + "truncated Drive envelope payload".into(), + )); + } + let payload = buf[*pos..*pos + payload_len].to_vec(); + *pos += payload_len; + Ok(Some(Self { + session_id, + seq, + target_addr, + payload, + flags, + })) + } +} + +enum DriveRx { + /// Server confirmed a successful upstream TCP connect. The client + /// SOCKS5 handshake waits for this before replying success so a + /// failed dial surfaces as a SOCKS5 error rather than a half-open + /// socket that silently closes. + Open, + Data(Vec), + Close, +} + +struct DriveSession { + id: String, + target_addr: String, + client_id: String, + tx_buf: Vec, + tx_seq: u64, + rx_seq: u64, + rx_queue: BTreeMap, + last_activity: Instant, + closed: bool, + rx_closed: bool, + /// Server-side: set once the upstream TCP connect succeeds; cleared + /// after the next flush emits an open-ack envelope. Always false on + /// the client side. + pending_open_ok: bool, + rx_tx: mpsc::Sender, +} + +impl DriveSession { + fn new( + id: String, + target_addr: String, + client_id: String, + ) -> (Arc>, mpsc::Receiver) { + let (rx_tx, rx_rx) = mpsc::channel(1024); + let session = Self { + id, + target_addr, + client_id, + tx_buf: Vec::new(), + tx_seq: 0, + rx_seq: 0, + rx_queue: BTreeMap::new(), + last_activity: Instant::now(), + closed: false, + rx_closed: false, + pending_open_ok: false, + rx_tx, + }; + (Arc::new(Mutex::new(session)), rx_rx) + } +} + +pub struct DriveNewSession { + id: String, + target_addr: String, + rx: mpsc::Receiver, +} + +pub struct DriveEngine { + backend: Arc, + my_dir: Direction, + peer_dir: Direction, + client_id: String, + sessions: Mutex>>>, + processed: Mutex>, + /// Recently-closed session IDs. process_envelope refuses to + /// re-create a session for any ID in here, so a stale envelope + /// arriving after teardown can't resurrect it (which would + /// otherwise re-dial the target on the server). + closed_sessions: Mutex>, + poll_interval: Duration, + flush_interval: Duration, + idle_timeout: Duration, + new_session_tx: Option>, +} + +impl DriveEngine { + fn new( + backend: Arc, + is_client: bool, + client_id: String, + config: &Config, + new_session_tx: Option>, + ) -> Arc { + Arc::new(Self { + backend, + my_dir: if is_client { + Direction::Req + } else { + Direction::Res + }, + peer_dir: if is_client { + Direction::Res + } else { + Direction::Req + }, + client_id, + sessions: Mutex::new(HashMap::new()), + processed: Mutex::new(HashMap::new()), + closed_sessions: Mutex::new(HashMap::new()), + poll_interval: Duration::from_millis(config.drive_poll_ms), + flush_interval: Duration::from_millis(config.drive_flush_ms), + idle_timeout: Duration::from_secs(config.drive_idle_timeout_secs), + new_session_tx, + }) + } + + fn start(self: &Arc) { + let flush = self.clone(); + tokio::spawn(async move { flush.flush_loop().await }); + let poll = self.clone(); + tokio::spawn(async move { poll.poll_loop().await }); + let cleanup = self.clone(); + tokio::spawn(async move { cleanup.cleanup_loop().await }); + } + + async fn add_client_session(&self, target_addr: String) -> (String, mpsc::Receiver) { + let id = random_hex(16); + let (session, rx) = DriveSession::new(id.clone(), target_addr, self.client_id.clone()); + self.sessions.lock().await.insert(id.clone(), session); + (id, rx) + } + + /// Returns `Err` when the session has been closed (either previously + /// or because the TX buffer would overflow). Callers must propagate + /// the error so the upstream socket reader stops pumping bytes that + /// would otherwise be silently dropped — silent drops corrupt the + /// underlying byte stream and break TLS / HTTP / SSH on top. + async fn enqueue_tx(&self, session_id: &str, data: &[u8]) -> Result<(), &'static str> { + let session = self.sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return Err("session gone"); + }; + let mut s = session.lock().await; + if s.closed { + return Err("session closed"); + } + if s.tx_buf.len().saturating_add(data.len()) > MAX_TX_BUFFER { + // Force-close instead of silently truncating. The flush + // will emit a FLAG_CLOSE envelope; the peer will tear its + // end down. Better a clean RST than a hole in the stream. + s.closed = true; + s.last_activity = Instant::now(); + tracing::warn!( + "Drive session {} TX buffer would overflow ({} + {} > {}); closing session", + session_id, + s.tx_buf.len(), + data.len(), + MAX_TX_BUFFER + ); + return Err("tx buffer overflow"); + } + s.tx_buf.extend_from_slice(data); + s.last_activity = Instant::now(); + Ok(()) + } + + async fn mark_closed(&self, session_id: &str) { + let session = self.sessions.lock().await.get(session_id).cloned(); + if let Some(session) = session { + let mut s = session.lock().await; + s.closed = true; + s.last_activity = Instant::now(); + } + } + + /// Server-side: flag a session so the next flush carries a + /// FLAG_OPEN_OK envelope back to the client. No-op if the session + /// is already gone (e.g. timed out before connect returned). + async fn mark_open_ok(&self, session_id: &str) { + let session = self.sessions.lock().await.get(session_id).cloned(); + if let Some(session) = session { + let mut s = session.lock().await; + s.pending_open_ok = true; + s.last_activity = Instant::now(); + } + } + + async fn session_count(&self) -> usize { + self.sessions.lock().await.len() + } + + async fn flush_loop(self: Arc) { + let mut ticker = tokio::time::interval(self.flush_interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + if let Err(e) = self.flush_all().await { + tracing::debug!("Drive flush error: {}", e); + } + } + } + + async fn flush_all(&self) -> Result<(), DriveError> { + let sessions: Vec>> = + self.sessions.lock().await.values().cloned().collect(); + let mut muxes: HashMap> = HashMap::new(); + let mut closed_ids = Vec::new(); + + for session in sessions { + let mut s = session.lock().await; + if s.last_activity.elapsed() > self.idle_timeout { + s.closed = true; + } + let first_client_open = self.my_dir == Direction::Req && s.tx_seq == 0; + let open_ok = self.my_dir == Direction::Res && s.pending_open_ok; + let should_send = !s.tx_buf.is_empty() || first_client_open || s.closed || open_ok; + if !should_send { + continue; + } + + let payload = std::mem::take(&mut s.tx_buf); + let mut flags = 0u8; + if s.closed { + flags |= FLAG_CLOSE; + } + if open_ok { + flags |= FLAG_OPEN_OK; + s.pending_open_ok = false; + } + let env = Envelope { + session_id: s.id.clone(), + seq: s.tx_seq, + target_addr: s.target_addr.clone(), + payload, + flags, + }; + s.tx_seq += 1; + if s.closed { + closed_ids.push(s.id.clone()); + } + let cid = if self.my_dir == Direction::Req { + self.client_id.clone() + } else if s.client_id.is_empty() { + "unknown".into() + } else { + s.client_id.clone() + }; + muxes.entry(cid).or_default().push(env); + } + + for (cid, envelopes) in muxes { + let filename = format!("{}-{}-mux-{}.bin", self.my_dir.as_str(), cid, now_nanos()); + let mut body = Vec::new(); + for env in &envelopes { + env.encode(&mut body)?; + } + self.backend.upload(&filename, body).await?; + } + + if !closed_ids.is_empty() { + let mut sessions = self.sessions.lock().await; + let mut closed_set = self.closed_sessions.lock().await; + for id in closed_ids { + sessions.remove(&id); + closed_set.insert(id, Instant::now()); + } + } + Ok(()) + } + + async fn poll_loop(self: Arc) { + loop { + if self.my_dir == Direction::Req && self.session_count().await == 0 { + tokio::time::sleep(self.poll_interval).await; + continue; + } + let got_files = match self.poll_once().await { + Ok(v) => v, + Err(e) => { + tracing::debug!("Drive poll error: {}", e); + false + } + }; + let delay = if got_files { + Duration::from_millis(100) + } else { + self.poll_interval + }; + tokio::time::sleep(delay).await; + } + } + + async fn poll_once(&self) -> Result { + let mut prefix = self.peer_dir.as_str().to_string(); + prefix.push('-'); + if self.my_dir == Direction::Req { + prefix.push_str(&self.client_id); + prefix.push_str("-mux-"); + } + + let files = self.backend.list_query(&prefix).await?; + if files.is_empty() { + return Ok(false); + } + + let stale_cutoff = SystemTime::now().checked_sub(STARTUP_STALE_TTL); + + for file in files { + if let (Some(cutoff), Some(created)) = (stale_cutoff, file.created_time) { + if created < cutoff { + let _ = self.backend.delete(&file.name).await; + continue; + } + } + let already_processed = { + let mut processed = self.processed.lock().await; + if processed.contains_key(&file.name) { + true + } else { + processed.insert(file.name.clone(), Instant::now()); + false + } + }; + if already_processed { + continue; + } + + let data = match self.backend.download(&file.name).await { + Ok(data) => data, + Err(e) => { + self.processed.lock().await.remove(&file.name); + tracing::debug!("Drive download {} failed: {}", file.name, e); + continue; + } + }; + let file_client_id = client_id_from_filename(&file.name).unwrap_or_default(); + if let Err(e) = self.process_mux_file(&data, &file_client_id).await { + // A bad envelope inside a mux file aborts the rest of + // that file's envelopes — nothing we can do, the format + // isn't self-synchronising. Bumping past `debug` so the + // data loss is visible in default logs. + tracing::warn!( + "Drive mux decode {} failed: {} (remaining envelopes in this file are lost)", + file.name, + e + ); + } + let _ = self.backend.delete(&file.name).await; + } + + Ok(true) + } + + async fn process_mux_file(&self, data: &[u8], file_client_id: &str) -> Result<(), DriveError> { + let mut pos = 0usize; + while let Some(env) = Envelope::decode_one(data, &mut pos)? { + self.process_envelope(env, file_client_id).await?; + } + Ok(()) + } + + async fn process_envelope( + &self, + env: Envelope, + file_client_id: &str, + ) -> Result<(), DriveError> { + let session = self.sessions.lock().await.get(&env.session_id).cloned(); + let session = if let Some(session) = session { + session + } else if self.my_dir == Direction::Res && !env.target_addr.is_empty() { + // Refuse to resurrect a session we've already torn down. + // Without this, a late envelope (peer retried, our delete + // raced the upload, etc.) would re-dial the target. + if self + .closed_sessions + .lock() + .await + .contains_key(&env.session_id) + { + return Ok(()); + } + let (session, rx) = DriveSession::new( + env.session_id.clone(), + env.target_addr.clone(), + file_client_id.to_string(), + ); + self.sessions + .lock() + .await + .insert(env.session_id.clone(), session.clone()); + if let Some(tx) = &self.new_session_tx { + let _ = tx + .send(DriveNewSession { + id: env.session_id.clone(), + target_addr: env.target_addr.clone(), + rx, + }) + .await; + } + session + } else { + return Ok(()); + }; + + process_rx(session, env).await; + Ok(()) + } + + async fn cleanup_loop(self: Arc) { + let mut ticker = tokio::time::interval(Duration::from_secs(5)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + self.processed + .lock() + .await + .retain(|_, seen| seen.elapsed() < Duration::from_secs(600)); + self.closed_sessions + .lock() + .await + .retain(|_, seen| seen.elapsed() < CLOSED_SESSION_TTL); + + // Cleanup is scoped to files we actually own. Without the + // client_id prefix, two clients sharing a Drive folder + // would each delete each other's in-flight req-* files. + let prefix = if self.my_dir == Direction::Req { + format!("req-{}-mux-", self.client_id) + } else { + "res-".to_string() + }; + let files = match self.backend.list_query(&prefix).await { + Ok(files) => files, + Err(_) => continue, + }; + let cutoff = match SystemTime::now().checked_sub(OLD_FILE_TTL) { + Some(t) => t, + None => continue, + }; + for file in files { + if let Some(created) = file.created_time { + if created < cutoff { + let _ = self.backend.delete(&file.name).await; + } + } + } + } + } +} + +async fn process_rx(session: Arc>, env: Envelope) { + let (tx, out) = { + let mut s = session.lock().await; + s.last_activity = Instant::now(); + let tx = s.rx_tx.clone(); + let mut out = Vec::new(); + if s.rx_closed { + return; + } + if env.seq == s.rx_seq { + apply_rx_env(&mut s, env, &mut out); + loop { + let next_seq = s.rx_seq; + let Some(next) = s.rx_queue.remove(&next_seq) else { + break; + }; + apply_rx_env(&mut s, next, &mut out); + if s.rx_closed { + break; + } + } + } else if env.seq > s.rx_seq { + s.rx_queue.insert(env.seq, env); + } + (tx, out) + }; + + for msg in out { + let _ = tx.send(msg).await; + } +} + +fn apply_rx_env(session: &mut DriveSession, env: Envelope, out: &mut Vec) { + if env.flags & FLAG_OPEN_OK != 0 { + out.push(DriveRx::Open); + } + if !env.payload.is_empty() { + out.push(DriveRx::Data(env.payload)); + } + session.rx_seq += 1; + if env.flags & FLAG_CLOSE != 0 { + session.rx_closed = true; + session.closed = true; + out.push(DriveRx::Close); + } +} + +pub async fn run_client(config: &Config) -> Result<(), DriveError> { + let backend = init_backend(config).await?; + let client_id = if config.drive_client_id.trim().is_empty() { + random_hex(4) + } else { + config.drive_client_id.trim().to_string() + }; + let engine = DriveEngine::new(backend, true, client_id.clone(), config, None); + engine.start(); + + let port = config.socks5_port.unwrap_or(config.listen_port + 1); + let addr = format!("{}:{}", config.listen_host, port); + let listener = TcpListener::bind(&addr).await?; + tracing::warn!( + "Google Drive mode listening SOCKS5 on {} (client_id={})", + addr, + client_id + ); + tracing::warn!("HTTP proxy and UDP ASSOCIATE are not available in google_drive mode."); + + loop { + let (sock, peer) = listener.accept().await?; + let engine = engine.clone(); + tokio::spawn(async move { + if let Err(e) = handle_socks5_client(sock, engine).await { + tracing::debug!("Drive SOCKS5 client {} closed: {}", peer, e); + } + }); + } +} + +pub async fn run_server(config: &Config) -> Result<(), DriveError> { + let backend = init_backend(config).await?; + let (new_tx, mut new_rx) = mpsc::channel(1024); + let engine = DriveEngine::new(backend, false, String::new(), config, Some(new_tx)); + engine.start(); + tracing::warn!("mhrv-drive-node polling Google Drive folder for request sessions"); + + while let Some(new_session) = new_rx.recv().await { + let engine = engine.clone(); + tokio::spawn(async move { + handle_server_session(engine, new_session).await; + }); + } + Ok(()) +} + +async fn init_backend(config: &Config) -> Result, DriveError> { + let backend = Arc::new(GoogleDriveBackend::from_config(config)?); + backend.login().await?; + backend.ensure_folder(&config.drive_folder_name).await?; + tracing::info!( + "Google Drive backend ready using credentials {}", + backend.credentials_path().display() + ); + Ok(backend) +} + +async fn handle_socks5_client( + mut sock: TcpStream, + engine: Arc, +) -> std::io::Result<()> { + let mut hdr = [0u8; 2]; + sock.read_exact(&mut hdr).await?; + if hdr[0] != 0x05 { + return Ok(()); + } + let mut methods = vec![0u8; hdr[1] as usize]; + sock.read_exact(&mut methods).await?; + if !methods.contains(&0x00) { + sock.write_all(&[0x05, 0xff]).await?; + return Ok(()); + } + sock.write_all(&[0x05, 0x00]).await?; + + let mut req = [0u8; 4]; + sock.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Ok(()); + } + if req[1] != 0x01 { + write_socks5_reply(&mut sock, 0x07).await?; + return Ok(()); + } + let host = read_socks5_addr(&mut sock, req[3]).await?; + let mut port_buf = [0u8; 2]; + sock.read_exact(&mut port_buf).await?; + let port = u16::from_be_bytes(port_buf); + let target = format!("{}:{}", host, port); + + let (session_id, mut rx) = engine.add_client_session(target.clone()).await; + tracing::info!("Drive SOCKS5 CONNECT {} -> session {}", target, session_id); + + // Wait for the server to confirm the upstream connect (FLAG_OPEN_OK) + // or report a failure (FLAG_CLOSE arriving without ever seeing Open) + // before replying to the SOCKS5 client. Without this we'd return + // success for unreachable hosts and the caller would see a half-open + // socket that immediately closes. + let mut early_data: Vec> = Vec::new(); + let deadline = tokio::time::Instant::now() + CONNECT_TIMEOUT; + tokio::select! { + msg = rx.recv() => match msg { + Some(DriveRx::Open) => {} + Some(DriveRx::Data(data)) => { + // Server *should* always send Open before Data, but if + // the encoder bundles them together in one envelope, + // treat the first Data as implicit success and forward + // the bytes after we've replied to the SOCKS5 client. + early_data.push(data); + } + Some(DriveRx::Close) | None => { + // Connect failed (or session vanished). Use SOCKS5 + // REP=5 (connection refused) since we can't tell + // refused vs unreachable from here. + write_socks5_reply(&mut sock, 0x05).await?; + engine.mark_closed(&session_id).await; + return Ok(()); + } + }, + _ = tokio::time::sleep_until(deadline) => { + // SOCKS5 REP=4 (host unreachable) is the closest match for + // a connect that didn't even ack within the window. + write_socks5_reply(&mut sock, 0x04).await?; + engine.mark_closed(&session_id).await; + return Ok(()); + } + } + + write_socks5_reply(&mut sock, 0x00).await?; + if !early_data.is_empty() { + for data in &early_data { + sock.write_all(data).await?; + } + sock.flush().await?; + } + pump_client_socket(sock, engine, session_id, rx).await +} + +async fn read_socks5_addr(sock: &mut TcpStream, atyp: u8) -> std::io::Result { + match atyp { + 0x01 => { + let mut ip = [0u8; 4]; + sock.read_exact(&mut ip).await?; + Ok(std::net::Ipv4Addr::from(ip).to_string()) + } + 0x03 => { + let mut len = [0u8; 1]; + sock.read_exact(&mut len).await?; + let mut name = vec![0u8; len[0] as usize]; + sock.read_exact(&mut name).await?; + String::from_utf8(name) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad domain")) + } + 0x04 => { + let mut ip = [0u8; 16]; + sock.read_exact(&mut ip).await?; + Ok(std::net::Ipv6Addr::from(ip).to_string()) + } + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "bad SOCKS5 ATYP", + )), + } +} + +async fn write_socks5_reply(sock: &mut TcpStream, rep: u8) -> std::io::Result<()> { + sock.write_all(&[0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await?; + sock.flush().await +} + +async fn pump_client_socket( + sock: TcpStream, + engine: Arc, + session_id: String, + mut rx: mpsc::Receiver, +) -> std::io::Result<()> { + let (mut reader, mut writer) = sock.into_split(); + let up_engine = engine.clone(); + let up_sid = session_id.clone(); + let mut upstream = tokio::spawn(async move { + let mut buf = vec![0u8; 32 * 1024]; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + // If enqueue_tx returns Err the session has been closed + // (peer hung up, TX buffer overflow, ...). Stop reading + // from the local socket so we don't drop bytes on the + // floor — flush_all will already emit a FLAG_CLOSE for us. + if up_engine.enqueue_tx(&up_sid, &buf[..n]).await.is_err() { + break; + } + } + up_engine.mark_closed(&up_sid).await; + Ok::<_, std::io::Error>(()) + }); + + let down_engine = engine.clone(); + let down_sid = session_id.clone(); + let mut downstream = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + match msg { + DriveRx::Open => {} // late open-ack after handshake; harmless + DriveRx::Data(data) => { + writer.write_all(&data).await?; + writer.flush().await?; + } + DriveRx::Close => break, + } + } + down_engine.mark_closed(&down_sid).await; + Ok::<_, std::io::Error>(()) + }); + + tokio::select! { + _ = &mut upstream => {} + _ = &mut downstream => {} + } + upstream.abort(); + downstream.abort(); + engine.mark_closed(&session_id).await; + Ok(()) +} + +async fn handle_server_session(engine: Arc, new_session: DriveNewSession) { + tracing::info!( + "Drive server session {} -> {}", + new_session.id, + new_session.target_addr + ); + let stream = match tokio::time::timeout( + Duration::from_secs(10), + TcpStream::connect(&new_session.target_addr), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + tracing::debug!( + "Drive server connect {} failed: {}", + new_session.target_addr, + e + ); + // mark_closed → next flush emits FLAG_CLOSE without ever + // having sent FLAG_OPEN_OK, so the client SOCKS5 handshake + // resolves to a connection-refused reply. + engine.mark_closed(&new_session.id).await; + return; + } + Err(_) => { + tracing::debug!("Drive server connect {} timed out", new_session.target_addr); + engine.mark_closed(&new_session.id).await; + return; + } + }; + let _ = stream.set_nodelay(true); + // Connect succeeded — flag the session so the next flush carries a + // FLAG_OPEN_OK envelope back to the SOCKS5 client. + engine.mark_open_ok(&new_session.id).await; + let _ = pump_server_socket(stream, engine, new_session.id, new_session.rx).await; +} + +async fn pump_server_socket( + stream: TcpStream, + engine: Arc, + session_id: String, + mut rx: mpsc::Receiver, +) -> std::io::Result<()> { + let (mut reader, mut writer) = stream.into_split(); + let up_engine = engine.clone(); + let up_sid = session_id.clone(); + let mut upstream = tokio::spawn(async move { + let mut buf = vec![0u8; 32 * 1024]; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + if up_engine.enqueue_tx(&up_sid, &buf[..n]).await.is_err() { + break; + } + } + up_engine.mark_closed(&up_sid).await; + Ok::<_, std::io::Error>(()) + }); + + let down_engine = engine.clone(); + let down_sid = session_id.clone(); + let mut downstream = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + match msg { + DriveRx::Open => {} // server side never expects Open; ignore defensively + DriveRx::Data(data) => { + writer.write_all(&data).await?; + writer.flush().await?; + } + DriveRx::Close => break, + } + } + down_engine.mark_closed(&down_sid).await; + Ok::<_, std::io::Error>(()) + }); + + tokio::select! { + _ = &mut upstream => {} + _ = &mut downstream => {} + } + upstream.abort(); + downstream.abort(); + engine.mark_closed(&session_id).await; + Ok(()) +} + +fn read_u8(buf: &[u8], pos: &mut usize) -> Result { + if *pos >= buf.len() { + return Err(DriveError::BadResponse("truncated Drive envelope".into())); + } + let v = buf[*pos]; + *pos += 1; + Ok(v) +} + +fn read_u32(buf: &[u8], pos: &mut usize) -> Result { + if buf.len().saturating_sub(*pos) < 4 { + return Err(DriveError::BadResponse( + "truncated Drive envelope u32".into(), + )); + } + let mut tmp = [0u8; 4]; + tmp.copy_from_slice(&buf[*pos..*pos + 4]); + *pos += 4; + Ok(u32::from_be_bytes(tmp)) +} + +fn read_u64(buf: &[u8], pos: &mut usize) -> Result { + if buf.len().saturating_sub(*pos) < 8 { + return Err(DriveError::BadResponse( + "truncated Drive envelope u64".into(), + )); + } + let mut tmp = [0u8; 8]; + tmp.copy_from_slice(&buf[*pos..*pos + 8]); + *pos += 8; + Ok(u64::from_be_bytes(tmp)) +} + +fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> Result { + if buf.len().saturating_sub(*pos) < len { + return Err(DriveError::BadResponse( + "truncated Drive envelope string".into(), + )); + } + let s = std::str::from_utf8(&buf[*pos..*pos + len]) + .map_err(|_| DriveError::BadResponse("non-utf8 Drive envelope string".into()))? + .to_string(); + *pos += len; + Ok(s) +} + +fn random_hex(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + let mut out = String::with_capacity(bytes * 2); + for b in buf { + out.push_str(&format!("{:02x}", b)); + } + out +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +fn timestamp_from_filename(filename: &str) -> Option { + let tail = filename.rsplit_once("-mux-")?.1; + tail.strip_suffix(".bin")?.parse::().ok() +} + +/// Extract the embedded client_id from either a `req--mux-…` or +/// `res--mux-…` filename. Used on the server side (for `req-`) to +/// learn which client a session belongs to so the response is +/// addressed back to the same client; clients only ever read files +/// already filtered to their own id by the listing prefix. +fn client_id_from_filename(filename: &str) -> Option { + let rest = filename + .strip_prefix("req-") + .or_else(|| filename.strip_prefix("res-"))?; + let (client_id, _) = rest.split_once("-mux-")?; + Some(client_id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_round_trips_close_and_open() { + let env = Envelope { + session_id: "abc".into(), + seq: 42, + target_addr: "example.com:443".into(), + payload: b"hello".to_vec(), + flags: FLAG_CLOSE | FLAG_OPEN_OK, + }; + let mut buf = Vec::new(); + env.encode(&mut buf).unwrap(); + // Magic + version are on the wire so a future format change + // can be detected at decode time instead of corrupting state. + assert_eq!(buf[0], MAGIC_BYTE); + assert_eq!(buf[1], ENVELOPE_VERSION); + let mut pos = 0; + let got = Envelope::decode_one(&buf, &mut pos).unwrap().unwrap(); + assert_eq!(got.session_id, "abc"); + assert_eq!(got.seq, 42); + assert_eq!(got.target_addr, "example.com:443"); + assert_eq!(&got.payload, b"hello"); + assert_eq!(got.flags, FLAG_CLOSE | FLAG_OPEN_OK); + assert!(Envelope::decode_one(&buf, &mut pos).unwrap().is_none()); + } + + #[test] + fn envelope_decode_rejects_wrong_version() { + let env = Envelope { + session_id: "x".into(), + seq: 0, + target_addr: String::new(), + payload: Vec::new(), + flags: 0, + }; + let mut buf = Vec::new(); + env.encode(&mut buf).unwrap(); + buf[1] = 0xff; + let mut pos = 0; + assert!(Envelope::decode_one(&buf, &mut pos).is_err()); + } + + #[tokio::test] + async fn rx_queue_reorders_out_of_order_envelopes() { + let (session, mut rx) = + DriveSession::new("sid".into(), "target:443".into(), "cid".into()); + let make_env = |seq: u64, payload: &[u8], flags: u8| Envelope { + session_id: "sid".into(), + seq, + target_addr: String::new(), + payload: payload.to_vec(), + flags, + }; + // Apply seq=2 first, then seq=0, then seq=1; expect everything + // to surface in seq order with a final Close. + process_rx(session.clone(), make_env(2, b"world", FLAG_CLOSE)).await; + process_rx(session.clone(), make_env(0, b"hel", 0)).await; + process_rx(session.clone(), make_env(1, b"lo ", 0)).await; + + let mut payloads: Vec> = Vec::new(); + let mut saw_close = false; + while let Ok(msg) = tokio::time::timeout(Duration::from_millis(50), rx.recv()).await { + match msg { + Some(DriveRx::Data(d)) => payloads.push(d), + Some(DriveRx::Close) => { + saw_close = true; + break; + } + Some(DriveRx::Open) => {} + None => break, + } + } + assert_eq!( + payloads, + vec![b"hel".to_vec(), b"lo ".to_vec(), b"world".to_vec()] + ); + assert!(saw_close, "expected DriveRx::Close to surface after seq=2"); + } + + + #[test] + fn filename_helpers_parse_client_and_timestamp() { + let req = "req-client-a-mux-12345.bin"; + assert_eq!(client_id_from_filename(req).as_deref(), Some("client-a")); + assert_eq!(timestamp_from_filename(req), Some(12345)); + + let res = "res-client-b-mux-67890.bin"; + assert_eq!(client_id_from_filename(res).as_deref(), Some("client-b")); + assert_eq!(timestamp_from_filename(res), Some(67890)); + + assert!(client_id_from_filename("garbage.txt").is_none()); + } +} diff --git a/src/google_drive.rs b/src/google_drive.rs new file mode 100644 index 0000000..1c33b89 --- /dev/null +++ b/src/google_drive.rs @@ -0,0 +1,1054 @@ +//! Minimal Google Drive REST client for `google_drive` tunnel mode. +//! +//! This intentionally avoids a heavyweight Google SDK. It uses the same +//! domain-fronting shape as the rest of the project: TCP goes to +//! `config.google_ip:443`, TLS SNI is `config.front_domain`, and the HTTP +//! Host header is `www.googleapis.com`. + +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use rand::RngCore; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::timeout; +use tokio_rustls::client::TlsStream; +use tokio_rustls::TlsConnector; + +use crate::config::Config; + +const GOOGLE_API_HOST: &str = "www.googleapis.com"; +const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug, thiserror::Error)] +pub enum DriveError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("tls: {0}")] + Tls(#[from] rustls::Error), + #[error("invalid dns name: {0}")] + Dns(#[from] rustls::pki_types::InvalidDnsNameError), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("http {status}: {body}")] + Http { status: u16, body: String }, + #[error("bad response: {0}")] + BadResponse(String), + #[error("oauth: {0}")] + OAuth(String), +} + +#[derive(Clone)] +struct GoogleApiClient { + connect_host: String, + sni: String, + host_header: String, + tls_connector: TlsConnector, +} + +struct HttpResponse { + status: u16, + body: Vec, +} + +impl GoogleApiClient { + fn new(connect_host: String, sni: String, host_header: String, verify_ssl: bool) -> Self { + let tls_config = if verify_ssl { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + } else { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth() + }; + + Self { + connect_host, + sni, + host_header, + tls_connector: TlsConnector::from(Arc::new(tls_config)), + } + } + + async fn open(&self) -> Result, DriveError> { + let tcp = TcpStream::connect(connect_addr(&self.connect_host)).await?; + let _ = tcp.set_nodelay(true); + let server_name = ServerName::try_from(self.sni.clone())?; + Ok(self.tls_connector.connect(server_name, tcp).await?) + } + + async fn request( + &self, + method: &str, + path: &str, + headers: Vec<(String, String)>, + body: &[u8], + ) -> Result { + let mut stream = timeout(HTTP_TIMEOUT, self.open()) + .await + .map_err(|_| DriveError::BadResponse("connect timeout".into()))??; + + let mut req = format!( + "{method} {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + User-Agent: mhrv-rs-drive/{version}\r\n\ + Accept-Encoding: identity\r\n\ + Connection: close\r\n", + method = method, + path = path, + host = self.host_header, + version = env!("CARGO_PKG_VERSION"), + ); + let mut has_content_length = false; + for (k, v) in headers { + if k.eq_ignore_ascii_case("content-length") { + has_content_length = true; + } + req.push_str(&k); + req.push_str(": "); + req.push_str(&v); + req.push_str("\r\n"); + } + if !has_content_length && (method == "POST" || method == "PATCH" || method == "PUT") { + req.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + req.push_str("\r\n"); + + stream.write_all(req.as_bytes()).await?; + if !body.is_empty() { + stream.write_all(body).await?; + } + stream.flush().await?; + + timeout(HTTP_TIMEOUT, read_http_response(&mut stream)) + .await + .map_err(|_| DriveError::BadResponse("response timeout".into()))? + } +} + +pub struct GoogleDriveBackend { + api: GoogleApiClient, + credentials_path: PathBuf, + token_path: PathBuf, + client_id: String, + client_secret: String, + auth_uri: String, + redirect_uri: String, + folder_id: Mutex>, + token: Mutex>, + /// Single-flight guard so concurrent token() callers don't all + /// stampede the OAuth refresh endpoint after expiry. + refresh_guard: Mutex<()>, + file_ids: Mutex>, +} + +#[derive(Clone)] +struct TokenState { + access_token: String, + refresh_token: String, + expires_at: Instant, +} + +#[derive(Deserialize)] +struct OAuthFile { + installed: Option, + web: Option, +} + +#[derive(Deserialize)] +struct OAuthClient { + client_id: String, + client_secret: String, + auth_uri: String, + #[serde(default)] + redirect_uris: Vec, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +#[derive(Serialize, Deserialize)] +struct TokenCache { + refresh_token: String, +} + +#[derive(Deserialize)] +struct DriveFile { + id: String, + name: String, + /// RFC 3339 timestamp from Drive itself. We use Drive's clock for + /// staleness checks instead of the timestamp embedded in the + /// filename, otherwise clock skew between two peers writing into + /// the same shared folder can cause one side to delete the other's + /// fresh files (a "5-minute stale" file from a peer 5+ min behind + /// looks ancient on first poll). + #[serde(default, rename = "createdTime")] + created_time: Option, +} + +#[derive(Deserialize)] +struct DriveList { + #[serde(default)] + files: Vec, +} + +/// What `list_query` hands back. `created_time` is parsed best-effort +/// from Drive's RFC 3339 stamp; on parse failure it's `None` and the +/// caller treats the file as "age unknown". +#[derive(Clone, Debug)] +pub struct DriveFileMeta { + pub name: String, + pub created_time: Option, +} + +impl GoogleDriveBackend { + pub fn from_config(config: &Config) -> Result { + let credentials_path = PathBuf::from(&config.drive_credentials_path); + let token_path = config + .drive_token_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(format!("{}.token", config.drive_credentials_path))); + let data = fs::read_to_string(&credentials_path)?; + let oauth: OAuthFile = serde_json::from_str(&data)?; + let client = oauth.installed.or(oauth.web).ok_or_else(|| { + DriveError::OAuth("credentials JSON has neither installed nor web client".into()) + })?; + let redirect_uri = client + .redirect_uris + .first() + .cloned() + .unwrap_or_else(|| "http://localhost".into()); + let folder_id = if config.drive_folder_id.trim().is_empty() { + None + } else { + Some(config.drive_folder_id.trim().to_string()) + }; + + Ok(Self { + api: GoogleApiClient::new( + config.google_ip.clone(), + config.front_domain.clone(), + GOOGLE_API_HOST.into(), + config.verify_ssl, + ), + credentials_path, + token_path, + client_id: client.client_id, + client_secret: client.client_secret, + auth_uri: client.auth_uri, + redirect_uri, + folder_id: Mutex::new(folder_id), + token: Mutex::new(None), + refresh_guard: Mutex::new(()), + file_ids: Mutex::new(HashMap::new()), + }) + } + + pub async fn login(&self) -> Result<(), DriveError> { + if let Ok(data) = fs::read_to_string(&self.token_path) { + if let Ok(cache) = serde_json::from_str::(&data) { + if !cache.refresh_token.is_empty() { + *self.token.lock().await = Some(TokenState { + access_token: String::new(), + refresh_token: cache.refresh_token, + expires_at: Instant::now(), + }); + return self.refresh_access_token().await; + } + } + } + + self.interactive_login().await + } + + async fn interactive_login(&self) -> Result<(), DriveError> { + let auth_url = { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("client_id", &self.client_id); + ser.append_pair("redirect_uri", &self.redirect_uri); + ser.append_pair("response_type", "code"); + ser.append_pair("scope", DRIVE_SCOPE); + ser.append_pair("access_type", "offline"); + ser.append_pair("prompt", "consent"); + format!("{}?{}", self.auth_uri, ser.finish()) + }; + + println!(); + println!("==================== GOOGLE DRIVE OAUTH REQUIRED ===================="); + println!("1. Open this URL in your browser:\n"); + println!("{}", auth_url); + println!("\n2. Approve access, then paste the full redirected URL or just the code."); + print!("\nEnter URL or code: "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let input = input.trim(); + let code = if input.starts_with("http://") || input.starts_with("https://") { + let parsed = url::Url::parse(input) + .map_err(|e| DriveError::OAuth(format!("bad redirect URL: {}", e)))?; + parsed + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| DriveError::OAuth("redirect URL did not contain a code".into()))? + } else { + input.to_string() + }; + + if code.is_empty() { + return Err(DriveError::OAuth("empty authorization code".into())); + } + + self.exchange_code(&code).await?; + let refresh_token = self + .token + .lock() + .await + .as_ref() + .map(|t| t.refresh_token.clone()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| DriveError::OAuth("Google did not return a refresh token".into()))?; + let cache = serde_json::to_vec_pretty(&TokenCache { refresh_token })?; + write_secret_file(&self.token_path, &cache)?; + println!("Saved Drive OAuth token to {}", self.token_path.display()); + println!("====================================================================="); + println!(); + Ok(()) + } + + async fn exchange_code(&self, code: &str) -> Result<(), DriveError> { + let body = { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("grant_type", "authorization_code"); + ser.append_pair("code", code); + ser.append_pair("client_id", &self.client_id); + ser.append_pair("client_secret", &self.client_secret); + ser.append_pair("redirect_uri", &self.redirect_uri); + ser.finish().into_bytes() + }; + let response = self.execute_token_request(body).await?; + self.apply_token_response(response, None).await; + Ok(()) + } + + async fn refresh_access_token(&self) -> Result<(), DriveError> { + let refresh_token = self + .token + .lock() + .await + .as_ref() + .map(|t| t.refresh_token.clone()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| DriveError::OAuth("no refresh token cached".into()))?; + + let body = { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("grant_type", "refresh_token"); + ser.append_pair("refresh_token", &refresh_token); + ser.append_pair("client_id", &self.client_id); + ser.append_pair("client_secret", &self.client_secret); + ser.finish().into_bytes() + }; + let response = self.execute_token_request(body).await?; + self.apply_token_response(response, Some(refresh_token)) + .await; + Ok(()) + } + + async fn execute_token_request(&self, body: Vec) -> Result { + let resp = self + .api + .request( + "POST", + "/oauth2/v4/token", + vec![( + "Content-Type".into(), + "application/x-www-form-urlencoded".into(), + )], + &body, + ) + .await?; + if resp.status != 200 { + return Err(http_error(resp)); + } + Ok(serde_json::from_slice(&resp.body)?) + } + + async fn apply_token_response( + &self, + response: TokenResponse, + fallback_refresh: Option, + ) { + let refresh_token = response + .refresh_token + .or(fallback_refresh) + .unwrap_or_default(); + // Use the token for at most `expires_in - 60s` so we refresh + // ahead of expiry. If the server returned an unusually short + // lifetime (or none at all), fall back to a small floor rather + // than over-claiming validity. + let raw = response.expires_in.unwrap_or(3600); + let expires = if raw > 90 { raw - 60 } else { raw / 2 + 1 }; + *self.token.lock().await = Some(TokenState { + access_token: response.access_token, + refresh_token, + expires_at: Instant::now() + Duration::from_secs(expires), + }); + } + + async fn token(&self) -> Result { + if let Some(tok) = self.live_token().await { + return Ok(tok); + } + // Single-flight: if another caller is already refreshing, wait + // for it to finish and use the freshly-set token instead of + // hitting /oauth2/v4/token a second time. + let _refresh = self.refresh_guard.lock().await; + if let Some(tok) = self.live_token().await { + return Ok(tok); + } + self.refresh_access_token().await?; + self.live_token() + .await + .ok_or_else(|| DriveError::OAuth("token refresh returned no access token".into())) + } + + async fn live_token(&self) -> Option { + let guard = self.token.lock().await; + let token = guard.as_ref()?; + if token.access_token.is_empty() || Instant::now() >= token.expires_at { + return None; + } + Some(token.access_token.clone()) + } + + pub async fn ensure_folder(&self, name: &str) -> Result { + if let Some(id) = self.folder_id.lock().await.clone() { + return Ok(id); + } + if let Some(id) = self.find_folder(name).await? { + *self.folder_id.lock().await = Some(id.clone()); + tracing::info!("Drive folder '{}' found: {}", name, id); + return Ok(id); + } + let id = self.create_folder(name).await?; + *self.folder_id.lock().await = Some(id.clone()); + tracing::info!("Drive folder '{}' created: {}", name, id); + Ok(id) + } + + pub async fn upload(&self, filename: &str, data: Vec) -> Result<(), DriveError> { + let token = self.token().await?; + let folder_id = self.folder_id.lock().await.clone(); + let boundary = format!("mhrv{}", random_hex(12)); + let meta = if let Some(folder_id) = folder_id { + json!({ "name": filename, "parents": [folder_id] }) + } else { + json!({ "name": filename }) + }; + + let mut body = Vec::with_capacity(data.len() + 512); + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.extend_from_slice(serde_json::to_string(&meta)?.as_bytes()); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); + body.extend_from_slice(&data); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + let resp = self + .api + .request( + "POST", + "/upload/drive/v3/files?uploadType=multipart", + vec![ + ("Authorization".into(), format!("Bearer {}", token)), + ( + "Content-Type".into(), + format!("multipart/related; boundary={}", boundary), + ), + ], + &body, + ) + .await?; + if resp.status != 200 && resp.status != 201 { + return Err(http_error(resp)); + } + Ok(()) + } + + pub async fn list_query(&self, prefix: &str) -> Result, DriveError> { + let token = self.token().await?; + let mut q = format!( + "name contains '{}' and trashed = false", + drive_query_quote(prefix) + ); + if let Some(folder_id) = self.folder_id.lock().await.clone() { + q.push_str(&format!( + " and '{}' in parents", + drive_query_quote(&folder_id) + )); + } + let path = { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("q", &q); + ser.append_pair("fields", "files(id,name,createdTime)"); + ser.append_pair("pageSize", "1000"); + format!("/drive/v3/files?{}", ser.finish()) + }; + + let resp = self + .api + .request( + "GET", + &path, + vec![("Authorization".into(), format!("Bearer {}", token))], + &[], + ) + .await?; + if resp.status != 200 { + return Err(http_error(resp)); + } + + let parsed: DriveList = serde_json::from_slice(&resp.body)?; + let mut metas = Vec::new(); + let mut ids = self.file_ids.lock().await; + if ids.len() > 2000 { + ids.clear(); + } + for file in parsed.files { + if file.name.starts_with(prefix) { + ids.insert(file.name.clone(), file.id); + let created_time = file.created_time.as_deref().and_then(parse_rfc3339); + metas.push(DriveFileMeta { + name: file.name, + created_time, + }); + } + } + Ok(metas) + } + + pub async fn download(&self, filename: &str) -> Result, DriveError> { + let file_id = match self.file_ids.lock().await.get(filename).cloned() { + Some(id) => id, + None => { + let _ = self.list_query(filename).await?; + self.file_ids + .lock() + .await + .get(filename) + .cloned() + .ok_or_else(|| { + DriveError::BadResponse(format!("Drive file id not found for {}", filename)) + })? + } + }; + let token = self.token().await?; + let path = format!("/drive/v3/files/{}?alt=media", url_path_escape(&file_id)); + let resp = self + .api + .request( + "GET", + &path, + vec![("Authorization".into(), format!("Bearer {}", token))], + &[], + ) + .await?; + if resp.status != 200 { + return Err(http_error(resp)); + } + Ok(resp.body) + } + + pub async fn delete(&self, filename: &str) -> Result<(), DriveError> { + let Some(file_id) = self.file_ids.lock().await.get(filename).cloned() else { + return Ok(()); + }; + let token = self.token().await?; + let path = format!("/drive/v3/files/{}", url_path_escape(&file_id)); + let resp = self + .api + .request( + "DELETE", + &path, + vec![("Authorization".into(), format!("Bearer {}", token))], + &[], + ) + .await?; + if resp.status != 204 && resp.status != 200 && resp.status != 404 { + return Err(http_error(resp)); + } + self.file_ids.lock().await.remove(filename); + Ok(()) + } + + async fn create_folder(&self, name: &str) -> Result { + let token = self.token().await?; + let body = serde_json::to_vec(&json!({ + "name": name, + "mimeType": "application/vnd.google-apps.folder", + }))?; + let resp = self + .api + .request( + "POST", + "/drive/v3/files", + vec![ + ("Authorization".into(), format!("Bearer {}", token)), + ("Content-Type".into(), "application/json".into()), + ], + &body, + ) + .await?; + if resp.status != 200 && resp.status != 201 { + return Err(http_error(resp)); + } + #[derive(Deserialize)] + struct Created { + id: String, + } + Ok(serde_json::from_slice::(&resp.body)?.id) + } + + async fn find_folder(&self, name: &str) -> Result, DriveError> { + let token = self.token().await?; + let q = format!( + "name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", + drive_query_quote(name) + ); + let path = { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("q", &q); + ser.append_pair("fields", "files(id,name)"); + ser.append_pair("pageSize", "10"); + format!("/drive/v3/files?{}", ser.finish()) + }; + let resp = self + .api + .request( + "GET", + &path, + vec![("Authorization".into(), format!("Bearer {}", token))], + &[], + ) + .await?; + if resp.status != 200 { + return Err(http_error(resp)); + } + let parsed: DriveList = serde_json::from_slice(&resp.body)?; + Ok(parsed.files.into_iter().next().map(|f| f.id)) + } + + pub fn credentials_path(&self) -> &PathBuf { + &self.credentials_path + } +} + +fn http_error(resp: HttpResponse) -> DriveError { + DriveError::Http { + status: resp.status, + body: String::from_utf8_lossy(&resp.body) + .chars() + .take(500) + .collect(), + } +} + +fn random_hex(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + let mut out = String::with_capacity(bytes * 2); + for b in buf { + out.push_str(&format!("{:02x}", b)); + } + out +} + +fn connect_addr(host: &str) -> String { + if host + .rsplit_once(':') + .and_then(|(_, p)| p.parse::().ok()) + .is_some() + { + host.to_string() + } else if host.contains(':') && !host.starts_with('[') { + format!("[{}]:443", host) + } else { + format!("{}:443", host) + } +} + +fn drive_query_quote(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} + +/// Parse a Drive `createdTime` (RFC 3339, always UTC `Z`) into a +/// `SystemTime`. Returns `None` for any oddity rather than panicking; +/// the caller treats unparseable timestamps as "age unknown" and skips +/// the staleness check rather than risking a wrong delete. +fn parse_rfc3339(s: &str) -> Option { + // Expected: 2024-05-13T07:21:34.512Z (fractional seconds optional). + let bytes = s.as_bytes(); + if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' { + return None; + } + if !s.ends_with('Z') { + return None; + } + let year: i64 = s.get(..4)?.parse().ok()?; + let month: i64 = s.get(5..7)?.parse().ok()?; + let day: i64 = s.get(8..10)?.parse().ok()?; + let hour: i64 = s.get(11..13)?.parse().ok()?; + let minute: i64 = s.get(14..16)?.parse().ok()?; + let second: i64 = s.get(17..19)?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + if !(0..24).contains(&hour) || !(0..60).contains(&minute) || !(0..=60).contains(&second) { + return None; + } + // Howard Hinnant's days_from_civil. Treats March as month 1, so + // Jan/Feb roll back into the previous year. Valid for any year. + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let m_index = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * m_index + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + let secs = days * 86400 + hour * 3600 + minute * 60 + second; + if secs < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(secs as u64)) +} + +/// Percent-encode for use inside a URL path component. `form_urlencoded` +/// would map space to `+` (which is wrong inside a path), so we hand-roll +/// per RFC 3986: keep unreserved chars, percent-encode the rest. Drive +/// file IDs only contain `[A-Za-z0-9_-]` in practice but a stricter +/// encoder costs nothing and keeps us safe if Google ever widens that. +fn url_path_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for &b in value.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{:02X}", b)); + } + } + out +} + +/// Write data and try to set 0600 on Unix so the OAuth refresh token isn't +/// world-readable. Best-effort: a permission failure after the write is +/// logged but doesn't fail the call. +fn write_secret_file(path: &PathBuf, data: &[u8]) -> std::io::Result<()> { + fs::write(path, data)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + if let Err(e) = fs::set_permissions(path, perms) { + tracing::warn!( + "could not chmod 0600 on {}: {} (refresh token may be world-readable)", + path.display(), + e + ); + } + } + } + Ok(()) +} + +async fn read_http_response(stream: &mut S) -> Result +where + S: AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(8192); + let mut tmp = [0u8; 8192]; + let header_end = loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(DriveError::BadResponse( + "connection closed before headers".into(), + )); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_double_crlf(&buf) { + break pos; + } + if buf.len() > 1024 * 1024 { + return Err(DriveError::BadResponse("headers too large".into())); + } + }; + + let header_text = std::str::from_utf8(&buf[..header_end]) + .map_err(|_| DriveError::BadResponse("non-utf8 headers".into()))?; + let mut lines = header_text.split("\r\n"); + let status = parse_status_line(lines.next().unwrap_or(""))?; + let mut headers = Vec::new(); + for line in lines { + if let Some((k, v)) = line.split_once(':') { + headers.push((k.trim().to_string(), v.trim().to_string())); + } + } + + let mut body = buf[header_end + 4..].to_vec(); + let content_length = header_get(&headers, "content-length").and_then(|v| v.parse().ok()); + let is_chunked = header_get(&headers, "transfer-encoding") + .map(|v| v.to_ascii_lowercase().contains("chunked")) + .unwrap_or(false); + + if is_chunked { + body = read_chunked(stream, body).await?; + } else if let Some(len) = content_length { + while body.len() < len { + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(DriveError::BadResponse( + "connection closed before full body".into(), + )); + } + body.extend_from_slice(&tmp[..n]); + } + body.truncate(len); + } else { + loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + break; + } + body.extend_from_slice(&tmp[..n]); + } + } + + if header_get(&headers, "content-encoding") + .map(|v| v.eq_ignore_ascii_case("gzip")) + .unwrap_or(false) + { + body = decode_gzip(&body)?; + } + + Ok(HttpResponse { status, body }) +} + +async fn read_chunked(stream: &mut S, mut buf: Vec) -> Result, DriveError> +where + S: AsyncRead + Unpin, +{ + let mut out = Vec::new(); + let mut tmp = [0u8; 8192]; + loop { + let line = read_crlf_line(stream, &mut buf, &mut tmp).await?; + let line = std::str::from_utf8(&line) + .map_err(|_| DriveError::BadResponse("bad chunk header".into()))? + .trim(); + if line.is_empty() { + continue; + } + let size = usize::from_str_radix(line.split(';').next().unwrap_or(""), 16) + .map_err(|_| DriveError::BadResponse(format!("bad chunk size '{}'", line)))?; + if size == 0 { + loop { + if read_crlf_line(stream, &mut buf, &mut tmp).await?.is_empty() { + return Ok(out); + } + } + } + while buf.len() < size + 2 { + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(DriveError::BadResponse( + "connection closed mid-chunk".into(), + )); + } + buf.extend_from_slice(&tmp[..n]); + } + if &buf[size..size + 2] != b"\r\n" { + return Err(DriveError::BadResponse( + "chunk missing trailing CRLF".into(), + )); + } + out.extend_from_slice(&buf[..size]); + buf.drain(..size + 2); + } +} + +async fn read_crlf_line( + stream: &mut S, + buf: &mut Vec, + tmp: &mut [u8], +) -> Result, DriveError> +where + S: AsyncRead + Unpin, +{ + loop { + if let Some(pos) = buf.windows(2).position(|w| w == b"\r\n") { + let line = buf[..pos].to_vec(); + buf.drain(..pos + 2); + return Ok(line); + } + let n = stream.read(tmp).await?; + if n == 0 { + return Err(DriveError::BadResponse("connection closed mid-line".into())); + } + buf.extend_from_slice(&tmp[..n]); + } +} + +fn header_get(headers: &[(String, String)], name: &str) -> Option { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn parse_status_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + let _version = parts.next(); + let code = parts + .next() + .ok_or_else(|| DriveError::BadResponse(format!("bad status line: {}", line)))?; + code.parse::() + .map_err(|_| DriveError::BadResponse(format!("bad status code: {}", code))) +} + +fn decode_gzip(data: &[u8]) -> Result, std::io::Error> { + let mut out = Vec::with_capacity(data.len() * 2); + flate2::read::GzDecoder::new(data).read_to_end(&mut out)?; + Ok(out) +} + +#[derive(Debug)] +struct NoVerify; + +impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + + #[test] + fn url_path_escape_keeps_unreserved_and_encodes_specials() { + assert_eq!(url_path_escape("AbC-_.~123"), "AbC-_.~123"); + assert_eq!(url_path_escape("hello world"), "hello%20world"); + assert_eq!(url_path_escape("a+b/c?d"), "a%2Bb%2Fc%3Fd"); + } + + #[test] + fn parse_rfc3339_handles_drive_timestamps() { + // Drive returns timestamps with millisecond fractional precision + // and always-Z UTC offset; our parser ignores the fraction and + // produces a SystemTime relative to the unix epoch. + let ts = parse_rfc3339("2024-05-13T07:21:34.512Z").unwrap(); + let secs = ts.duration_since(UNIX_EPOCH).unwrap().as_secs(); + // 2024-05-13T07:21:34Z = 1715584894s since the epoch. + assert_eq!(secs, 1715584894); + // Sub-second component is intentionally truncated. + let ts_no_frac = parse_rfc3339("2024-05-13T07:21:34Z").unwrap(); + assert_eq!(ts, ts_no_frac); + // Junk and non-Z offsets are rejected (we don't risk getting + // tz math wrong for a stale-file decision). + assert!(parse_rfc3339("not a date").is_none()); + assert!(parse_rfc3339("2024-05-13T07:21:34+02:00").is_none()); + } + + #[tokio::test] + async fn read_http_response_decodes_chunked_body() { + let (mut w, mut r) = tokio::io::duplex(4096); + let body = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"; + w.write_all(body).await.unwrap(); + drop(w); + let resp = read_http_response(&mut r).await.unwrap(); + assert_eq!(resp.status, 200); + assert_eq!(&resp.body, b"hello world"); + } + + #[tokio::test] + async fn read_http_response_decodes_content_length_body() { + let (mut w, mut r) = tokio::io::duplex(4096); + let body = b"HTTP/1.1 201 Created\r\nContent-Length: 4\r\n\r\nbody"; + w.write_all(body).await.unwrap(); + drop(w); + let resp = read_http_response(&mut r).await.unwrap(); + assert_eq!(resp.status, 201); + assert_eq!(&resp.body, b"body"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1c62a5b..a2fdd0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,13 +5,15 @@ pub mod cert_installer; pub mod config; pub mod data_dir; pub mod domain_fronter; +pub mod drive_tunnel; +pub mod google_drive; pub mod mitm; pub mod proxy_server; pub mod rlimit; -pub mod tunnel_client; pub mod scan_ips; pub mod scan_sni; pub mod test_cmd; +pub mod tunnel_client; pub mod update_check; #[cfg(target_os = "android")] diff --git a/src/main.rs b/src/main.rs index fe33d16..6732478 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ enum Command { fn print_help() { println!( - "mhrv-rs {} — Rust port of MasterHttpRelayVPN (apps_script mode only) + "mhrv-rs {} — Rust port of MasterHttpRelayVPN USAGE: mhrv-rs [OPTIONS] Start the proxy server (default) @@ -312,9 +312,36 @@ async fn main() -> ExitCode { } tracing::warn!( "Full tunnel mode: NO certificate installation needed. \ - ALL traffic is tunneled end-to-end through Apps Script + tunnel node." + ALL traffic is tunneled end-to-end through Apps Script + tunnel node." ); } + mhrv_rs::config::Mode::GoogleDrive => { + tracing::info!( + "Google Drive tunnel: SNI={} -> www.googleapis.com (via {})", + config.front_domain, + config.google_ip + ); + tracing::warn!( + "Google Drive mode is SOCKS5-only and needs `mhrv-drive-node` \ + running with the same Drive folder." + ); + } + } + + if mode == mhrv_rs::config::Mode::GoogleDrive { + let run = mhrv_rs::drive_tunnel::run_client(&config); + tokio::select! { + r = run => { + if let Err(e) = r { + eprintln!("google_drive client error: {}", e); + return ExitCode::FAILURE; + } + } + _ = tokio::signal::ctrl_c() => { + tracing::warn!("Ctrl+C — shutting down google_drive client."); + } + } + return ExitCode::SUCCESS; } // Initialize MITM manager (generates CA on first run). diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 73210b5..18447c9 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -235,6 +235,8 @@ impl ProxyServer { // `google_only` mode skips the Apps Script relay entirely, so we must // not try to construct the DomainFronter — it errors on a missing // `script_id`, which is exactly the state a bootstrapping user is in. + // `google_drive` never reaches this constructor (main.rs short-circuits + // before ProxyServer::new), but the arm has to typecheck. let fronter = match mode { Mode::AppsScript | Mode::Full => { let f = DomainFronter::new(config) @@ -242,6 +244,9 @@ impl ProxyServer { Some(Arc::new(f)) } Mode::GoogleOnly => None, + Mode::GoogleDrive => unreachable!( + "ProxyServer::new called in google_drive mode; main.rs is supposed to dispatch to drive_tunnel::run_client first" + ), }; let tls_config = if config.verify_ssl { @@ -1338,10 +1343,13 @@ async fn dispatch_tunnel( // isn't SNI-rewrite-matched gets direct TCP passthrough so the user's // browser still works while they're deploying Code.gs. They'd switch // to apps_script mode for the real DPI bypass. + // google_drive doesn't run through dispatch_tunnel at all (main.rs + // routes those connections to drive_tunnel::run_client), so we don't + // need an arm for it here. if rewrite_ctx.mode == Mode::GoogleOnly { let via = rewrite_ctx.upstream_socks5.as_deref(); tracing::info!( - "dispatch {}:{} -> raw-tcp ({}) (google_only: no relay)", + "dispatch {}:{} -> raw-tcp ({}) (google_only: no Apps Script relay)", host, port, via.unwrap_or("direct") diff --git a/src/test_cmd.rs b/src/test_cmd.rs index a9007a8..d1beff3 100644 --- a/src/test_cmd.rs +++ b/src/test_cmd.rs @@ -20,10 +20,11 @@ use crate::domain_fronter::DomainFronter; const TEST_URL: &str = "https://api.ipify.org/?format=json"; pub async fn run(config: &Config) -> bool { - if matches!(config.mode_kind(), Ok(Mode::GoogleOnly)) { + if matches!(config.mode_kind(), Ok(Mode::GoogleOnly | Mode::GoogleDrive)) { let msg = "`mhrv-rs test` probes the Apps Script relay, which isn't \ - wired up in google_only mode. Run `mhrv-rs test-sni` to \ - check the direct SNI-rewrite tunnel instead."; + wired up in this mode. Run `mhrv-rs test-sni` to check \ + Google-edge reachability; google_drive mode is exercised \ + by starting both `mhrv-rs` and `mhrv-drive-node`."; println!("{}", msg); tracing::error!("{}", msg); return false; From b525948779e840d2123b3aa7394dd086ecad736f Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:31:27 +0400 Subject: [PATCH 02/11] feat(ui): add Google Drive mode to desktop and Android UIs --- .../java/com/therealaleph/mhrv/ConfigStore.kt | 70 ++- .../com/therealaleph/mhrv/MhrvVpnService.kt | 32 +- .../main/java/com/therealaleph/mhrv/Native.kt | 35 ++ .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 1 + .../com/therealaleph/mhrv/ui/HomeScreen.kt | 359 +++++++++++ .../app/src/main/res/values-fa/strings.xml | 29 + android/app/src/main/res/values/strings.xml | 29 + src/android_jni.rs | 218 ++++++- src/bin/drive_node.rs | 2 + src/bin/ui.rs | 584 +++++++++++++++++- src/drive_tunnel.rs | 38 +- src/google_drive.rs | 125 ++-- src/main.rs | 4 +- 13 files changed, 1462 insertions(+), 64 deletions(-) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 2666679..c48c731 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -70,8 +70,11 @@ enum class UiLang { AUTO, FA, EN } * Non-Google traffic goes direct (no relay). * - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through * Apps Script + a remote tunnel node. No certificate installation needed. + * - [GOOGLE_DRIVE] — FlowDriver-style queue. SOCKS5 multiplexed through + * a shared Google Drive folder; needs `mhrv-drive-node` running on a + * remote host pointed at the same folder. No Apps Script involved. */ -enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL } +enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL, GOOGLE_DRIVE } data class MhrvConfig( val mode: Mode = Mode.APPS_SCRIPT, @@ -114,6 +117,26 @@ data class MhrvConfig( /** UI language toggle. Non-Rust; honoured only by the Android wrapper. */ val uiLang: UiLang = UiLang.AUTO, + + // ── google_drive mode ────────────────────────────────────────────── + /** + * Path to the Google Cloud OAuth desktop credentials JSON. On Android + * this is the absolute path inside the app's filesDir where the user + * imported their downloaded `credentials.json`. Empty on a fresh install. + */ + val driveCredentialsPath: String = "", + /** Pinned Drive folder ID, or empty to look up by [driveFolderName]. */ + val driveFolderId: String = "", + val driveFolderName: String = "MHRV-Drive", + /** + * Stable per-device client_id embedded in Drive filenames. Empty = + * Rust generates a random short id at start. Validated server-side + * (<=32 chars, ASCII alphanumeric / dash / underscore). + */ + val driveClientId: String = "", + val drivePollMs: Int = 500, + val driveFlushMs: Int = 300, + val driveIdleTimeoutSecs: Int = 300, ) { /** * Extract just the deployment ID from either a full @@ -160,6 +183,7 @@ data class MhrvConfig( Mode.APPS_SCRIPT -> "apps_script" Mode.GOOGLE_ONLY -> "google_only" Mode.FULL -> "full" + Mode.GOOGLE_DRIVE -> "google_drive" }) put("listen_host", listenHost) put("listen_port", listenPort) @@ -214,10 +238,36 @@ data class MhrvConfig( UiLang.FA -> "fa" UiLang.EN -> "en" }) + + // google_drive: only emit when the user has actually set a + // credentials path. Otherwise the file would gain stub keys + // (poll/flush/idle defaults) for users who don't run drive + // mode, which makes diffs noisier. + if (mode == Mode.GOOGLE_DRIVE || driveCredentialsPath.isNotBlank()) { + if (driveCredentialsPath.isNotBlank()) { + put("drive_credentials_path", driveCredentialsPath) + } + if (driveFolderId.isNotBlank()) put("drive_folder_id", driveFolderId) + if (driveFolderName.isNotBlank()) put("drive_folder_name", driveFolderName) + if (driveClientId.isNotBlank()) put("drive_client_id", driveClientId) + put("drive_poll_ms", drivePollMs) + put("drive_flush_ms", driveFlushMs) + put("drive_idle_timeout_secs", driveIdleTimeoutSecs) + } } return obj.toString(2) } + /** + * Whether the Drive mode has enough configured to attempt a Start. + * Mirrors the Rust-side validate() rules: needs a credentials path + * and an OAuth refresh token cached for those credentials. The + * token check is best-effort — `Native.driveTokenPresent` reads the + * file on disk, so a true here doesn't guarantee Google will accept + * the refresh on next call. + */ + val driveConfigured: Boolean get() = driveCredentialsPath.isNotBlank() + /** Convenience: is there at least one usable deployment ID? */ val hasDeploymentId: Boolean get() = appsScriptUrls.any { extractId(it).isNotEmpty() } @@ -255,6 +305,7 @@ object ConfigStore { Mode.APPS_SCRIPT -> "apps_script" Mode.GOOGLE_ONLY -> "google_only" Mode.FULL -> "full" + Mode.GOOGLE_DRIVE -> "google_drive" }) val ids = cfg.appsScriptUrls.mapNotNull { url -> val marker = "/macros/s/" @@ -277,6 +328,15 @@ object ConfigStore { if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay) if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5) if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } }) + // google_drive — share the knobs but never the credentials path + // or refresh token; those are device-local. + if (cfg.mode == Mode.GOOGLE_DRIVE) { + if (cfg.driveFolderId.isNotBlank()) obj.put("drive_folder_id", cfg.driveFolderId) + if (cfg.driveFolderName != defaults.driveFolderName) obj.put("drive_folder_name", cfg.driveFolderName) + if (cfg.drivePollMs != defaults.drivePollMs) obj.put("drive_poll_ms", cfg.drivePollMs) + if (cfg.driveFlushMs != defaults.driveFlushMs) obj.put("drive_flush_ms", cfg.driveFlushMs) + if (cfg.driveIdleTimeoutSecs != defaults.driveIdleTimeoutSecs) obj.put("drive_idle_timeout_secs", cfg.driveIdleTimeoutSecs) + } // Compress with DEFLATE then base64. val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8) @@ -350,6 +410,7 @@ object ConfigStore { mode = when (obj.optString("mode", "apps_script")) { "google_only" -> Mode.GOOGLE_ONLY "full" -> Mode.FULL + "google_drive" -> Mode.GOOGLE_DRIVE else -> Mode.APPS_SCRIPT }, listenHost = obj.optString("listen_host", "127.0.0.1"), @@ -384,6 +445,13 @@ object ConfigStore { "en" -> UiLang.EN else -> UiLang.AUTO }, + driveCredentialsPath = obj.optString("drive_credentials_path", ""), + driveFolderId = obj.optString("drive_folder_id", ""), + driveFolderName = obj.optString("drive_folder_name", "MHRV-Drive"), + driveClientId = obj.optString("drive_client_id", ""), + drivePollMs = obj.optInt("drive_poll_ms", 500), + driveFlushMs = obj.optInt("drive_flush_ms", 300), + driveIdleTimeoutSecs = obj.optInt("drive_idle_timeout_secs", 300), ) } } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt index 58e3dbf..b3ff19f 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -105,15 +105,23 @@ class MhrvVpnService : VpnService() { // Deployment ID + auth key are required for apps_script and full // modes — both talk to Apps Script. Only google_only (bootstrap) - // runs without them. Closes #73 regression where google_only - // users hit this branch and crashed on startForeground timeout. - val needsCreds = cfg.mode != Mode.GOOGLE_ONLY - if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) { + // and google_drive (Drive queue) run without them. Closes #73 + // regression where google_only users hit this branch and crashed + // on startForeground timeout. + val needsAppsScriptCreds = + cfg.mode != Mode.GOOGLE_ONLY && cfg.mode != Mode.GOOGLE_DRIVE + if (needsAppsScriptCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) { Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}") try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {} stopSelf() return } + if (cfg.mode == Mode.GOOGLE_DRIVE && cfg.driveCredentialsPath.isBlank()) { + Log.e(TAG, "Drive mode needs drive_credentials_path; aborting") + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {} + stopSelf() + return + } // Defensive stop: if a previous startEverything left a handle behind // (e.g. the user tapped Start twice, or a Stop path errored out @@ -127,9 +135,21 @@ class MhrvVpnService : VpnService() { proxyHandle = 0L } - proxyHandle = Native.startProxy(cfg.toJson()) + // google_drive uses a separate JNI entry point because the + // ProxyServer constructor unreachable!()s for Drive mode (the + // drive client owns its own SOCKS5 listener; there's no MITM + // pipeline to wire up). Both paths share the same handle slot + // map, so stopProxy works unchanged for either. + proxyHandle = if (cfg.mode == Mode.GOOGLE_DRIVE) { + Native.startDriveProxy(cfg.toJson()) + } else { + Native.startProxy(cfg.toJson()) + } if (proxyHandle == 0L) { - Log.e(TAG, "Native.startProxy returned 0 — see logcat tag mhrv_rs") + Log.e( + TAG, + "Native.startProxy returned 0 (mode=${cfg.mode}) — see logcat tag mhrv_rs", + ) try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {} stopSelf() return diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt index 517e46d..536bb3e 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt @@ -105,4 +105,39 @@ object Native { * @return 0 on normal shutdown, negative on error. BLOCKS. */ external fun runTun2proxy(cliArgs: String, tunMtu: Int): Int + + // ── google_drive mode ──────────────────────────────────────────── + + /** + * Same shape as [startProxy] but takes the google_drive code path. + * Caller is expected to have completed OAuth via + * [driveCompleteAuth] before calling this — otherwise the backend + * returns "no refresh token cached" and we return 0. + */ + external fun startDriveProxy(configJson: String): Long + + /** + * Build the OAuth consent URL the UI sends the user to. Empty + * string on credentials-load failure (logcat has details). + * Cheap (no network). + */ + external fun driveAuthUrl(configJson: String): String + + /** + * Exchange an authorization code (or full redirect URL) for tokens + * and persist the refresh token to disk. Returns a small JSON blob: + * `{"ok":true,"tokenPath":"/data/.../credentials.json.token"}` + * `{"ok":false,"error":"..."}` + * BLOCKS on a one-shot tokio runtime — call from a background + * dispatcher. + */ + external fun driveCompleteAuth(configJson: String, code: String): String + + /** + * Whether a non-empty refresh token has been persisted for the + * credentials referenced by `configJson`. Cheap (just a file + * read) — UIs poll this every recompose to gate "Authorize" vs + * "Re-authorize". + */ + external fun driveTokenPresent(configJson: String): Boolean } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt index 50626c4..e3e2e86 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt @@ -296,6 +296,7 @@ private fun ImportConfirmDialog( com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script" com.therealaleph.mhrv.Mode.GOOGLE_ONLY -> "google_only" com.therealaleph.mhrv.Mode.FULL -> "full" + com.therealaleph.mhrv.Mode.GOOGLE_DRIVE -> "google_drive" } AlertDialog( diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index d16a66e..64a6373 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -1,6 +1,9 @@ package com.therealaleph.mhrv.ui +import android.content.Intent import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -317,6 +320,7 @@ fun HomeScreen( }, enabled = (isVpnRunning || cfg.mode == Mode.GOOGLE_ONLY || + (cfg.mode == Mode.GOOGLE_DRIVE && cfg.driveConfigured) || (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning, colors = ButtonDefaults.buttonColors( containerColor = if (isVpnRunning) ErrRed else OkGreen, @@ -369,6 +373,23 @@ fun HomeScreen( ) } + // ── Google Drive section ────────────────────────────────────── + // Only shown when the user has selected GOOGLE_DRIVE mode. + // Starts expanded if the drive isn't yet configured (no + // credentials path, or no cached refresh token). + if (cfg.mode == Mode.GOOGLE_DRIVE) { + CollapsibleSection( + title = stringResource(R.string.sec_google_drive), + initiallyExpanded = !cfg.driveConfigured, + ) { + DriveSection( + cfg = cfg, + onChange = ::persist, + onSnack = { snackbar.showSnackbar(it) }, + ) + } + } + Spacer(Modifier.height(4.dp)) SectionHeader(stringResource(R.string.sec_network)) @@ -849,10 +870,12 @@ private fun ModeDropdown( val labelApps = "Apps Script (MITM)" val labelGoogle = "Google-only (bootstrap)" val labelFull = "Full tunnel (no cert)" + val labelDrive = "Google Drive queue" val currentLabel = when (mode) { Mode.APPS_SCRIPT -> labelApps Mode.GOOGLE_ONLY -> labelGoogle Mode.FULL -> labelFull + Mode.GOOGLE_DRIVE -> labelDrive } var expanded by remember { mutableStateOf(false) } @@ -885,6 +908,10 @@ private fun ModeDropdown( text = { Text(labelFull) }, onClick = { onChange(Mode.FULL); expanded = false }, ) + DropdownMenuItem( + text = { Text(labelDrive) }, + onClick = { onChange(Mode.GOOGLE_DRIVE); expanded = false }, + ) } } @@ -895,6 +922,8 @@ private fun ModeDropdown( "Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct." Mode.FULL -> "All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed." + Mode.GOOGLE_DRIVE -> + "SOCKS5 multiplexed through a shared Google Drive folder. Needs `mhrv-drive-node` running on a remote host pointed at the same folder." } Text( help, @@ -1166,6 +1195,336 @@ private fun parseProbeResult(json: String?): ProbeState { } } +// ========================================================================= +// Google Drive section. Importer for credentials.json + OAuth dialog + +// poll/flush/idle knobs. Visible only while mode == GOOGLE_DRIVE. +// ========================================================================= + +@Composable +private fun DriveSection( + cfg: MhrvConfig, + onChange: (MhrvConfig) -> Unit, + onSnack: suspend (String) -> Unit, +) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + var oauthOpen by rememberSaveable { mutableStateOf(false) } + // Re-checked every time the credentials path changes — flips the + // button label between Authorize / Re-authorize so the user knows + // whether OAuth is already done. + var hasToken by remember(cfg.driveCredentialsPath) { + mutableStateOf( + cfg.driveCredentialsPath.isNotBlank() && + runCatching { Native.driveTokenPresent(cfg.toJson()) } + .getOrDefault(false) + ) + } + + // SAF-based importer. ACTION_OPEN_DOCUMENT returns a content URI we + // can read once; we copy the bytes into filesDir/drive-credentials.json + // so the Rust side has a stable absolute path. + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val dest = java.io.File(ctx.filesDir, "drive-credentials.json") + try { + ctx.contentResolver.openInputStream(uri)?.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } + onChange(cfg.copy(driveCredentialsPath = dest.absolutePath)) + scope.launch { onSnack("Credentials imported to ${dest.name}") } + } catch (t: Throwable) { + scope.launch { onSnack("Import failed: ${t.message ?: "unknown"}") } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.help_google_drive), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Credentials importer / status row. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.field_drive_credentials), + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = if (cfg.driveCredentialsPath.isBlank()) + stringResource(R.string.drive_creds_none) + else + cfg.driveCredentialsPath, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FilledTonalButton( + onClick = { importLauncher.launch(arrayOf("application/json", "*/*")) }, + ) { + Text(stringResource(R.string.btn_drive_import_creds)) + } + } + + // Authorize / re-authorize. + Button( + onClick = { + if (cfg.driveCredentialsPath.isBlank()) { + scope.launch { onSnack("Import credentials.json first") } + } else { + oauthOpen = true + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = cfg.driveCredentialsPath.isNotBlank(), + ) { + Text( + if (hasToken) + stringResource(R.string.btn_drive_reauthorize) + else + stringResource(R.string.btn_drive_authorize) + ) + } + + // Folder name + (optional) folder id. + OutlinedTextField( + value = cfg.driveFolderName, + onValueChange = { onChange(cfg.copy(driveFolderName = it)) }, + label = { Text(stringResource(R.string.field_drive_folder_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = cfg.driveFolderId, + onValueChange = { onChange(cfg.copy(driveFolderId = it)) }, + label = { Text(stringResource(R.string.field_drive_folder_id)) }, + placeholder = { Text(stringResource(R.string.placeholder_drive_folder_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = cfg.driveClientId, + onValueChange = { onChange(cfg.copy(driveClientId = it)) }, + label = { Text(stringResource(R.string.field_drive_client_id)) }, + placeholder = { Text(stringResource(R.string.placeholder_drive_client_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // poll / flush / idle. + Column { + Text( + stringResource(R.string.adv_drive_poll, cfg.drivePollMs), + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = cfg.drivePollMs.toFloat(), + onValueChange = { onChange(cfg.copy(drivePollMs = it.toInt().coerceIn(100, 5000))) }, + valueRange = 100f..5000f, + ) + } + Column { + Text( + stringResource(R.string.adv_drive_flush, cfg.driveFlushMs), + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = cfg.driveFlushMs.toFloat(), + onValueChange = { onChange(cfg.copy(driveFlushMs = it.toInt().coerceIn(100, 5000))) }, + valueRange = 100f..5000f, + ) + } + Column { + Text( + stringResource(R.string.adv_drive_idle, cfg.driveIdleTimeoutSecs), + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = cfg.driveIdleTimeoutSecs.toFloat(), + onValueChange = { onChange(cfg.copy(driveIdleTimeoutSecs = it.toInt().coerceIn(15, 3600))) }, + valueRange = 15f..3600f, + ) + Text( + stringResource(R.string.adv_drive_idle_help), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (oauthOpen) { + DriveOAuthDialog( + cfg = cfg, + onDismiss = { + oauthOpen = false + // Re-check after the dialog closes — covers the success + // case where the user just completed auth. + hasToken = cfg.driveCredentialsPath.isNotBlank() && + runCatching { Native.driveTokenPresent(cfg.toJson()) } + .getOrDefault(false) + }, + onSuccess = { + hasToken = true + scope.launch { onSnack("Google Drive authorized ✓") } + }, + ) + } +} + +@Composable +private fun DriveOAuthDialog( + cfg: MhrvConfig, + onDismiss: () -> Unit, + onSuccess: () -> Unit, +) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + + var url by remember { mutableStateOf(null) } + var loadingUrl by remember { mutableStateOf(true) } + var code by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var status by remember { mutableStateOf?>(null) } + + LaunchedEffect(cfg.driveCredentialsPath) { + loadingUrl = true + url = withContext(Dispatchers.IO) { + runCatching { Native.driveAuthUrl(cfg.toJson()) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + } + loadingUrl = false + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_drive_auth_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.dialog_drive_auth_step1)) + when { + loadingUrl -> { + Text( + stringResource(R.string.dialog_drive_auth_loading), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + url == null -> { + Text( + stringResource(R.string.dialog_drive_auth_load_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + else -> { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { + runCatching { + val intent = Intent( + Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ) + ctx.startActivity(intent) + } + }) { Text(stringResource(R.string.btn_drive_open_consent)) } + TextButton(onClick = { + clipboard.setText(AnnotatedString(url ?: "")) + Toast.makeText( + ctx, + ctx.getString(R.string.snack_drive_url_copied), + Toast.LENGTH_SHORT, + ).show() + }) { Text(stringResource(R.string.btn_copy)) } + } + } + } + + Spacer(Modifier.height(4.dp)) + Text(stringResource(R.string.dialog_drive_auth_step2)) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + placeholder = { Text(stringResource(R.string.placeholder_drive_code)) }, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + status?.let { r -> + if (r.isSuccess) { + Text( + r.getOrNull() ?: "", + color = OkGreen, + style = MaterialTheme.typography.labelMedium, + ) + } else { + Text( + r.exceptionOrNull()?.message + ?: stringResource(R.string.dialog_drive_auth_failed_generic), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !busy && url != null && code.isNotBlank(), + onClick = { + busy = true + status = null + scope.launch { + val json = withContext(Dispatchers.IO) { + runCatching { + Native.driveCompleteAuth(cfg.toJson(), code.trim()) + }.getOrNull() + } + busy = false + val parsed = parseDriveAuthResult(json) + status = parsed + if (parsed.isSuccess) { + onSuccess() + onDismiss() + } + } + }, + ) { + Text( + if (busy) stringResource(R.string.dialog_drive_auth_exchanging) + else stringResource(R.string.btn_drive_submit_code) + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + }, + ) +} + +private fun parseDriveAuthResult(json: String?): Result { + if (json.isNullOrBlank()) return Result.failure(RuntimeException("no response")) + return try { + val obj = JSONObject(json) + if (obj.optBoolean("ok", false)) { + val path = obj.optString("tokenPath", "") + Result.success("Saved refresh token to $path") + } else { + Result.failure(RuntimeException(obj.optString("error", "failed"))) + } + } catch (_: Throwable) { + Result.failure(RuntimeException("bad json")) + } +} + // ========================================================================= // Advanced settings. // ========================================================================= diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index 23c3cfd..7a41982 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -93,6 +93,35 @@ مشاهدهٔ سهمیه در گوگل ← تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است. + + صف Google Drive + ترافیک SOCKS5 را از طریق یک پوشهٔ مشترک گوگل درایو رد می‌کند. هم این دستگاه و هم پروسهٔ `mhrv-drive-node` روی سرور باید با یک حساب گوگل (یا پوشه‌ای که حساب دوم دسترسی دارد) OAuth شده باشند. Apps Script در این مدل دخیل نیست. + credentials.json + هنوز وارد نشده. روی «وارد کردن» بزنید. + وارد کردن… + احراز Google Drive + احراز مجدد Google Drive + باز کردن صفحهٔ تأیید + ثبت + نام پوشهٔ Drive + شناسهٔ پوشهٔ Drive (اختیاری) + خالی = جست‌وجو با نام + شناسهٔ Client (اختیاری) + خالی = هر بار یک شناسهٔ تصادفی + فاصلهٔ poll: %1$d ms + فاصلهٔ flush: %1$d ms + تایم‌اوت بی‌کاری: %1$d ثانیه + حد بی‌کاری هر نشست. اگر long-poll HTTP یا WebSocket بی‌کار رد می‌کنید، این را بالا ببرید. + احراز Google Drive + ۱. صفحهٔ تأیید گوگل را باز و دسترسی را تأیید کنید: + ۲. URL ریدایرکت‌شده را — یا فقط مقدار `code=…` — جای‌گذاری کنید: + در حال خواندن credentials… + خواندن credentials ممکن نشد. فایل را دوباره وارد کنید. + احراز انجام نشد + در حال تبادل… + https://localhost/?code=4/0AdQt8q… (یا فقط همان code) + URL تأیید کپی شد + ۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5f4d637..0894176 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -108,6 +108,35 @@ View quota on Google → Estimate — this is what this device relayed. Google\'s dashboard has the authoritative number. + + Google Drive queue + Multiplex SOCKS5 traffic through a shared Google Drive folder. Both this device and the remote `mhrv-drive-node` process need OAuth credentials for the same Google account (or a folder the second account has access to). No Apps Script involved. + credentials.json + Not yet imported. Tap Import to pick the file. + Import… + Authorize Google Drive + Re-authorize Google Drive + Open consent page + Submit + Drive folder name + Drive folder ID (optional) + empty = look up by name + Client ID (optional) + empty = random short id each run + poll interval: %1$d ms + flush interval: %1$d ms + idle timeout: %1$d s + Per-session inactivity cutoff. Bump up if you tunnel long-poll HTTP / idle WebSockets. + Authorize Google Drive + 1. Open Google\'s consent page and approve access: + 2. Paste the redirected URL — or just the `code=…` value: + Loading credentials… + Could not load credentials. Re-import the file. + Authorization failed + Exchanging… + https://localhost/?code=4/0AdQt8q… (or just the code) + Consent URL copied + 1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to Downloads/mhrv-ca.crt and the Settings app opens. Use Settings\' search bar to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If every entry times out, your google_ip is unreachable — replace it with one that resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different egress IP — gets re-challenged. Nothing in this app can fix that; it\'s inherent to Apps Script as a relay. Sites that only gate the initial page load (not every request) will work after one solve. diff --git a/src/android_jni.rs b/src/android_jni.rs index 6bb5a97..1f5e8c2 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -42,8 +42,13 @@ struct Running { rt: Option, /// Keep an Arc to the DomainFronter so `statsJson(handle)` can read the /// live stats without going through the async server. `None` for - /// google-only / full-only configs where the fronter isn't used. + /// google-only / full-only configs (and google_drive) where the fronter + /// isn't used. fronter: Option>, + /// True when this slot is running a `drive_tunnel::run_client` instead + /// of the regular `ProxyServer`. Used by `stopProxy` purely for + /// logging — both paths share the same shutdown channel + runtime. + is_drive: bool, } static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -248,12 +253,223 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( shutdown: Some(tx), rt: Some(rt), fronter, + is_drive: false, }, ); handle as jlong })) } +/// `Native.startDriveProxy(String configJson)` -> `long` handle (0 on failure). +/// +/// Same shape as `startProxy` but takes the google_drive code path: we +/// validate that mode == google_drive (otherwise the file would have +/// gone through the regular ProxyServer constructor, which `unreachable!()`s +/// for Drive), then spawn `drive_tunnel::run_client_with_shutdown` on +/// our own runtime. The returned handle plugs into the existing +/// `stopProxy` slot map — Stop works the same for both modes. +/// +/// Auth is expected to be done up front (call `driveCompleteAuth` first). +/// If the cached refresh token is missing, `init_backend` returns an +/// OAuth error — we surface it via tracing/logcat and return 0. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_startDriveProxy( + mut env: JNIEnv, + _class: JClass, + config_json: JString, +) -> jlong { + safe(0i64, AssertUnwindSafe(|| { + install_logging_once(); + + let json = jstring_to_string(&mut env, &config_json); + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { + tracing::error!("android: invalid drive config json: {}", e); + return 0i64; + } + }; + if !matches!( + config.mode_kind(), + Ok(crate::config::Mode::GoogleDrive) + ) { + tracing::error!( + "android: startDriveProxy called with mode={} — must be google_drive", + config.mode + ); + return 0i64; + } + + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("mhrv-drive-worker") + .build() + { + Ok(r) => r, + Err(e) => { + tracing::error!("android: drive runtime build failed: {}", e); + return 0i64; + } + }; + + let (tx, rx) = oneshot::channel::<()>(); + let cfg_for_task = config; + rt.spawn(async move { + if let Err(e) = + crate::drive_tunnel::run_client_with_shutdown(&cfg_for_task, rx).await + { + tracing::error!("android: drive client exited: {}", e); + } + }); + + let handle = HANDLE_COUNTER.fetch_add(1, Ordering::Relaxed); + slot_map().lock().unwrap().insert( + handle, + Running { + shutdown: Some(tx), + rt: Some(rt), + fronter: None, + is_drive: true, + }, + ); + handle as jlong + })) +} + +/// `Native.driveAuthUrl(String configJson)` -> String. Returns the +/// Google OAuth consent URL the UI should send the user to. Empty on +/// failure (logged to logcat). Idempotent — does not start a server. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveAuthUrl<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + config_json: JString, +) -> jstring { + let url = safe(String::new(), AssertUnwindSafe(|| { + install_logging_once(); + let json = jstring_to_string(&mut env, &config_json); + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { + tracing::error!("android: driveAuthUrl bad json: {}", e); + return String::new(); + } + }; + match crate::google_drive::GoogleDriveBackend::from_config(&config) { + Ok(b) => b.auth_url(), + Err(e) => { + tracing::error!("android: driveAuthUrl backend init: {}", e); + String::new() + } + } + })); + env.new_string(url) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +/// `Native.driveCompleteAuth(String configJson, String code)` -> String. +/// +/// Hands the pasted authorization code (or full redirect URL) to the +/// backend, which exchanges it for tokens and persists the refresh +/// token to `.token`. Returns a small JSON blob: +/// `{"ok":true,"tokenPath":"/data/.../credentials.json.token"}` on +/// success, `{"ok":false,"error":"..."}` otherwise. +/// +/// BLOCKS on a one-shot tokio runtime — call from a background +/// dispatcher. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveCompleteAuth<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + config_json: JString, + code: JString, +) -> jstring { + let result_json = safe( + r#"{"ok":false,"error":"panic"}"#.to_string(), + AssertUnwindSafe(|| { + install_logging_once(); + let json = jstring_to_string(&mut env, &config_json); + let raw = jstring_to_string(&mut env, &code); + if raw.trim().is_empty() { + return r#"{"ok":false,"error":"empty code"}"#.to_string(); + } + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { + return format!( + r#"{{"ok":false,"error":"bad config json: {}"}}"#, + json_escape(&e.to_string()) + ); + } + }; + let backend = + match crate::google_drive::GoogleDriveBackend::from_config(&config) { + Ok(b) => b, + Err(e) => { + return format!( + r#"{{"ok":false,"error":"{}"}}"#, + json_escape(&e.to_string()) + ); + } + }; + let Some(rt) = one_shot_runtime() else { + return r#"{"ok":false,"error":"tokio init failed"}"#.to_string(); + }; + match rt.block_on(backend.apply_auth_code(&raw)) { + Ok(()) => { + let path = backend.token_path().display().to_string(); + format!( + r#"{{"ok":true,"tokenPath":"{}"}}"#, + json_escape(&path) + ) + } + Err(e) => format!( + r#"{{"ok":false,"error":"{}"}}"#, + json_escape(&e.to_string()) + ), + } + }), + ); + env.new_string(result_json) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +/// `Native.driveTokenPresent(String configJson)` -> boolean. True iff a +/// non-empty refresh token has been persisted for these credentials. +/// Cheap (just a file read) — UI calls this on every recompose to gate +/// the "Authorize" vs "Re-authorize" button label. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveTokenPresent( + mut env: JNIEnv, + _class: JClass, + config_json: JString, +) -> jboolean { + safe(JNI_FALSE, AssertUnwindSafe(|| { + install_logging_once(); + let json = jstring_to_string(&mut env, &config_json); + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(_) => return JNI_FALSE, + }; + let backend = match crate::google_drive::GoogleDriveBackend::from_config(&config) { + Ok(b) => b, + Err(_) => return JNI_FALSE, + }; + if backend.has_cached_token() { + JNI_TRUE + } else { + JNI_FALSE + } + })) +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + /// `Native.stopProxy(long handle)` -> boolean. Idempotent: calling on an /// unknown handle returns false quietly. /// diff --git a/src/bin/drive_node.rs b/src/bin/drive_node.rs index 59026f7..11b70ce 100644 --- a/src/bin/drive_node.rs +++ b/src/bin/drive_node.rs @@ -86,6 +86,8 @@ async fn main() -> ExitCode { } tracing::warn!("mhrv-drive-node {} starting", VERSION); + // run_server is unaffected by the run_client refactor — it owns + // its own poll loop and exits when its mpsc channel closes. let run = mhrv_rs::drive_tunnel::run_server(&config); tokio::select! { r = run => { diff --git a/src/bin/ui.rs b/src/bin/ui.rs index fea1796..3036728 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -91,6 +91,10 @@ fn main() -> eframe::Result<()> { form, last_poll: Instant::now(), toast: initial_toast, + drive_oauth_open: false, + drive_oauth_code: String::new(), + drive_oauth_busy: false, + drive_oauth_status: None, })) }), ) @@ -143,6 +147,17 @@ struct UiState { /// One-line status of the most recent download (Ok(path) or Err(msg)). last_download: Option>, last_download_at: Option, + /// Drive OAuth: the consent URL produced by the most recent + /// `DriveBeginAuth`. UI shows it as a clickable link. + drive_auth_url: Option, + /// Result of the most recent `DriveCompleteAuth`. `Ok(token_path)` on + /// success, `Err(message)` otherwise. + drive_auth_result: Option>, + /// Whether the Drive client is the one currently held in `active` + /// on the background thread. Independent from `running` because the + /// drive client doesn't go through ProxyServer / DomainFronter, so + /// the stats panel stays empty while it runs. + drive_running: bool, } #[derive(Clone, Debug)] @@ -194,6 +209,13 @@ enum Cmd { url: String, name: String, }, + /// google_drive: prompt the OAuth consent URL and surface it in the + /// Drive auth dialog. Output goes into the auth status fields. + DriveBeginAuth(Config), + /// google_drive: exchange the authorization code (or pasted redirect + /// URL) and persist the refresh token. Surfaces success / failure on + /// the dialog status line. + DriveCompleteAuth { config: Config, code: String }, } struct App { @@ -202,6 +224,16 @@ struct App { form: FormState, last_poll: Instant, toast: Option<(String, Instant)>, + /// Drive OAuth modal state. The dialog lives outside the form + /// because it owns its own background work (open URL, paste-back + /// code, exchange) and we don't want a re-render of the form to + /// reset its half-completed state. + drive_oauth_open: bool, + drive_oauth_code: String, + drive_oauth_busy: bool, + /// `Ok(message)` after a successful exchange, `Err(message)` after a + /// failure. Cleared when the dialog reopens. + drive_oauth_status: Option>, } #[derive(Clone)] @@ -243,6 +275,25 @@ struct FormState { /// drop the user's setting. Not currently exposed as a UI control; /// users edit `block_quic` directly in `config.json` (Issue #213). block_quic: bool, + + // ── google_drive mode fields ───────────────────────────────────── + /// Path to the Google Cloud OAuth desktop credentials JSON. The + /// refresh token is cached next to it as `.token`. + drive_credentials_path: String, + /// Override for the cached-token path. Empty = derive from + /// drive_credentials_path. + drive_token_path: String, + /// Pinned Drive folder ID. When empty, we look up / create + /// `drive_folder_name` on the authorised account. + drive_folder_id: String, + drive_folder_name: String, + /// Stable per-process client_id used in Drive filenames. Empty = + /// generate a random short id at startup. Validated server-side + /// (length <=32, [A-Za-z0-9_-]). + drive_client_id: String, + drive_poll_ms: u64, + drive_flush_ms: u64, + drive_idle_timeout_secs: u64, } #[derive(Clone, Debug)] @@ -326,6 +377,14 @@ fn load_form() -> (FormState, Option) { youtube_via_relay: c.youtube_via_relay, passthrough_hosts: c.passthrough_hosts.clone(), block_quic: c.block_quic, + drive_credentials_path: c.drive_credentials_path.clone(), + drive_token_path: c.drive_token_path.clone().unwrap_or_default(), + drive_folder_id: c.drive_folder_id.clone(), + drive_folder_name: c.drive_folder_name.clone(), + drive_client_id: c.drive_client_id.clone(), + drive_poll_ms: c.drive_poll_ms, + drive_flush_ms: c.drive_flush_ms, + drive_idle_timeout_secs: c.drive_idle_timeout_secs, } } else { FormState { @@ -354,6 +413,14 @@ fn load_form() -> (FormState, Option) { youtube_via_relay: false, passthrough_hosts: Vec::new(), block_quic: false, + drive_credentials_path: "credentials.json".into(), + drive_token_path: String::new(), + drive_folder_id: String::new(), + drive_folder_name: "MHRV-Drive".into(), + drive_client_id: String::new(), + drive_poll_ms: 500, + drive_flush_ms: 300, + drive_idle_timeout_secs: 300, } }; (form, load_err) @@ -406,7 +473,13 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec impl FormState { fn to_config(&self) -> Result { let is_google_only = self.mode == "google_only"; - if !is_google_only { + let is_google_drive = self.mode == "google_drive"; + // Apps Script credentials are only required for the Apps Script + // relay paths. google_only and google_drive both bypass Apps + // Script entirely (one uses raw SNI rewrite, the other uses + // Drive as a queue), so the script_id / auth_key fields can stay + // empty without invalidating the form. + if !is_google_only && !is_google_drive { if self.script_id.trim().is_empty() { return Err("Apps Script ID is required".into()); } @@ -414,6 +487,19 @@ impl FormState { return Err("Auth key is required".into()); } } + if is_google_drive { + if self.drive_credentials_path.trim().is_empty() { + return Err( + "Google Drive mode requires drive_credentials_path".into(), + ); + } + if self.drive_poll_ms == 0 || self.drive_flush_ms == 0 { + return Err("Drive poll/flush intervals must be > 0".into()); + } + if self.drive_idle_timeout_secs == 0 { + return Err("Drive idle timeout must be > 0".into()); + } + } let listen_port: u16 = self .listen_port .parse() @@ -500,16 +586,21 @@ impl FormState { // control yet). Round-trip through the file so save // doesn't drop a user-set true. block_quic: self.block_quic, - // Google Drive mode is CLI-only for now; keep defaults here so - // the desktop UI's Config initializer stays in sync. - drive_credentials_path: "credentials.json".into(), - drive_token_path: None, - drive_folder_id: String::new(), - drive_folder_name: "MHRV-Drive".into(), - drive_client_id: String::new(), - drive_poll_ms: 500, - drive_flush_ms: 300, - drive_idle_timeout_secs: 300, + drive_credentials_path: self.drive_credentials_path.trim().to_string(), + drive_token_path: { + let v = self.drive_token_path.trim(); + if v.is_empty() { + None + } else { + Some(v.to_string()) + } + }, + drive_folder_id: self.drive_folder_id.trim().to_string(), + drive_folder_name: self.drive_folder_name.trim().to_string(), + drive_client_id: self.drive_client_id.trim().to_string(), + drive_poll_ms: self.drive_poll_ms, + drive_flush_ms: self.drive_flush_ms, + drive_idle_timeout_secs: self.drive_idle_timeout_secs, }) } } @@ -561,6 +652,29 @@ struct ConfigWire<'a> { max_ips_to_scan: usize, scan_batch_size: usize, google_ip_validation: bool, + + // google_drive mode. Skipped when empty/default so files written by + // Apps Script users stay diff-clean against the previous schema. + #[serde(skip_serializing_if = "str::is_empty")] + drive_credentials_path: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + drive_token_path: Option<&'a str>, + #[serde(skip_serializing_if = "str::is_empty")] + drive_folder_id: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + drive_folder_name: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + drive_client_id: &'a str, + #[serde(skip_serializing_if = "is_zero_u64")] + drive_poll_ms: u64, + #[serde(skip_serializing_if = "is_zero_u64")] + drive_flush_ms: u64, + #[serde(skip_serializing_if = "is_zero_u64")] + drive_idle_timeout_secs: u64, +} + +fn is_zero_u64(v: &u64) -> bool { + *v == 0 } fn is_false(b: &bool) -> bool { @@ -609,6 +723,42 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { max_ips_to_scan: c.max_ips_to_scan, scan_batch_size: c.scan_batch_size, google_ip_validation: c.google_ip_validation, + // Only emit drive_* keys when the user has actually opted + // into google_drive mode. Otherwise the file would gain a + // pile of "credentials.json" / "MHRV-Drive" stubs that the + // user never asked for. + drive_credentials_path: if c.mode == "google_drive" { + c.drive_credentials_path.as_str() + } else { + "" + }, + drive_token_path: if c.mode == "google_drive" { + c.drive_token_path.as_deref() + } else { + None + }, + drive_folder_id: if c.mode == "google_drive" { + c.drive_folder_id.as_str() + } else { + "" + }, + drive_folder_name: if c.mode == "google_drive" { + c.drive_folder_name.as_str() + } else { + "" + }, + drive_client_id: if c.mode == "google_drive" { + c.drive_client_id.as_str() + } else { + "" + }, + drive_poll_ms: if c.mode == "google_drive" { c.drive_poll_ms } else { 0 }, + drive_flush_ms: if c.mode == "google_drive" { c.drive_flush_ms } else { 0 }, + drive_idle_timeout_secs: if c.mode == "google_drive" { + c.drive_idle_timeout_secs + } else { + 0 + }, } } } @@ -754,12 +904,14 @@ impl eframe::App for App { form_row(ui, "Mode", Some( "apps_script: DPI bypass via Apps Script relay (needs cert).\n\ full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\ - google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only." + google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only.\n\ + google_drive: SOCKS5 multiplexed through a shared Google Drive folder (needs `mhrv-drive-node`)." ), |ui| { egui::ComboBox::from_id_source("mode") .selected_text(match self.form.mode.as_str() { "google_only" => "Google-only (bootstrap)", "full" => "Full tunnel (no cert)", + "google_drive" => "Google Drive queue", _ => "Apps Script (MITM)", }) .show_ui(ui, |ui| { @@ -778,6 +930,11 @@ impl eframe::App for App { "google_only".into(), "Google-only (bootstrap)", ); + ui.selectable_value( + &mut self.form.mode, + "google_drive".into(), + "Google Drive queue", + ); }); }); if self.form.mode == "google_only" { @@ -798,13 +955,23 @@ impl eframe::App for App { .color(OK_GREEN)); }); } + if self.form.mode == "google_drive" { + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.small(egui::RichText::new( + "Drive queue — SOCKS5 only. Run `mhrv-drive-node` on a remote host pointed at the same Drive folder.", + ) + .color(OK_GREEN)); + }); + } }); let google_only = self.form.mode == "google_only"; + let google_drive = self.form.mode == "google_drive"; // ── Section: Apps Script relay ──────────────────────────────── section(ui, "Apps Script relay", |ui| { - ui.add_enabled_ui(!google_only, |ui| { + ui.add_enabled_ui(!google_only && !google_drive, |ui| { form_row(ui, "Deployment IDs", Some( "One deployment ID per line. Proxy round-robins between them and sidelines \ any ID that hits its daily quota for 10 minutes before retrying." @@ -908,6 +1075,112 @@ impl eframe::App for App { }); }); + // ── Section: Google Drive queue ───────────────────────────── + // Only visible while the user has google_drive selected so it + // doesn't clutter the form for Apps Script users. + if google_drive { + section(ui, "Google Drive queue", |ui| { + form_row(ui, "Credentials JSON", Some( + "Path to your Google Cloud OAuth desktop credentials file. \ + The refresh token is cached next to it as `.token` \ + (chmod 0600 on Unix)." + ), |ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.drive_credentials_path) + .hint_text("credentials.json") + .desired_width(f32::INFINITY)); + }); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + if ui.small_button("Browse…") + .on_hover_text("Pick the Google Cloud desktop credentials JSON.") + .clicked() + { + if let Some(p) = pick_credentials_file() { + self.form.drive_credentials_path = p; + } + } + let oauth_btn = egui::Button::new( + egui::RichText::new("Authorize Google Drive…") + .color(egui::Color32::WHITE), + ) + .fill(ACCENT) + .rounding(4.0); + if ui.add(oauth_btn) + .on_hover_text( + "Open Google's OAuth consent page for the credentials file \ + above. Paste back the redirected URL or just the code." + ) + .clicked() + { + match self.form.to_config() { + Ok(cfg) => { + self.drive_oauth_open = true; + self.drive_oauth_code.clear(); + self.drive_oauth_busy = false; + self.drive_oauth_status = None; + let _ = self.cmd_tx.send(Cmd::DriveBeginAuth(cfg)); + } + Err(e) => { + self.toast = Some((format!("Cannot authorize: {}", e), Instant::now())); + } + } + } + }); + + form_row(ui, "Folder name", Some( + "Friendly name of the shared Drive folder. Used to look up / create \ + the folder when `Folder ID` is empty. Both peers must agree." + ), |ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.drive_folder_name) + .hint_text("MHRV-Drive") + .desired_width(f32::INFINITY)); + }); + form_row(ui, "Folder ID", Some( + "Optional fixed Drive folder ID. When set, takes precedence over \ + the folder-name lookup. Useful when both ends use different OAuth \ + accounts (the folder name is scoped to whoever created it)." + ), |ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.drive_folder_id) + .hint_text("(empty = look up by name)") + .desired_width(f32::INFINITY)); + }); + form_row(ui, "Client ID", Some( + "Stable identifier for this client embedded in Drive filenames. \ + Empty = a random short id is generated each run. Validated as \ + <=32 ASCII alphanumeric / dash / underscore." + ), |ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.drive_client_id) + .hint_text("(empty = random)") + .desired_width(f32::INFINITY)); + }); + ui.horizontal(|ui| { + ui.add_sized( + [120.0, 20.0], + egui::Label::new(egui::RichText::new("Timing") + .color(egui::Color32::from_gray(200))), + ); + ui.label(egui::RichText::new("poll ms").small()); + ui.add(egui::DragValue::new(&mut self.form.drive_poll_ms) + .speed(50) + .range(50..=10_000)); + ui.add_space(10.0); + ui.label(egui::RichText::new("flush ms").small()); + ui.add(egui::DragValue::new(&mut self.form.drive_flush_ms) + .speed(50) + .range(50..=10_000)); + ui.add_space(10.0); + ui.label(egui::RichText::new("idle s").small()) + .on_hover_text( + "Per-session inactivity cutoff. Bump up if you tunnel \ + long-poll HTTP / idle WebSockets that go quiet for minutes." + ); + ui.add(egui::DragValue::new(&mut self.form.drive_idle_timeout_secs) + .speed(10) + .range(15..=3600)); + }); + }); + } + // ── Section: Advanced (collapsed by default) ────────────────── ui.add_space(6.0); egui::CollapsingHeader::new( @@ -1006,6 +1279,11 @@ impl eframe::App for App { // same egui context but visually pops out with its own title bar. self.show_sni_editor(ctx); + // Floating Drive OAuth dialog. Same lifetime/visibility model + // as the SNI editor — opened from the Authorize button and + // closed via its own X. + self.show_drive_oauth(ctx); + ui.add_space(8.0); // ── Status + stats card ──────────────────────────────────────── @@ -1779,6 +2057,201 @@ impl App { }); self.form.sni_editor_open = keep_open; } + + /// Drive OAuth dialog. Two-step flow: + /// 1. Background thread loads credentials.json, derives the consent + /// URL, and sets `shared.drive_auth_url`. UI shows it as a + /// hyperlink + a Copy button. + /// 2. User signs in, copies either the redirect URL or just the + /// `code=…` value, pastes it back, hits Submit. We hand the + /// blob to `apply_auth_code`, which exchanges it for tokens + /// and persists the refresh token to `.token` + /// (chmod 0600 on Unix). + fn show_drive_oauth(&mut self, ctx: &egui::Context) { + if !self.drive_oauth_open { + return; + } + let mut keep_open = true; + let mut closed_via_button = false; + let auth_url = self.shared.state.lock().unwrap().drive_auth_url.clone(); + let auth_result = self.shared.state.lock().unwrap().drive_auth_result.clone(); + // Sync the latest result into our local status field so a + // dialog reopen doesn't show a stale message. + if let Some(r) = auth_result { + match r { + Ok(p) => { + self.drive_oauth_status = Some(Ok(format!("Saved refresh token to {}", p))); + self.drive_oauth_busy = false; + } + Err(e) => { + self.drive_oauth_status = Some(Err(e)); + self.drive_oauth_busy = false; + } + } + self.shared.state.lock().unwrap().drive_auth_result = None; + } + + egui::Window::new("Authorize Google Drive") + .open(&mut keep_open) + .resizable(false) + .default_size(egui::vec2(560.0, 280.0)) + .collapsible(false) + .show(ctx, |ui| { + ui.label(egui::RichText::new( + "1. Open this URL in your browser and approve access:", + ).strong()); + ui.add_space(4.0); + if let Some(url) = &auth_url { + ui.horizontal(|ui| { + ui.hyperlink_to( + egui::RichText::new("→ open consent page").color(ACCENT), + url, + ); + if ui.small_button("copy URL").clicked() { + ui.output_mut(|o| o.copied_text = url.clone()); + } + }); + egui::ScrollArea::horizontal() + .max_width(f32::INFINITY) + .show(ui, |ui| { + ui.label( + egui::RichText::new(url) + .monospace() + .size(11.0) + .color(egui::Color32::from_gray(170)), + ); + }); + } else if self.drive_oauth_busy { + ui.label( + egui::RichText::new("Loading credentials…") + .italics() + .color(egui::Color32::from_gray(150)), + ); + } else { + ui.label( + egui::RichText::new( + "Click Authorize on the form to load the credentials JSON.", + ) + .color(ERR_RED), + ); + } + + ui.add_space(8.0); + ui.label(egui::RichText::new( + "2. Paste the redirected URL — or just the `code=…` value:", + ).strong()); + ui.add( + egui::TextEdit::multiline(&mut self.drive_oauth_code) + .desired_width(f32::INFINITY) + .desired_rows(2) + .hint_text("https://localhost/?code=4/0AdQt8q… (or just the code)"), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + let submit_enabled = !self.drive_oauth_busy + && !self.drive_oauth_code.trim().is_empty() + && auth_url.is_some(); + let btn = egui::Button::new( + egui::RichText::new(if self.drive_oauth_busy { + "Exchanging…" + } else { + "Submit" + }) + .color(egui::Color32::WHITE), + ) + .fill(ACCENT) + .rounding(4.0); + ui.add_enabled_ui(submit_enabled, |ui| { + if ui.add(btn).clicked() { + match self.form.to_config() { + Ok(cfg) => { + self.drive_oauth_busy = true; + self.drive_oauth_status = None; + let _ = self.cmd_tx.send(Cmd::DriveCompleteAuth { + config: cfg, + code: self.drive_oauth_code.trim().to_string(), + }); + } + Err(e) => { + self.drive_oauth_status = Some(Err(format!( + "Cannot exchange code: {}", + e + ))); + } + } + } + }); + if ui.small_button("Close").clicked() { + closed_via_button = true; + } + }); + + if let Some(status) = &self.drive_oauth_status { + ui.add_space(6.0); + match status { + Ok(msg) => { + ui.colored_label(OK_GREEN, msg); + } + Err(e) => { + ui.colored_label(ERR_RED, e); + } + } + } + }); + self.drive_oauth_open = keep_open && !closed_via_button; + if !self.drive_oauth_open { + // Reset the URL/result so the next reopen doesn't flash + // stale state at the user. + let mut s = self.shared.state.lock().unwrap(); + s.drive_auth_url = None; + s.drive_auth_result = None; + } + } +} + +/// Native file dialog for picking the Google Cloud OAuth desktop +/// credentials JSON. Best-effort per platform; falls back to `None` so +/// the user can still type the path manually. +fn pick_credentials_file() -> Option { + // We deliberately don't pull in `rfd` (would mean another large + // GUI dep). PowerShell on Windows / osascript on macOS / zenity on + // Linux all handle the "pick a file" prompt without a new crate. + #[cfg(target_os = "windows")] + { + let script = "Add-Type -AssemblyName System.Windows.Forms; \ + $f = New-Object System.Windows.Forms.OpenFileDialog; \ + $f.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'; \ + if ($f.ShowDialog() -eq 'OK') { Write-Output $f.FileName }"; + let out = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", script]) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } + #[cfg(target_os = "macos")] + { + let script = "POSIX path of (choose file with prompt \"Pick credentials.json\" of type {\"json\"})"; + let out = std::process::Command::new("osascript") + .args(["-e", script]) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let out = std::process::Command::new("zenity") + .args([ + "--file-selection", + "--title=Pick credentials.json", + "--file-filter=JSON | *.json", + ]) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } } fn fmt_duration(d: Duration) -> String { @@ -1835,6 +2308,45 @@ fn background_thread(shared: Arc, rx: Receiver) { push_log(&shared, "[ui] already running"); continue; } + // google_drive mode bypasses the MITM/ProxyServer flow + // entirely — it owns its own SOCKS5 listener and never + // needs a CA. Spawn drive_tunnel::run_client_with_shutdown + // and reuse the same `active` handle slot so Stop works + // unchanged. + if matches!(cfg.mode_kind(), Ok(mhrv_rs::config::Mode::GoogleDrive)) { + push_log(&shared, "[ui] starting google_drive client..."); + shared.state.lock().unwrap().proxy_active = true; + let shared2 = shared.clone(); + let fronter_slot: Arc>>> = + Arc::new(AsyncMutex::new(None)); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let handle = rt.spawn(async move { + { + let mut s = shared2.state.lock().unwrap(); + s.running = true; + s.started_at = Some(Instant::now()); + s.drive_running = true; + } + let port = cfg.socks5_port.unwrap_or(cfg.listen_port + 1); + push_log( + &shared2, + &format!("[ui] drive client SOCKS5 {}:{}", cfg.listen_host, port), + ); + if let Err(e) = + mhrv_rs::drive_tunnel::run_client_with_shutdown(&cfg, shutdown_rx).await + { + push_log(&shared2, &format!("[ui] drive client error: {}", e)); + } + let mut st = shared2.state.lock().unwrap(); + st.running = false; + st.started_at = None; + st.proxy_active = false; + st.drive_running = false; + push_log(&shared2, "[ui] drive client stopped"); + }); + active = Some((handle, fronter_slot, shutdown_tx)); + continue; + } push_log(&shared, "[ui] starting proxy..."); // Flip proxy_active synchronously so a `Remove CA` click // queued in the same frame as Start is rejected before @@ -2177,6 +2689,52 @@ fn background_thread(shared: Arc, rx: Receiver) { } }); } + Ok(Cmd::DriveBeginAuth(cfg)) => { + let shared2 = shared.clone(); + rt.spawn(async move { + match mhrv_rs::google_drive::GoogleDriveBackend::from_config(&cfg) { + Ok(backend) => { + let url = backend.auth_url(); + push_log(&shared2, "[ui] drive: opened consent URL"); + shared2.state.lock().unwrap().drive_auth_url = Some(url); + } + Err(e) => { + let msg = format!("Failed to load credentials: {}", e); + push_log(&shared2, &format!("[ui] drive: {}", msg)); + shared2.state.lock().unwrap().drive_auth_result = Some(Err(msg)); + } + } + }); + } + Ok(Cmd::DriveCompleteAuth { config: cfg, code }) => { + let shared2 = shared.clone(); + rt.spawn(async move { + let backend = match mhrv_rs::google_drive::GoogleDriveBackend::from_config(&cfg) { + Ok(b) => b, + Err(e) => { + let msg = format!("Failed to load credentials: {}", e); + push_log(&shared2, &format!("[ui] drive: {}", msg)); + shared2.state.lock().unwrap().drive_auth_result = Some(Err(msg)); + return; + } + }; + match backend.apply_auth_code(&code).await { + Ok(()) => { + let path = backend.token_path().display().to_string(); + push_log( + &shared2, + &format!("[ui] drive: refresh token saved to {}", path), + ); + shared2.state.lock().unwrap().drive_auth_result = Some(Ok(path)); + } + Err(e) => { + let msg = format!("Code exchange failed: {}", e); + push_log(&shared2, &format!("[ui] drive: {}", msg)); + shared2.state.lock().unwrap().drive_auth_result = Some(Err(msg)); + } + } + }); + } Err(_) => {} } diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs index 0d7216f..aadc486 100644 --- a/src/drive_tunnel.rs +++ b/src/drive_tunnel.rs @@ -628,6 +628,22 @@ fn apply_rx_env(session: &mut DriveSession, env: Envelope, out: &mut Vec Result<(), DriveError> { + // Backward compatibility shim — never returns. Newer entry points + // ([`run_client_with_shutdown`]) accept a oneshot so UIs can stop + // the SOCKS5 listener cleanly. + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + run_client_with_shutdown(config, rx).await +} + +/// Same as [`run_client`] but returns when `shutdown` resolves. UIs and +/// services use this so the listener can be released on stop without +/// killing the whole tokio runtime. Backend OAuth + folder discovery +/// happen up front (before the listen socket binds), so a Ctrl+C that +/// arrives during login still bubbles back to the caller. +pub async fn run_client_with_shutdown( + config: &Config, + shutdown: tokio::sync::oneshot::Receiver<()>, +) -> Result<(), DriveError> { let backend = init_backend(config).await?; let client_id = if config.drive_client_id.trim().is_empty() { random_hex(4) @@ -647,14 +663,24 @@ pub async fn run_client(config: &Config) -> Result<(), DriveError> { ); tracing::warn!("HTTP proxy and UDP ASSOCIATE are not available in google_drive mode."); + let mut shutdown = shutdown; loop { - let (sock, peer) = listener.accept().await?; - let engine = engine.clone(); - tokio::spawn(async move { - if let Err(e) = handle_socks5_client(sock, engine).await { - tracing::debug!("Drive SOCKS5 client {} closed: {}", peer, e); + tokio::select! { + biased; + _ = &mut shutdown => { + tracing::info!("google_drive client: shutdown signal received, releasing {}", addr); + return Ok(()); } - }); + accepted = listener.accept() => { + let (sock, peer) = accepted?; + let engine = engine.clone(); + tokio::spawn(async move { + if let Err(e) = handle_socks5_client(sock, engine).await { + tracing::debug!("Drive SOCKS5 client {} closed: {}", peer, e); + } + }); + } + } } } diff --git a/src/google_drive.rs b/src/google_drive.rs index 1c33b89..3a0c2eb 100644 --- a/src/google_drive.rs +++ b/src/google_drive.rs @@ -266,48 +266,67 @@ impl GoogleDriveBackend { }) } + /// Best-effort login: try cached refresh token, otherwise fall through + /// to the (CLI-only) interactive prompt. UIs should call + /// [`try_login_with_cached_token`] first and, if it errors with + /// [`DriveError::NeedsOAuth`], drive the [`auth_url`] / [`apply_auth_code`] + /// pair from their own widget instead. pub async fn login(&self) -> Result<(), DriveError> { - if let Ok(data) = fs::read_to_string(&self.token_path) { - if let Ok(cache) = serde_json::from_str::(&data) { - if !cache.refresh_token.is_empty() { - *self.token.lock().await = Some(TokenState { - access_token: String::new(), - refresh_token: cache.refresh_token, - expires_at: Instant::now(), - }); - return self.refresh_access_token().await; - } - } + if self.try_login_with_cached_token().await? { + return Ok(()); } - self.interactive_login().await } - async fn interactive_login(&self) -> Result<(), DriveError> { - let auth_url = { - let mut ser = url::form_urlencoded::Serializer::new(String::new()); - ser.append_pair("client_id", &self.client_id); - ser.append_pair("redirect_uri", &self.redirect_uri); - ser.append_pair("response_type", "code"); - ser.append_pair("scope", DRIVE_SCOPE); - ser.append_pair("access_type", "offline"); - ser.append_pair("prompt", "consent"); - format!("{}?{}", self.auth_uri, ser.finish()) + /// Returns `Ok(true)` if a cached refresh token was found and an access + /// token was successfully minted from it; `Ok(false)` if no cached token + /// exists at all. Errors propagate transport / OAuth failures. + pub async fn try_login_with_cached_token(&self) -> Result { + let Ok(data) = fs::read_to_string(&self.token_path) else { + return Ok(false); + }; + let Ok(cache) = serde_json::from_str::(&data) else { + return Ok(false); }; + if cache.refresh_token.is_empty() { + return Ok(false); + } + *self.token.lock().await = Some(TokenState { + access_token: String::new(), + refresh_token: cache.refresh_token, + expires_at: Instant::now(), + }); + self.refresh_access_token().await?; + Ok(true) + } - println!(); - println!("==================== GOOGLE DRIVE OAUTH REQUIRED ===================="); - println!("1. Open this URL in your browser:\n"); - println!("{}", auth_url); - println!("\n2. Approve access, then paste the full redirected URL or just the code."); - print!("\nEnter URL or code: "); - std::io::stdout().flush()?; + /// Build the authorization URL. UIs show this to the user (clickable + /// link or QR code) and then ask them to paste the redirect URL or + /// raw code into a text field — which they hand back to + /// [`apply_auth_code`]. + pub fn auth_url(&self) -> String { + let mut ser = url::form_urlencoded::Serializer::new(String::new()); + ser.append_pair("client_id", &self.client_id); + ser.append_pair("redirect_uri", &self.redirect_uri); + ser.append_pair("response_type", "code"); + ser.append_pair("scope", DRIVE_SCOPE); + ser.append_pair("access_type", "offline"); + ser.append_pair("prompt", "consent"); + format!("{}?{}", self.auth_uri, ser.finish()) + } - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let input = input.trim(); - let code = if input.starts_with("http://") || input.starts_with("https://") { - let parsed = url::Url::parse(input) + /// Accept either a raw authorization code or the full redirect URL + /// (`http(s)://.../?code=…`). Exchanges it for tokens, persists the + /// refresh token to disk (chmod 0600 on Unix), and leaves the in-memory + /// state ready for API calls. Idempotent — safe to call multiple times + /// with fresh codes. + pub async fn apply_auth_code(&self, raw: &str) -> Result<(), DriveError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(DriveError::OAuth("empty authorization code".into())); + } + let code = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + let parsed = url::Url::parse(trimmed) .map_err(|e| DriveError::OAuth(format!("bad redirect URL: {}", e)))?; parsed .query_pairs() @@ -315,9 +334,8 @@ impl GoogleDriveBackend { .map(|(_, v)| v.into_owned()) .ok_or_else(|| DriveError::OAuth("redirect URL did not contain a code".into()))? } else { - input.to_string() + trimmed.to_string() }; - if code.is_empty() { return Err(DriveError::OAuth("empty authorization code".into())); } @@ -333,6 +351,41 @@ impl GoogleDriveBackend { .ok_or_else(|| DriveError::OAuth("Google did not return a refresh token".into()))?; let cache = serde_json::to_vec_pretty(&TokenCache { refresh_token })?; write_secret_file(&self.token_path, &cache)?; + Ok(()) + } + + /// Whether a refresh token is already cached on disk for this + /// credentials JSON. Cheap — does no network I/O. UIs use this to + /// decide whether to show the "Authorize" dialog at all. + pub fn has_cached_token(&self) -> bool { + let Ok(data) = fs::read_to_string(&self.token_path) else { + return false; + }; + serde_json::from_str::(&data) + .map(|c| !c.refresh_token.is_empty()) + .unwrap_or(false) + } + + /// Path to where the cached refresh token will be written by + /// [`apply_auth_code`]. Surfaced for UIs that want to display it. + pub fn token_path(&self) -> &PathBuf { + &self.token_path + } + + async fn interactive_login(&self) -> Result<(), DriveError> { + let auth_url = self.auth_url(); + + println!(); + println!("==================== GOOGLE DRIVE OAUTH REQUIRED ===================="); + println!("1. Open this URL in your browser:\n"); + println!("{}", auth_url); + println!("\n2. Approve access, then paste the full redirected URL or just the code."); + print!("\nEnter URL or code: "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + self.apply_auth_code(&input).await?; println!("Saved Drive OAuth token to {}", self.token_path.display()); println!("====================================================================="); println!(); diff --git a/src/main.rs b/src/main.rs index 6732478..344eaa5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -329,7 +329,8 @@ async fn main() -> ExitCode { } if mode == mhrv_rs::config::Mode::GoogleDrive { - let run = mhrv_rs::drive_tunnel::run_client(&config); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let run = mhrv_rs::drive_tunnel::run_client_with_shutdown(&config, shutdown_rx); tokio::select! { r = run => { if let Err(e) = r { @@ -339,6 +340,7 @@ async fn main() -> ExitCode { } _ = tokio::signal::ctrl_c() => { tracing::warn!("Ctrl+C — shutting down google_drive client."); + let _ = shutdown_tx.send(()); } } return ExitCode::SUCCESS; From c9bc65149999e5a88d5d06412ffd898fbad68ac6 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:12:16 +0400 Subject: [PATCH 03/11] build(drive): add Dockerfile for mhrv-drive-node --- Dockerfile.drive-node | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 Dockerfile.drive-node diff --git a/Dockerfile.drive-node b/Dockerfile.drive-node new file mode 100644 index 0000000..2f8f1b4 --- /dev/null +++ b/Dockerfile.drive-node @@ -0,0 +1,48 @@ +# Dockerfile for `mhrv-drive-node` — the server side of google_drive mode. +# +# Build: +# docker build -t mhrv-drive-node -f Dockerfile.drive-node . +# +# First run (interactive — completes Google OAuth): +# docker run -it --rm \ +# -v /opt/mhrv-drive:/data \ +# mhrv-drive-node +# +# Subsequent runs (detached, restart on host reboot): +# docker run -d --name mhrv-drive-node --restart unless-stopped \ +# -v /opt/mhrv-drive:/data \ +# mhrv-drive-node +# +# /data is expected to contain `credentials.json` and `config.drive.json` +# before first run. The binary writes `credentials.json.token` (the cached +# refresh token, chmod 0600) into the same dir on successful OAuth. + +# ---- builder ------------------------------------------------------------ +FROM rust:1.82-slim-bookworm AS builder +WORKDIR /src + +# `ring` (TLS backend) needs a C compiler + assembler. Everything else is +# pure Rust thanks to rustls. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +# Skip the desktop / Android binaries — we only need the drive node. +RUN cargo build --release --bin mhrv-drive-node + +# ---- runtime ------------------------------------------------------------ +FROM debian:bookworm-slim + +# CA bundle for HTTPS to www.googleapis.com. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/target/release/mhrv-drive-node /usr/local/bin/ + +WORKDIR /data +ENTRYPOINT ["/usr/local/bin/mhrv-drive-node"] +CMD ["-c", "/data/config.drive.json"] From 991f5f64665f713d24f5c511e4642a4a66a5bd6e Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:20:35 +0400 Subject: [PATCH 04/11] fix: docker build --- Dockerfile.drive-node | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile.drive-node b/Dockerfile.drive-node index 2f8f1b4..e4111a0 100644 --- a/Dockerfile.drive-node +++ b/Dockerfile.drive-node @@ -18,7 +18,10 @@ # refresh token, chmod 0600) into the same dir on successful OAuth. # ---- builder ------------------------------------------------------------ -FROM rust:1.82-slim-bookworm AS builder +# Need >= 1.85 for the edition2024 stabilization that time-macros (and a +# few other transitive deps in our lockfile) now require. `rust:1` always +# points at the latest 1.x stable — fine for a build image we throw away. +FROM rust:1-slim-bookworm AS builder WORKDIR /src # `ring` (TLS backend) needs a C compiler + assembler. Everything else is From 526377c31b0f7706b369ccefdf2136bdd268b8ed Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:43:04 +0400 Subject: [PATCH 05/11] feat: added hyper-util --- Cargo.lock | 87 +++++++++++- Cargo.toml | 7 + src/drive_tunnel.rs | 138 ++++++++++++++----- src/google_drive.rs | 325 +++++++++++++------------------------------- 4 files changed, 294 insertions(+), 263 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89b7ddb..1775e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1709,12 +1709,73 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2232,7 +2293,10 @@ dependencies = [ "futures-util", "h2", "http", + "http-body-util", "httparse", + "hyper", + "hyper-util", "jni 0.21.1", "libc", "portable-atomic", @@ -3847,6 +3911,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tproxy-config" version = "7.0.7" @@ -3931,6 +4001,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4118,6 +4194,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4579,7 +4664,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ecbe746..98367ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,13 @@ httparse = "1" rand = "0.8" h2 = "0.4" http = "1" +# hyper drives the Drive REST client over a single multiplexed HTTP/2 +# connection. Without this, every Drive API call paid a 300-500 ms TLS +# handshake; HTTP/2 multiplexing folds them all onto one keep-alive +# stream and gets us roughly an order of magnitude on perceived latency. +hyper = { version = "1", features = ["client", "http2"] } +hyper-util = { version = "0.1", features = ["client", "client-legacy", "tokio", "http2"] } +http-body-util = "0.1" flate2 = "1" directories = "5" futures-util = { version = "0.3", default-features = false, features = ["std"] } diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs index aadc486..24ea5d4 100644 --- a/src/drive_tunnel.rs +++ b/src/drive_tunnel.rs @@ -9,6 +9,7 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use futures_util::stream::{self, StreamExt}; use rand::RngCore; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -17,6 +18,12 @@ use tokio::sync::{mpsc, Mutex}; use crate::config::Config; use crate::google_drive::{DriveError, GoogleDriveBackend}; +/// Maximum number of concurrent uploads/downloads in flight against +/// Drive. Matches FlowDriver's `e.sem = make(chan struct{}, 8)`. Uses +/// HTTP/2 multiplexing on a single TLS connection, so the cost of bumping +/// this is just a few more in-flight streams — no extra handshakes. +const STORAGE_CONCURRENCY: usize = 8; + const MAGIC_BYTE: u8 = 0x1f; /// Bumped whenever the wire format changes. v1 added a flags byte /// (replacing the old `close` bool) and gained the FLAG_OPEN_OK bit @@ -381,13 +388,36 @@ impl DriveEngine { muxes.entry(cid).or_default().push(env); } + // Encode all mux files up front (CPU only, fast), then ship them + // in parallel. With one client this is one upload — no win — but + // the server side typically has several active clients and the + // parallelism plus HTTP/2 multiplexing folds them into a single + // round-trip's worth of latency. + let mut uploads: Vec<(String, Vec)> = Vec::with_capacity(muxes.len()); for (cid, envelopes) in muxes { let filename = format!("{}-{}-mux-{}.bin", self.my_dir.as_str(), cid, now_nanos()); let mut body = Vec::new(); for env in &envelopes { env.encode(&mut body)?; } - self.backend.upload(&filename, body).await?; + uploads.push((filename, body)); + } + if !uploads.is_empty() { + let backend = self.backend.clone(); + let results: Vec> = stream::iter(uploads.into_iter().map( + |(name, body)| { + let backend = backend.clone(); + async move { backend.upload(&name, body).await } + }, + )) + .buffer_unordered(STORAGE_CONCURRENCY) + .collect() + .await; + for r in results { + if let Err(e) = r { + tracing::debug!("Drive upload error: {}", e); + } + } } if !closed_ids.is_empty() { @@ -438,47 +468,87 @@ impl DriveEngine { let stale_cutoff = SystemTime::now().checked_sub(STARTUP_STALE_TTL); - for file in files { - if let (Some(cutoff), Some(created)) = (stale_cutoff, file.created_time) { - if created < cutoff { - let _ = self.backend.delete(&file.name).await; - continue; + // Pre-filter: drop stale files (created > 5 min ago is most + // likely a leftover from a previous run on the peer; nuking it + // is safer than re-processing) and skip files we already + // downloaded but haven't garbage-collected from `processed` + // yet. Mark the survivors as processed up front so a slow + // download doesn't get re-fetched on the next poll cycle. + let mut to_download: Vec = Vec::with_capacity(files.len()); + let mut to_delete_stale: Vec = Vec::new(); + { + let mut processed = self.processed.lock().await; + for file in files { + if let (Some(cutoff), Some(created)) = (stale_cutoff, file.created_time) { + if created < cutoff { + to_delete_stale.push(file.name); + continue; + } } - } - let already_processed = { - let mut processed = self.processed.lock().await; if processed.contains_key(&file.name) { - true - } else { - processed.insert(file.name.clone(), Instant::now()); - false + continue; } - }; - if already_processed { - continue; + processed.insert(file.name.clone(), Instant::now()); + to_download.push(file.name); } + } + + // Fire stale deletes in the background — don't block this poll + // cycle on cleanup. + for name in to_delete_stale { + let backend = self.backend.clone(); + tokio::spawn(async move { + let _ = backend.delete(&name).await; + }); + } + + if to_download.is_empty() { + return Ok(true); + } - let data = match self.backend.download(&file.name).await { - Ok(data) => data, + // Concurrent downloads, bounded by STORAGE_CONCURRENCY. With + // HTTP/2 these all multiplex onto the same TLS connection — no + // extra handshakes, just more in-flight streams. This is the + // single biggest win over the v1 sequential implementation. + let backend = self.backend.clone(); + let downloads = stream::iter(to_download.into_iter().map(|name| { + let backend = backend.clone(); + async move { + let res = backend.download(&name).await; + (name, res) + } + })) + .buffer_unordered(STORAGE_CONCURRENCY); + tokio::pin!(downloads); + + while let Some((name, result)) = downloads.next().await { + match result { + Ok(data) => { + let file_client_id = client_id_from_filename(&name).unwrap_or_default(); + if let Err(e) = self.process_mux_file(&data, &file_client_id).await { + // A bad envelope inside a mux file aborts the + // rest of that file's envelopes. Bumping past + // `debug` so the data loss is visible. + tracing::warn!( + "Drive mux decode {} failed: {} (remaining envelopes in this file are lost)", + name, + e + ); + } + // Fire-and-forget delete — the next poll won't see + // it because we marked it processed; if delete + // races we get a 404 which the backend ignores. + let backend = self.backend.clone(); + let name_for_delete = name; + tokio::spawn(async move { + let _ = backend.delete(&name_for_delete).await; + }); + } Err(e) => { - self.processed.lock().await.remove(&file.name); - tracing::debug!("Drive download {} failed: {}", file.name, e); - continue; + self.processed.lock().await.remove(&name); + tracing::debug!("Drive download {} failed: {}", name, e); } - }; - let file_client_id = client_id_from_filename(&file.name).unwrap_or_default(); - if let Err(e) = self.process_mux_file(&data, &file_client_id).await { - // A bad envelope inside a mux file aborts the rest of - // that file's envelopes — nothing we can do, the format - // isn't self-synchronising. Bumping past `debug` so the - // data loss is visible in default logs. - tracing::warn!( - "Drive mux decode {} failed: {} (remaining envelopes in this file are lost)", - file.name, - e - ); } - let _ = self.backend.delete(&file.name).await; } Ok(true) diff --git a/src/google_drive.rs b/src/google_drive.rs index 3a0c2eb..c13100e 100644 --- a/src/google_drive.rs +++ b/src/google_drive.rs @@ -7,22 +7,25 @@ use std::collections::HashMap; use std::fs; -use std::io::{Read, Write}; +use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::client::conn::http2::SendRequest; +use hyper::Request; +use hyper_util::rt::{TokioExecutor, TokioIo}; use rand::RngCore; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; use serde::{Deserialize, Serialize}; use serde_json::json; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio::time::timeout; -use tokio_rustls::client::TlsStream; use tokio_rustls::TlsConnector; use crate::config::Config; @@ -49,12 +52,27 @@ pub enum DriveError { OAuth(String), } -#[derive(Clone)] +/// Domain-fronted HTTP/2 client over a single multiplexed TLS connection. +/// +/// The earlier hand-rolled HTTP/1.1 client opened a fresh TLS handshake per +/// Drive API call (`Connection: close`); on a busy SOCKS5 page-load it spent +/// 1-2 seconds per poll cycle in pure handshake overhead. Hyper's HTTP/2 +/// client multiplexes every request as a stream on one long-lived +/// connection, which is what FlowDriver does and what closes the perf gap. +/// +/// The actual transport is still SNI-spoofing rustls — we connect a TCP +/// socket to `connect_host`, do a TLS handshake with `sni`, advertise `h2` +/// in ALPN, then hand the resulting stream to hyper's HTTP/2 builder. Every +/// request rewrites `Host:` to `host_header` so Google's edge routes to the +/// real `www.googleapis.com` backend regardless of the SNI we sent. struct GoogleApiClient { connect_host: String, sni: String, host_header: String, tls_connector: TlsConnector, + /// The live HTTP/2 sender. `None` until first use, replaced if a + /// request fails because the connection went away. + sender: Mutex>>>, } struct HttpResponse { @@ -64,7 +82,7 @@ struct HttpResponse { impl GoogleApiClient { fn new(connect_host: String, sni: String, host_header: String, verify_ssl: bool) -> Self { - let tls_config = if verify_ssl { + let mut tls_config = if verify_ssl { let mut roots = rustls::RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); ClientConfig::builder() @@ -76,20 +94,59 @@ impl GoogleApiClient { .with_custom_certificate_verifier(Arc::new(NoVerify)) .with_no_client_auth() }; + // Advertise HTTP/2 in ALPN so Google's edge negotiates h2 instead + // of h1.1. Without this hyper's handshake will still complete but + // we'd lose multiplexing — back to one-stream-per-connection. + tls_config.alpn_protocols = vec![b"h2".to_vec()]; Self { connect_host, sni, host_header, tls_connector: TlsConnector::from(Arc::new(tls_config)), + sender: Mutex::new(None), } } - async fn open(&self) -> Result, DriveError> { - let tcp = TcpStream::connect(connect_addr(&self.connect_host)).await?; + /// Returns a clone of the live `SendRequest`, opening / reopening the + /// HTTP/2 connection if needed. The clone is cheap (it's an `mpsc`-like + /// handle into the connection driver), and concurrent callers don't + /// block each other once the connection is established. + async fn sender(&self) -> Result>, DriveError> { + let mut guard = self.sender.lock().await; + if let Some(s) = guard.as_ref() { + if !s.is_closed() { + return Ok(s.clone()); + } + } + + let tcp = timeout(HTTP_TIMEOUT, TcpStream::connect(connect_addr(&self.connect_host))) + .await + .map_err(|_| DriveError::BadResponse("connect timeout".into()))??; let _ = tcp.set_nodelay(true); let server_name = ServerName::try_from(self.sni.clone())?; - Ok(self.tls_connector.connect(server_name, tcp).await?) + let tls = timeout(HTTP_TIMEOUT, self.tls_connector.connect(server_name, tcp)) + .await + .map_err(|_| DriveError::BadResponse("tls handshake timeout".into()))??; + let io = TokioIo::new(tls); + + let (sender, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new()) + .handshake(io) + .await + .map_err(|e| DriveError::BadResponse(format!("h2 handshake: {}", e)))?; + + // The connection driver runs on its own task. When the peer closes + // (GOAWAY, network drop), it returns; the next sender() call sees + // `is_closed()` and reopens. Errors are debug-only; they're + // expected on idle teardown. + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::debug!("Drive HTTP/2 connection closed: {}", e); + } + }); + + *guard = Some(sender.clone()); + Ok(sender) } async fn request( @@ -99,45 +156,43 @@ impl GoogleApiClient { headers: Vec<(String, String)>, body: &[u8], ) -> Result { - let mut stream = timeout(HTTP_TIMEOUT, self.open()) - .await - .map_err(|_| DriveError::BadResponse("connect timeout".into()))??; - - let mut req = format!( - "{method} {path} HTTP/1.1\r\n\ - Host: {host}\r\n\ - User-Agent: mhrv-rs-drive/{version}\r\n\ - Accept-Encoding: identity\r\n\ - Connection: close\r\n", - method = method, - path = path, - host = self.host_header, - version = env!("CARGO_PKG_VERSION"), - ); - let mut has_content_length = false; + // Build the request once. The :authority pseudo-header is what + // routes the request inside Google's HTTP/2 frontend; it must be + // the API host even though we're connected via SNI=front_domain. + let uri = format!("https://{}{}", self.host_header, path); + let mut builder = Request::builder() + .method(method) + .uri(&uri) + .header( + "user-agent", + format!("mhrv-rs-drive/{}", env!("CARGO_PKG_VERSION")), + ) + .header("accept-encoding", "identity"); for (k, v) in headers { - if k.eq_ignore_ascii_case("content-length") { - has_content_length = true; - } - req.push_str(&k); - req.push_str(": "); - req.push_str(&v); - req.push_str("\r\n"); - } - if !has_content_length && (method == "POST" || method == "PATCH" || method == "PUT") { - req.push_str(&format!("Content-Length: {}\r\n", body.len())); + builder = builder.header(k, v); } - req.push_str("\r\n"); + let req = builder + .body(Full::new(Bytes::from(body.to_vec()))) + .map_err(|e| DriveError::BadResponse(format!("build request: {}", e)))?; - stream.write_all(req.as_bytes()).await?; - if !body.is_empty() { - stream.write_all(body).await?; - } - stream.flush().await?; + let mut sender = self.sender().await?; + let resp = timeout(HTTP_TIMEOUT, sender.send_request(req)) + .await + .map_err(|_| DriveError::BadResponse("request timeout".into()))? + .map_err(|e| DriveError::BadResponse(format!("h2 send: {}", e)))?; - timeout(HTTP_TIMEOUT, read_http_response(&mut stream)) + let status = resp.status().as_u16(); + let body_bytes = timeout(HTTP_TIMEOUT, resp.into_body().collect()) .await - .map_err(|_| DriveError::BadResponse("response timeout".into()))? + .map_err(|_| DriveError::BadResponse("body timeout".into()))? + .map_err(|e| DriveError::BadResponse(format!("body read: {}", e)))? + .to_bytes() + .to_vec(); + + Ok(HttpResponse { + status, + body: body_bytes, + }) } } @@ -842,169 +897,6 @@ fn write_secret_file(path: &PathBuf, data: &[u8]) -> std::io::Result<()> { Ok(()) } -async fn read_http_response(stream: &mut S) -> Result -where - S: AsyncRead + Unpin, -{ - let mut buf = Vec::with_capacity(8192); - let mut tmp = [0u8; 8192]; - let header_end = loop { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Err(DriveError::BadResponse( - "connection closed before headers".into(), - )); - } - buf.extend_from_slice(&tmp[..n]); - if let Some(pos) = find_double_crlf(&buf) { - break pos; - } - if buf.len() > 1024 * 1024 { - return Err(DriveError::BadResponse("headers too large".into())); - } - }; - - let header_text = std::str::from_utf8(&buf[..header_end]) - .map_err(|_| DriveError::BadResponse("non-utf8 headers".into()))?; - let mut lines = header_text.split("\r\n"); - let status = parse_status_line(lines.next().unwrap_or(""))?; - let mut headers = Vec::new(); - for line in lines { - if let Some((k, v)) = line.split_once(':') { - headers.push((k.trim().to_string(), v.trim().to_string())); - } - } - - let mut body = buf[header_end + 4..].to_vec(); - let content_length = header_get(&headers, "content-length").and_then(|v| v.parse().ok()); - let is_chunked = header_get(&headers, "transfer-encoding") - .map(|v| v.to_ascii_lowercase().contains("chunked")) - .unwrap_or(false); - - if is_chunked { - body = read_chunked(stream, body).await?; - } else if let Some(len) = content_length { - while body.len() < len { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Err(DriveError::BadResponse( - "connection closed before full body".into(), - )); - } - body.extend_from_slice(&tmp[..n]); - } - body.truncate(len); - } else { - loop { - let n = stream.read(&mut tmp).await?; - if n == 0 { - break; - } - body.extend_from_slice(&tmp[..n]); - } - } - - if header_get(&headers, "content-encoding") - .map(|v| v.eq_ignore_ascii_case("gzip")) - .unwrap_or(false) - { - body = decode_gzip(&body)?; - } - - Ok(HttpResponse { status, body }) -} - -async fn read_chunked(stream: &mut S, mut buf: Vec) -> Result, DriveError> -where - S: AsyncRead + Unpin, -{ - let mut out = Vec::new(); - let mut tmp = [0u8; 8192]; - loop { - let line = read_crlf_line(stream, &mut buf, &mut tmp).await?; - let line = std::str::from_utf8(&line) - .map_err(|_| DriveError::BadResponse("bad chunk header".into()))? - .trim(); - if line.is_empty() { - continue; - } - let size = usize::from_str_radix(line.split(';').next().unwrap_or(""), 16) - .map_err(|_| DriveError::BadResponse(format!("bad chunk size '{}'", line)))?; - if size == 0 { - loop { - if read_crlf_line(stream, &mut buf, &mut tmp).await?.is_empty() { - return Ok(out); - } - } - } - while buf.len() < size + 2 { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Err(DriveError::BadResponse( - "connection closed mid-chunk".into(), - )); - } - buf.extend_from_slice(&tmp[..n]); - } - if &buf[size..size + 2] != b"\r\n" { - return Err(DriveError::BadResponse( - "chunk missing trailing CRLF".into(), - )); - } - out.extend_from_slice(&buf[..size]); - buf.drain(..size + 2); - } -} - -async fn read_crlf_line( - stream: &mut S, - buf: &mut Vec, - tmp: &mut [u8], -) -> Result, DriveError> -where - S: AsyncRead + Unpin, -{ - loop { - if let Some(pos) = buf.windows(2).position(|w| w == b"\r\n") { - let line = buf[..pos].to_vec(); - buf.drain(..pos + 2); - return Ok(line); - } - let n = stream.read(tmp).await?; - if n == 0 { - return Err(DriveError::BadResponse("connection closed mid-line".into())); - } - buf.extend_from_slice(&tmp[..n]); - } -} - -fn header_get(headers: &[(String, String)], name: &str) -> Option { - headers - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.clone()) -} - -fn find_double_crlf(buf: &[u8]) -> Option { - buf.windows(4).position(|w| w == b"\r\n\r\n") -} - -fn parse_status_line(line: &str) -> Result { - let mut parts = line.split_whitespace(); - let _version = parts.next(); - let code = parts - .next() - .ok_or_else(|| DriveError::BadResponse(format!("bad status line: {}", line)))?; - code.parse::() - .map_err(|_| DriveError::BadResponse(format!("bad status code: {}", code))) -} - -fn decode_gzip(data: &[u8]) -> Result, std::io::Error> { - let mut out = Vec::with_capacity(data.len() * 2); - flate2::read::GzDecoder::new(data).read_to_end(&mut out)?; - Ok(out) -} - #[derive(Debug)] struct NoVerify; @@ -1056,7 +948,6 @@ impl ServerCertVerifier for NoVerify { #[cfg(test)] mod tests { use super::*; - use tokio::io::AsyncWriteExt; #[test] fn url_path_escape_keeps_unreserved_and_encodes_specials() { @@ -1082,26 +973,4 @@ mod tests { assert!(parse_rfc3339("not a date").is_none()); assert!(parse_rfc3339("2024-05-13T07:21:34+02:00").is_none()); } - - #[tokio::test] - async fn read_http_response_decodes_chunked_body() { - let (mut w, mut r) = tokio::io::duplex(4096); - let body = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"; - w.write_all(body).await.unwrap(); - drop(w); - let resp = read_http_response(&mut r).await.unwrap(); - assert_eq!(resp.status, 200); - assert_eq!(&resp.body, b"hello world"); - } - - #[tokio::test] - async fn read_http_response_decodes_content_length_body() { - let (mut w, mut r) = tokio::io::duplex(4096); - let body = b"HTTP/1.1 201 Created\r\nContent-Length: 4\r\n\r\nbody"; - w.write_all(body).await.unwrap(); - drop(w); - let resp = read_http_response(&mut r).await.unwrap(); - assert_eq!(resp.status, 201); - assert_eq!(&resp.body, b"body"); - } } From e99918abba1154e6e57d08c5f18ac796c8ac3109 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:07:30 +0400 Subject: [PATCH 06/11] feat: added sharing for google drive --- .../java/com/therealaleph/mhrv/ConfigStore.kt | 186 ++++++++++++ .../com/therealaleph/mhrv/ui/HomeScreen.kt | 282 ++++++++++++++++++ .../app/src/main/res/values-fa/strings.xml | 20 ++ android/app/src/main/res/values/strings.xml | 20 ++ 4 files changed, 508 insertions(+) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index c48c731..bfbac9b 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -294,6 +294,23 @@ object ConfigStore { /** Prefix for encoded config strings so we can detect them in clipboard. */ private const val HASH_PREFIX = "mhrv-rs://" + /** Distinct prefix for the "Drive setup" share — bundles credentials + * + refresh token so a recipient can connect with no manual OAuth. + * Different from [HASH_PREFIX] because the payload includes secrets, + * the recipient flow needs to write extra files, and we don't want + * to silently fall through to the regular config import path. */ + private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://" + + /** Filename inside the app's filesDir where imported credentials are + * written. Must match what the regular Drive import flow uses, so + * a setup-import is indistinguishable from a manual import + + * authorize after the fact. */ + private const val DRIVE_CREDENTIALS_FILE = "drive-credentials.json" + + /** Token cache filename — `.token` — same shape the + * Rust side writes when a fresh OAuth dance completes. */ + private const val DRIVE_TOKEN_FILE = "drive-credentials.json.token" + /** Encode config as a shareable base64 string with prefix. * Only includes non-default fields to keep the hash short. */ fun encode(cfg: MhrvConfig): String { @@ -385,6 +402,175 @@ object ConfigStore { } } + // ----------------------------------------------------------------- + // Drive setup share — bundle credentials + refresh token + folder + // ID so a fresh device can be onboarded with one QR scan and zero + // technical steps. Distinct from [encode]/[decode] because that + // flow deliberately omits secrets; this one deliberately includes + // them and warns the sharer accordingly. + // ----------------------------------------------------------------- + + /** + * Drive-setup payload as it travels in the QR. Versioned in case we + * later rotate the bundle shape. + * + * - [credentials]: full content of credentials.json (the OAuth + * desktop client config — client_id + client_secret). + * - [refreshToken]: the cached OAuth refresh token. The recipient + * uses it directly without any browser dance. + * - [folderId] / [folderName] / [pollMs] / [flushMs] / [idleSecs] / + * [googleIp] / [frontDomain]: the same Drive-mode knobs that + * apply on the recipient. + */ + data class DriveSetup( + val credentials: String, + val refreshToken: String, + val folderId: String, + val folderName: String, + val pollMs: Int, + val flushMs: Int, + val idleSecs: Int, + val googleIp: String, + val frontDomain: String, + ) + + /** Read the on-disk credentials + token files and bundle them with + * the user's Drive config knobs into a shareable string. Returns + * null when there's nothing to share (no credentials imported, or + * no token cached yet — the sharer has to complete OAuth first). */ + fun encodeDriveSetup(ctx: Context, cfg: MhrvConfig): String? { + if (cfg.driveCredentialsPath.isBlank()) return null + val credsFile = File(cfg.driveCredentialsPath) + if (!credsFile.exists()) return null + val tokenFile = File(credsFile.absolutePath + ".token") + if (!tokenFile.exists()) return null + + val credentials = runCatching { credsFile.readText() }.getOrNull() ?: return null + val refreshToken = runCatching { + JSONObject(tokenFile.readText()).optString("refresh_token", "") + }.getOrNull().orEmpty() + if (refreshToken.isBlank()) return null + + val defaults = MhrvConfig() + val obj = JSONObject().apply { + put("v", 1) + put("credentials", credentials) + put("refresh_token", refreshToken) + if (cfg.driveFolderId.isNotBlank()) put("folder_id", cfg.driveFolderId) + if (cfg.driveFolderName != defaults.driveFolderName) put("folder_name", cfg.driveFolderName) + if (cfg.drivePollMs != defaults.drivePollMs) put("poll_ms", cfg.drivePollMs) + if (cfg.driveFlushMs != defaults.driveFlushMs) put("flush_ms", cfg.driveFlushMs) + if (cfg.driveIdleTimeoutSecs != defaults.driveIdleTimeoutSecs) put("idle_secs", cfg.driveIdleTimeoutSecs) + if (cfg.googleIp != defaults.googleIp) put("google_ip", cfg.googleIp) + if (cfg.frontDomain != defaults.frontDomain) put("front_domain", cfg.frontDomain) + } + + val raw = obj.toString().toByteArray(Charsets.UTF_8) + val compressed = java.io.ByteArrayOutputStream().also { bos -> + java.util.zip.DeflaterOutputStream(bos).use { it.write(raw) } + }.toByteArray() + val b64 = android.util.Base64.encodeToString( + compressed, + android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE, + ) + return "$DRIVE_SETUP_PREFIX$b64" + } + + /** Cheap check used to dispatch a scanned / pasted blob to the + * Drive-setup import path instead of the regular config-import + * path (the two formats look different but both base64; the prefix + * is what disambiguates). */ + fun looksLikeDriveSetup(text: String): Boolean = + text.trim().startsWith(DRIVE_SETUP_PREFIX) + + /** Decode a [DRIVE_SETUP_PREFIX] payload. Returns null if the blob + * doesn't parse, lacks required fields, or has an unsupported + * version. Does NOT touch disk — call [applyDriveSetup] to actually + * import. */ + fun decodeDriveSetup(encoded: String): DriveSetup? { + val trimmed = encoded.trim() + val payload = trimmed.removePrefix(DRIVE_SETUP_PREFIX).trim() + if (payload.isEmpty()) return null + val raw = runCatching { + android.util.Base64.decode( + payload, + android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE, + ) + }.getOrNull() ?: return null + val text = inflateOrRaw(raw) + return try { + val obj = JSONObject(text) + if (obj.optInt("v", 0) != 1) return null + val credentials = obj.optString("credentials", "") + val refreshToken = obj.optString("refresh_token", "") + if (credentials.isBlank() || refreshToken.isBlank()) return null + val defaults = MhrvConfig() + DriveSetup( + credentials = credentials, + refreshToken = refreshToken, + folderId = obj.optString("folder_id", ""), + folderName = obj.optString("folder_name", defaults.driveFolderName), + pollMs = obj.optInt("poll_ms", defaults.drivePollMs), + flushMs = obj.optInt("flush_ms", defaults.driveFlushMs), + idleSecs = obj.optInt("idle_secs", defaults.driveIdleTimeoutSecs), + googleIp = obj.optString("google_ip", defaults.googleIp), + frontDomain = obj.optString("front_domain", defaults.frontDomain), + ) + } catch (_: Throwable) { + null + } + } + + /** + * Write the credentials + token files into the app's filesDir and + * return an [MhrvConfig] reflecting the imported setup. The caller + * is responsible for [save]'ing it (we keep this side-effect-free + * apart from disk writes so callers can compose it into their own + * "import + persist + snackbar" flow). + * + * On success returns the new config. On any I/O failure returns + * null and tries to clean up partial writes — better to leave the + * recipient in the original (empty) state than half-imported. + */ + fun applyDriveSetup(ctx: Context, base: MhrvConfig, setup: DriveSetup): MhrvConfig? { + val credsFile = File(ctx.filesDir, DRIVE_CREDENTIALS_FILE) + val tokenFile = File(ctx.filesDir, DRIVE_TOKEN_FILE) + return try { + credsFile.writeText(setup.credentials) + tokenFile.writeText(JSONObject().apply { + put("refresh_token", setup.refreshToken) + }.toString()) + // Best-effort 0600. Android's FileProvider sandbox already + // walls /data/user/0//files/ off from other apps, so + // this is belt-and-braces. + runCatching { + credsFile.setReadable(false, false) + credsFile.setReadable(true, true) + credsFile.setWritable(false, false) + credsFile.setWritable(true, true) + tokenFile.setReadable(false, false) + tokenFile.setReadable(true, true) + tokenFile.setWritable(false, false) + tokenFile.setWritable(true, true) + } + base.copy( + mode = Mode.GOOGLE_DRIVE, + driveCredentialsPath = credsFile.absolutePath, + driveFolderId = setup.folderId, + driveFolderName = setup.folderName, + drivePollMs = setup.pollMs, + driveFlushMs = setup.flushMs, + driveIdleTimeoutSecs = setup.idleSecs, + googleIp = setup.googleIp, + frontDomain = setup.frontDomain, + ) + } catch (_: Throwable) { + runCatching { credsFile.delete() } + runCatching { tokenFile.delete() } + null + } + } + /** Check if a string looks like an encoded mhrv config. */ fun looksLikeConfig(text: String): Boolean { val t = text.trim() diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 64a6373..8c47a92 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -22,6 +23,8 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -29,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -1239,6 +1243,20 @@ private fun DriveSection( } } + var setupShareOpen by remember { mutableStateOf(false) } + var setupScanResult by remember { mutableStateOf(null) } + val setupScanLauncher = rememberLauncherForActivityResult( + com.journeyapps.barcodescanner.ScanContract(), + ) { result -> + val scanned = result.contents ?: return@rememberLauncherForActivityResult + val decoded = ConfigStore.decodeDriveSetup(scanned) + if (decoded != null) { + setupScanResult = decoded + } else { + scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_invalid)) } + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( stringResource(R.string.help_google_drive), @@ -1246,6 +1264,59 @@ private fun DriveSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // --------------------------------------------------------------- + // Share / Scan setup. The "Scan setup QR" path is the one-tap + // onboarding for non-technical recipients: the sharer's QR + // includes credentials + refresh token + folder ID, so the + // scanner skips file imports and the OAuth dance entirely. + // + // We render the Scan button big and primary when nothing is + // configured yet (fresh install — most likely a recipient). + // The Share button only enables once OAuth has produced a + // refresh token to share. + // --------------------------------------------------------------- + if (!cfg.driveConfigured || !hasToken) { + Button( + onClick = { + val opts = com.journeyapps.barcodescanner.ScanOptions().apply { + setDesiredBarcodeFormats(com.journeyapps.barcodescanner.ScanOptions.QR_CODE) + setPrompt(ctx.getString(R.string.dialog_drive_setup_scan_prompt)) + setBeepEnabled(false) + setOrientationLocked(true) + } + setupScanLauncher.launch(opts) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.Default.QrCodeScanner, + null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.btn_drive_scan_setup)) + } + Text( + stringResource(R.string.help_drive_scan_setup), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (hasToken) { + OutlinedButton( + onClick = { setupShareOpen = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.Default.Share, + null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.btn_drive_share_setup)) + } + } + // Credentials importer / status row. Row( verticalAlignment = Alignment.CenterVertically, @@ -1376,6 +1447,217 @@ private fun DriveSection( }, ) } + + if (setupShareOpen) { + DriveSetupShareDialog( + cfg = cfg, + onDismiss = { setupShareOpen = false }, + onSnack = { msg -> scope.launch { onSnack(msg) } }, + ) + } + + setupScanResult?.let { setup -> + DriveSetupImportConfirmDialog( + setup = setup, + onConfirm = { + val applied = ConfigStore.applyDriveSetup(ctx, cfg, setup) + if (applied != null) { + onChange(applied) + hasToken = true + scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_imported)) } + } else { + scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_failed)) } + } + setupScanResult = null + }, + onDismiss = { setupScanResult = null }, + ) + } +} + +/** + * Share-setup dialog. Renders the encoded blob as a QR code plus a + * monospace text fallback the sharer can copy/paste into a chat. The + * payload contains a refresh token and the OAuth client secret, so we + * lead with a red warning and only put the QR behind a one-tap reveal + * step — sharers don't accidentally hold their phone screen up in a + * café and have someone else scan it from across the table. + */ +@Composable +private fun DriveSetupShareDialog( + cfg: MhrvConfig, + onDismiss: () -> Unit, + onSnack: (String) -> Unit, +) { + val ctx = LocalContext.current + val clipboard = LocalClipboardManager.current + val encoded = remember(cfg) { ConfigStore.encodeDriveSetup(ctx, cfg) } + + if (encoded == null) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_drive_share_title)) }, + text = { Text(stringResource(R.string.dialog_drive_share_unavailable)) }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + }, + ) + return + } + + var revealed by remember { mutableStateOf(false) } + val qrBitmap = remember(encoded, revealed) { + if (revealed) generateSetupQr(encoded, 512) else null + } + + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.dialog_drive_share_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.dialog_drive_share_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + + if (!revealed) { + Button( + onClick = { revealed = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.btn_drive_share_reveal)) + } + } else { + if (qrBitmap != null) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "Drive setup QR", + modifier = Modifier.size(280.dp), + ) + } else { + Text( + stringResource(R.string.dialog_drive_share_qr_too_large), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalButton( + onClick = { + clipboard.setText(AnnotatedString(encoded)) + onSnack(ctx.getString(R.string.snack_drive_setup_copied)) + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.btn_copy_hash)) + } + FilledTonalButton( + onClick = { + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, encoded) + } + ctx.startActivity( + android.content.Intent.createChooser( + intent, + ctx.getString(R.string.dialog_drive_share_title), + ), + ) + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.btn_drive_share_send)) + } + } + } + + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.btn_cancel)) + } + } + } + } +} + +/** + * Confirmation dialog shown after a setup QR scan but before we touch + * disk. Reuses the same trust-prompt language as the regular config + * import; the only twist is reminding the user that this includes + * credentials, so they should only proceed for QRs they trust. + */ +@Composable +private fun DriveSetupImportConfirmDialog( + setup: ConfigStore.DriveSetup, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_drive_setup_import_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.dialog_drive_setup_import_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource( + R.string.dialog_drive_setup_import_summary, + setup.folderId.ifEmpty { "(auto)" }, + setup.folderName, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.btn_drive_setup_import)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + }, + ) +} + +/** Same QR generator the regular config-share dialog uses, copied here + * so DriveSection isn't transitively depending on ConfigSharing.kt's + * private helper. Returns null when the payload is too large for a + * single QR — caller falls back to the copy/share text path. */ +private fun generateSetupQr(content: String, size: Int): android.graphics.Bitmap? { + return try { + val writer = com.google.zxing.qrcode.QRCodeWriter() + val matrix = writer.encode(content, com.google.zxing.BarcodeFormat.QR_CODE, size, size) + val bitmap = android.graphics.Bitmap.createBitmap( + size, size, android.graphics.Bitmap.Config.RGB_565, + ) + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel( + x, y, + if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE, + ) + } + } + bitmap + } catch (_: Throwable) { + null + } } @Composable diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index 7a41982..c58bd5b 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -122,6 +122,26 @@ https://localhost/?code=4/0AdQt8q… (یا فقط همان code) URL تأیید کپی شد + + اشتراک‌گذاری تنظیمات Drive + اسکن QR تنظیمات + نمایش QR + بسته + ارسال + وارد کردن + اگر کسی QR تنظیمات Drive را با شما به اشتراک گذاشته، اینجا اسکن کنید. برنامه credentials و توکن OAuth را می‌نویسد، folder_id را تنظیم می‌کند و فقط کافی است Connect را بزنید — نیازی به Google Cloud Console یا مرورگر نیست. + اشتراک‌گذاری تنظیمات Drive + این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند به همان مقدار دسترسی به Drive شما خواهد داشت که این برنامه دارد. فقط با افراد قابل اعتماد به اشتراک بگذارید. + هنوز چیزی برای اشتراک‌گذاری وجود ندارد — ابتدا روی این دستگاه OAuth را تکمیل کنید (دکمهٔ احراز Google Drive). + حجم بسته برای QR زیاد است. از دکمه‌های کپی / ارسال برای فرستادن متن استفاده کنید. + QR تنظیمات Drive را اسکن کنید + وارد کردن تنظیمات Drive؟ + این عمل credentials فعلی Drive روی این دستگاه را بازنویسی می‌کند. فقط در صورتی ادامه دهید که QR را از فردی قابل اعتماد دریافت کرده‌اید — این به برنامه اجازهٔ دسترسی به فضای Drive ایشان را می‌دهد. + شناسهٔ پوشه: %1$s\nنام پوشه: %2$s + تنظیمات Drive وارد شد — برای شروع Connect را بزنید + وارد کردن تنظیمات Drive ناموفق بود (نوشتن فایل‌ها ممکن نشد) + QR معتبر تنظیمات Drive نیست + بستهٔ تنظیمات در کلیپ‌بورد کپی شد + ۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0894176..2ecb1cf 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -137,6 +137,26 @@ https://localhost/?code=4/0AdQt8q… (or just the code) Consent URL copied + + Share Drive setup + Scan setup QR + Show QR + payload + Send + Import + If someone shared a Drive setup QR with you, scan it here. The app will write their credentials and OAuth token, set the folder ID, and you can tap Connect — no Google Cloud Console or browser steps needed. + Share Drive setup + This QR contains your OAuth client secret AND your Drive refresh token. Anyone who scans it gets the same access to your Drive that this app has. Only share with people you trust. + Nothing to share yet — finish OAuth on this device first (Authorize Google Drive button). + Setup payload is too large for a QR code. Use the Copy / Send buttons to share the text instead. + Scan a Drive setup QR + Import Drive setup? + This will overwrite any existing Drive credentials on this device. Only proceed if the QR came from someone you trust — it grants this app access to their Google Drive app data. + Folder ID: %1$s\nFolder name: %2$s + Drive setup imported — tap Connect to start + Drive setup import failed (couldn\'t write files) + Not a valid Drive setup QR + Setup payload copied to clipboard + 1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to Downloads/mhrv-ca.crt and the Settings app opens. Use Settings\' search bar to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If every entry times out, your google_ip is unreachable — replace it with one that resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different egress IP — gets re-challenged. Nothing in this app can fix that; it\'s inherent to Apps Script as a relay. Sites that only gate the initial page load (not every request) will work after one solve. From 89e4c5f5eb51db742c3c2253175604f25d70247b Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:15:38 +0400 Subject: [PATCH 07/11] feat: better sharing --- android/app/src/main/AndroidManifest.xml | 16 +- .../com/therealaleph/mhrv/MainActivity.kt | 24 ++- .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 141 +++++++++++++++++- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 79 ++++++++++ .../app/src/main/res/values-fa/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 3 + 6 files changed, 250 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d74ca5..d1b9064 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -53,14 +53,26 @@ - + + + + + + + + diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt index 6336274..14016a6 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt @@ -95,14 +95,22 @@ class MainActivity : AppCompatActivity() { handleDeepLink(intent) } - /** Stash decoded config from deep link for the UI to confirm — never - * auto-import. The composable reads this and shows a confirmation - * dialog with the deployment IDs and a trust warning. */ + /** Stash decoded config / setup from deep link for the UI to + * confirm — never auto-import. The composable reads these state + * holders and shows a confirmation dialog before any disk write. */ private fun handleDeepLink(intent: Intent?) { val data = intent?.data ?: return - if (data.scheme != "mhrv-rs") return - val cfg = ConfigStore.decode(data.toString()) ?: return - pendingDeepLinkConfig.value = cfg + when (data.scheme) { + "mhrv-rs" -> { + val cfg = ConfigStore.decode(data.toString()) ?: return + pendingDeepLinkConfig.value = cfg + } + "mhrv-rs-setup" -> { + val setup = ConfigStore.decodeDriveSetup(data.toString()) ?: return + pendingDeepLinkSetup.value = setup + } + else -> {} + } } @@ -257,5 +265,9 @@ class MainActivity : AppCompatActivity() { private const val REQ_NOTIF = 42 /** Deep link config waiting for user confirmation. Read by ConfigSharingBar. */ val pendingDeepLinkConfig = mutableStateOf(null) + /** Deep link Drive-setup payload waiting for user confirmation. + * Read by ConfigSharingBar; consumed in HomeScreen which knows + * how to wire it through ConfigStore.applyDriveSetup. */ + val pendingDeepLinkSetup = mutableStateOf(null) } } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt index e3e2e86..e38547c 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt @@ -43,9 +43,13 @@ import kotlinx.coroutines.launch fun ConfigSharingBar( cfg: MhrvConfig, onImport: (MhrvConfig) -> Unit, + onImportDriveSetup: (ConfigStore.DriveSetup) -> Unit, onSnackbar: suspend (String) -> Unit, ) { - // Deep link import — requires confirmation before applying. + // Deep link imports — both regular config and Drive-setup deep + // links land here and get a confirmation dialog before any + // mutation, identical to clipboard / QR-scan paths. Trust prompt + // is the same for all three input vectors. val deepLinkCfg by com.therealaleph.mhrv.MainActivity.pendingDeepLinkConfig if (deepLinkCfg != null) { ImportConfirmDialog( @@ -59,32 +63,96 @@ fun ConfigSharingBar( }, ) } + val deepLinkSetup by com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup + if (deepLinkSetup != null) { + DriveSetupConfirmDialog( + setup = deepLinkSetup!!, + onConfirm = { + onImportDriveSetup(deepLinkSetup!!) + com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup.value = null + }, + onDismiss = { + com.therealaleph.mhrv.MainActivity.pendingDeepLinkSetup.value = null + }, + ) + } val ctx = LocalContext.current val clipboard = LocalClipboardManager.current val scope = rememberCoroutineScope() val clipText = clipboard.getText()?.text.orEmpty() val hasConfigInClipboard = clipText.isNotEmpty() && ConfigStore.looksLikeConfig(clipText) + val hasDriveSetupInClipboard = clipText.isNotEmpty() && ConfigStore.looksLikeDriveSetup(clipText) var showExportDialog by remember { mutableStateOf(false) } var showImportConfirm by remember { mutableStateOf(false) } var pendingImport by remember { mutableStateOf(null) } + var pendingDriveSetup by remember { mutableStateOf(null) } var showQrDialog by remember { mutableStateOf(false) } // QR scanner launcher — fires the ZXing embedded scanner activity. + // Dispatches based on payload prefix: regular config vs Drive setup. val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val scanned = result.contents ?: return@rememberLauncherForActivityResult - val decoded = ConfigStore.decode(scanned) - if (decoded != null) { - pendingImport = decoded - showImportConfirm = true + if (ConfigStore.looksLikeDriveSetup(scanned)) { + val setup = ConfigStore.decodeDriveSetup(scanned) + if (setup != null) { + pendingDriveSetup = setup + } else { + scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_invalid)) } + } } else { - scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) } + val decoded = ConfigStore.decode(scanned) + if (decoded != null) { + pendingImport = decoded + showImportConfirm = true + } else { + scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) } + } } } - // --- Paste from clipboard banner --- - if (hasConfigInClipboard) { + // --- Paste from clipboard banner (regular config OR Drive setup) --- + // Drive-setup blob takes precedence — it's a more specific format + // and we want to surface it immediately when a fresh recipient + // pastes a `mhrv-rs-setup://...` link from WhatsApp. + if (hasDriveSetupInClipboard) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.banner_drive_setup_clipboard), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.weight(1f), + ) + FilledTonalButton( + onClick = { + val setup = ConfigStore.decodeDriveSetup(clipText) + if (setup != null) { + pendingDriveSetup = setup + } else { + scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_invalid)) } + } + }, + ) { + Icon(Icons.Default.ContentPaste, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.btn_import_clipboard)) + } + } + } + } else if (hasConfigInClipboard) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -272,6 +340,63 @@ fun ConfigSharingBar( }, ) } + + // --- Drive setup confirmation dialog (clipboard / QR / deep link) --- + pendingDriveSetup?.let { setup -> + DriveSetupConfirmDialog( + setup = setup, + onConfirm = { + onImportDriveSetup(setup) + clipboard.setText(AnnotatedString("")) + pendingDriveSetup = null + scope.launch { onSnackbar(ctx.getString(R.string.snack_drive_setup_imported)) } + }, + onDismiss = { pendingDriveSetup = null }, + ) + } +} + +/** + * Trust prompt before applying an `mhrv-rs-setup://...` payload. Same + * shape as the regular import confirm dialog, with copy that calls out + * the credential-bearing nature of the bundle so the user understands + * what they're accepting. + */ +@Composable +internal fun DriveSetupConfirmDialog( + setup: ConfigStore.DriveSetup, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_drive_setup_import_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.dialog_drive_setup_import_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource( + R.string.dialog_drive_setup_import_summary, + setup.folderId.ifEmpty { "(auto)" }, + setup.folderName, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.btn_drive_setup_import)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + }, + ) } // ========================================================================= diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 8c47a92..dd8fb50 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -261,6 +261,13 @@ fun HomeScreen( ConfigSharingBar( cfg = cfg, onImport = { persist(it) }, + onImportDriveSetup = { setup -> + val applied = ConfigStore.applyDriveSetup(ctx, cfg, setup) + if (applied != null) persist(applied) + else scope.launch { + snackbar.showSnackbar(ctx.getString(R.string.snack_drive_setup_failed)) + } + }, onSnackbar = { snackbar.showSnackbar(it) }, ) @@ -1256,6 +1263,30 @@ private fun DriveSection( scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_invalid)) } } } + // Gallery image picker → decode any QR payload found in the + // chosen image. Cheap fallback for the WhatsApp-receives-image + // case: long-press the QR in chat → save to gallery → tap this + // button → pick the saved image. No need to point one phone at + // another's screen. + val setupImagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val decoded = decodeQrFromImage(ctx, uri) + when { + decoded == null -> scope.launch { + onSnack(ctx.getString(R.string.snack_drive_setup_no_qr_in_image)) + } + else -> { + val setup = ConfigStore.decodeDriveSetup(decoded) + if (setup != null) { + setupScanResult = setup + } else { + scope.launch { onSnack(ctx.getString(R.string.snack_drive_setup_invalid)) } + } + } + } + } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -1296,6 +1327,15 @@ private fun DriveSection( Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.btn_drive_scan_setup)) } + // Gallery picker — for the case where someone messaged a + // QR image instead of letting it be scanned camera-to-camera. + // Long-press in WhatsApp → Save → tap this → pick the file. + OutlinedButton( + onClick = { setupImagePicker.launch("image/*") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.btn_drive_setup_from_image)) + } Text( stringResource(R.string.help_drive_scan_setup), style = MaterialTheme.typography.labelSmall, @@ -1635,6 +1675,45 @@ private fun DriveSetupImportConfirmDialog( ) } +/** + * Decode a QR code embedded in a static image (gallery-picked content + * URI). Returns the decoded text payload or null if no QR was found, + * the image couldn't be loaded, or zxing failed to parse. + * + * Uses `BinaryBitmap(HybridBinarizer)` over the bitmap pixels because + * that's what zxing's reference Android samples do — handles screenshots + * with anti-aliasing, JPEG artefacts, etc. better than the global + * binarizer. Tries inverted colours as a fallback so dark-mode QRs + * (white on black) still decode. + */ +private fun decodeQrFromImage( + ctx: android.content.Context, + uri: android.net.Uri, +): String? { + val bitmap = runCatching { + ctx.contentResolver.openInputStream(uri)?.use { + android.graphics.BitmapFactory.decodeStream(it) + } + }.getOrNull() ?: return null + + val width = bitmap.width + val height = bitmap.height + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + val source = com.google.zxing.RGBLuminanceSource(width, height, pixels) + val reader = com.google.zxing.qrcode.QRCodeReader() + + fun tryDecode(src: com.google.zxing.LuminanceSource): String? = runCatching { + val binary = com.google.zxing.BinaryBitmap( + com.google.zxing.common.HybridBinarizer(src), + ) + reader.decode(binary).text + }.getOrNull() + + return tryDecode(source) ?: tryDecode(source.invert()) +} + /** Same QR generator the regular config-share dialog uses, copied here * so DriveSection isn't transitively depending on ConfigSharing.kt's * private helper. Returns null when the payload is too large for a diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index c58bd5b..e46f661 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -141,6 +141,9 @@ وارد کردن تنظیمات Drive ناموفق بود (نوشتن فایل‌ها ممکن نشد) QR معتبر تنظیمات Drive نیست بستهٔ تنظیمات در کلیپ‌بورد کپی شد + تنظیمات Drive در کلیپ‌بورد یافت شد + انتخاب تصویر QR از گالری + هیچ QR در آن تصویر یافت نشد ۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2ecb1cf..38e7077 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -156,6 +156,9 @@ Drive setup import failed (couldn\'t write files) Not a valid Drive setup QR Setup payload copied to clipboard + Drive setup detected in clipboard + Pick QR image from gallery + No QR code found in that image 1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to Downloads/mhrv-ca.crt and the Settings app opens. Use Settings\' search bar to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If every entry times out, your google_ip is unreachable — replace it with one that resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different egress IP — gets re-challenged. Nothing in this app can fix that; it\'s inherent to Apps Script as a relay. Sites that only gate the initial page load (not every request) will work after one solve. From 59dd7c5687ef81da3e250a05d0736abd2fcd1567 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:16:38 +0400 Subject: [PATCH 08/11] =?UTF-8?q?chore(drive):=20address=20review=20feedba?= =?UTF-8?q?ck=20=E2=80=94=20quota=20counter,=20orphan=20reap,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +++++++++++ src/drive_tunnel.rs | 36 ++++++++++++++ src/google_drive.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/README.md b/README.md index bda05b3..918f8bb 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,33 @@ Tune `drive_idle_timeout_secs` (default 300) upward if you tunnel long-poll HTTP > **Security note:** `mhrv-drive-node` is effectively an open TCP relay for whoever has read/write access to the shared Drive folder — anything that can drop a `req-…mux-…bin` file in there can open arbitrary `host:port` connections through the node. Keep the folder narrowly scoped (one OAuth account, no link sharing) and don't run the node on a machine you don't control. +### Onboarding a non-technical user (Android) + +Once one device has finished OAuth, you can hand the configured state to another via QR or text — no Cloud Console steps required on the receiving end. In the Drive section: **Share Drive setup** → **Show QR + payload** → copy / send the `mhrv-rs-setup://...` link via WhatsApp / Telegram / SMS. The recipient pastes the link, scans the QR, picks the QR image from their gallery, or just taps the link if their messenger linkifies it. The bundle includes the OAuth refresh token, so they don't run their own consent flow — they share the sharer's Google identity for `drive.file` scope. + +Caveat: the **sharer** still needs an unfiltered path to `accounts.google.com` for the initial OAuth dance, since the consent page opens in their system browser. If your network blocks Google Accounts, do the initial OAuth on a different network (mobile data, friend's Wi-Fi) and then share the resulting setup. Recipients aren't bound by this — they get the refresh token via the QR. + +When the consent page warns _"Google hasn't verified this app"_, that's expected for personal Cloud projects in **Testing** publication status. Click **Advanced → Go to mhrv-drive (unsafe)** → grant the `drive.file` scope. Same flow as deploying an Apps Script for the existing modes. + +### Quota and reachability + +Google Drive's free-tier per-user quota is **1,000 requests per 100 seconds**. Default `drive_poll_ms = 100` plus `drive_flush_ms = 100` is comfortably below that even under heavy traffic, but if you turn polling down further or run a single OAuth identity across many devices you can blow it. The Rust side logs a `WARN` at 80% and `ERROR`s past 100% — watch for `Drive API rate climbing` in the logs. Bump `drive_poll_ms` / `drive_flush_ms` if you see them. + +Before deploying, sanity-check that your network can actually reach Drive's edge IPs. The most informative test (from the host that will run `mhrv-drive-node` or the client): + +```bash +curl --resolve www.googleapis.com:443:216.239.38.120 \ + -I https://www.googleapis.com/drive/v3/files +``` + +A 401 response (no auth) is success — it means TCP reached Google and the TLS handshake completed. A connect timeout, RST, or TLS error means the same DPI / RST-injection path that affects the Apps Script outbound also hits Drive's API endpoint, and this mode won't work better than the existing Apps Script ones on that network. + +### Garbage collection + +Both sides reap their own files via `cleanup_loop` (every 5 s, deletes own files older than `OLD_FILE_TTL = 60 s` using Drive's `createdTime` so cross-machine clock skew can't false-positive). The poll path also auto-deletes peer files older than `STARTUP_STALE_TTL = 5 min` that look like leftovers from a previous run, plus reaps orphan response files for our own client ID at the same TTL — covers the edge case where `mhrv-drive-node` dies mid-batch and can't run its own cleanup. + +If you ever notice `MHRV-Drive` accumulating files past these windows, check the Live logs / Docker logs on both sides for poll errors that prevent the cleanup loop from firing. + ## Running on OpenWRT (or any musl distro) The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpine, and any libc-less Linux userland. Put the binary on the router and start it as a service: diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs index 24ea5d4..1ea7c95 100644 --- a/src/drive_tunnel.rs +++ b/src/drive_tunnel.rs @@ -646,6 +646,42 @@ impl DriveEngine { } } } + + // Reap orphan peer files. Normal flow has each side + // deleting its own files via `cleanup_loop` above plus the + // `processed`-then-delete path in `poll_once`. The edge + // case is the peer dying mid-batch: a `res-*` file it + // wrote remains in the folder, the dead node can't run + // its own cleanup, and our own cleanup above only + // touches files matching our `my_dir` prefix. Without + // the block below, those orphans accumulate forever. + // + // Scoped to `--mux-` so a single + // client sharing a folder with several others doesn't + // touch their in-flight files. Uses STARTUP_STALE_TTL + // (5 min) — much longer than the per-file lifetime in + // normal operation, so this only fires on the orphan + // case; a slow round-trip won't trip it. + let orphan_prefix = format!( + "{}-{}-mux-", + self.peer_dir.as_str(), + self.client_id, + ); + if !self.client_id.is_empty() { + if let Ok(orphans) = self.backend.list_query(&orphan_prefix).await { + if let Some(orphan_cutoff) = + SystemTime::now().checked_sub(STARTUP_STALE_TTL) + { + for file in orphans { + if let Some(created) = file.created_time { + if created < orphan_cutoff { + let _ = self.backend.delete(&file.name).await; + } + } + } + } + } + } } } } diff --git a/src/google_drive.rs b/src/google_drive.rs index c13100e..0553010 100644 --- a/src/google_drive.rs +++ b/src/google_drive.rs @@ -9,9 +9,18 @@ use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +/// Google Drive's free-tier quota is 1000 requests / 100 s / user. We +/// surface a warning at 80% and an error past 100% so operators see +/// quota pressure in logs before Drive starts handing back 403s. The +/// per-project ceiling (10k / 100s / project) is shared across all +/// users of an OAuth client and isn't observable from one client. +const DRIVE_QUOTA_PER_USER_100S: u64 = 1000; +const DRIVE_QUOTA_WARN_THRESHOLD: u64 = (DRIVE_QUOTA_PER_USER_100S * 80) / 100; + use bytes::Bytes; use http_body_util::{BodyExt, Full}; use hyper::client::conn::http2::SendRequest; @@ -34,6 +43,90 @@ const GOOGLE_API_HOST: &str = "www.googleapis.com"; const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file"; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); +/// Lock-free counter of Drive REST calls within a 100-second sliding +/// bucket. The bucket boundary is best-effort — a request right at the +/// 100s mark may land in the previous or next window, which is fine +/// since Google's quota is also approximate. Used purely for logging +/// and the [`QuotaSnapshot`] surface; doesn't gate or rate-limit. +#[derive(Debug)] +pub struct QuotaTracker { + start: Instant, + bucket_start_secs: AtomicU64, + bucket_count: AtomicU64, + total: AtomicU64, +} + +impl QuotaTracker { + fn new() -> Self { + Self { + start: Instant::now(), + bucket_start_secs: AtomicU64::new(0), + bucket_count: AtomicU64::new(0), + total: AtomicU64::new(0), + } + } + + /// Bump on every Drive REST call. Returns the count for the + /// current 100-second window so callers can decide if the rate + /// looks scary. Logs a warning at 80% of the per-user quota and + /// an error past 100%, throttled to once per 50 calls past the + /// limit so we don't spam the log under sustained overrun. + fn record_call(&self) -> u64 { + let now_secs = self.start.elapsed().as_secs(); + let bucket = self.bucket_start_secs.load(Ordering::Relaxed); + if now_secs.saturating_sub(bucket) >= 100 { + // Rolling window: stale bucket → reset. Race-prone in the + // strict sense (two threads can both reset) but the + // off-by-one calls don't matter for a logging counter. + self.bucket_start_secs.store(now_secs, Ordering::Relaxed); + self.bucket_count.store(0, Ordering::Relaxed); + } + let count = self.bucket_count.fetch_add(1, Ordering::Relaxed) + 1; + self.total.fetch_add(1, Ordering::Relaxed); + if count == DRIVE_QUOTA_WARN_THRESHOLD { + tracing::warn!( + "Drive API rate climbing: {}/100s — free-tier limit is {}/100s/user. \ + Consider increasing drive_poll_ms / drive_flush_ms to slow down.", + count, + DRIVE_QUOTA_PER_USER_100S, + ); + } else if count >= DRIVE_QUOTA_PER_USER_100S && count.is_multiple_of(50) { + tracing::error!( + "Drive API rate {}/100s — exceeded free-tier per-user quota ({}/100s). \ + Expect 403/429 responses. Drive returns these with no Retry-After, so \ + the caller has to back off itself.", + count, + DRIVE_QUOTA_PER_USER_100S, + ); + } + count + } + + /// Snapshot of the live counters for UI display. Cheap (atomic + /// loads only, no allocation). + pub fn snapshot(&self) -> QuotaSnapshot { + QuotaSnapshot { + total: self.total.load(Ordering::Relaxed), + current_window: self.bucket_count.load(Ordering::Relaxed), + window_secs: 100, + quota_per_user: DRIVE_QUOTA_PER_USER_100S, + } + } +} + +/// Read-only view of the quota counter for UI / status surfaces. +/// `current_window` is the count of API calls in the most recent +/// 100-second bucket; `quota_per_user` is the documented free-tier +/// limit. Workspace / paid Cloud projects get higher ceilings, but +/// without knowing the user's project tier we display the floor. +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +pub struct QuotaSnapshot { + pub total: u64, + pub current_window: u64, + pub window_secs: u64, + pub quota_per_user: u64, +} + #[derive(Debug, thiserror::Error)] pub enum DriveError { #[error("io: {0}")] @@ -73,6 +166,10 @@ struct GoogleApiClient { /// The live HTTP/2 sender. `None` until first use, replaced if a /// request fails because the connection went away. sender: Mutex>>>, + /// Per-process Drive REST call counter. Wrapped in `Arc` so the + /// outer [`GoogleDriveBackend`] can hand snapshots to the UI + /// without holding a lock on this client. + quota: Arc, } struct HttpResponse { @@ -105,6 +202,7 @@ impl GoogleApiClient { host_header, tls_connector: TlsConnector::from(Arc::new(tls_config)), sender: Mutex::new(None), + quota: Arc::new(QuotaTracker::new()), } } @@ -156,6 +254,12 @@ impl GoogleApiClient { headers: Vec<(String, String)>, body: &[u8], ) -> Result { + // Tick the rate counter on every Drive REST call. This is what + // surfaces "you're about to hit the free-tier quota" warnings + // in the log without any UI work, and what feeds [`quota()`] + // for surfaces that want to display it. + self.quota.record_call(); + // Build the request once. The :authority pseudo-header is what // routes the request inside Google's HTTP/2 frontend; it must be // the API host even though we're connected via SNI=front_domain. @@ -776,6 +880,14 @@ impl GoogleDriveBackend { pub fn credentials_path(&self) -> &PathBuf { &self.credentials_path } + + /// Snapshot of the Drive API rate counter — total calls since + /// process start plus the count in the most recent 100-second + /// window. Used by stats / status surfaces that want to render a + /// quota meter; cheap (atomic loads only). + pub fn quota_snapshot(&self) -> QuotaSnapshot { + self.api.quota.snapshot() + } } fn http_error(resp: HttpResponse) -> DriveError { From c093bf1ba9a1fb6c9bde7fb4e70ed09712ed5b9a Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:30:58 +0400 Subject: [PATCH 09/11] fix(drive): sync backend validation in startDriveProxy + serde_json for JNI replies --- src/android_jni.rs | 206 ++++++++++++++++++++++++-------------------- src/drive_tunnel.rs | 23 +++++ 2 files changed, 135 insertions(+), 94 deletions(-) diff --git a/src/android_jni.rs b/src/android_jni.rs index 1f5e8c2..f69615d 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -313,11 +313,38 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startDriveProxy( } }; + // Validate the Drive backend SYNCHRONOUSLY before we hand a + // handle back to Kotlin. Loads credentials.json, refreshes the + // OAuth access token, and ensures the target folder exists — + // any of which can fail (no token cached, expired refresh, + // network unreachable, folder ID typo, ...). Without this + // up-front validation, startDriveProxy would return a non-zero + // handle even on credential failure, leaving Kotlin with a + // dead service holding a zombie handle. + // + // The runtime is the same one the listener will run on, so + // the HTTP/2 connection task spawned during `build_backend` + // stays alive after we return. + let backend = match rt.block_on(crate::drive_tunnel::build_backend(&config)) { + Ok(b) => b, + Err(e) => { + tracing::error!("android: drive backend init failed: {}", e); + // Drop the runtime explicitly — `build_backend` may have + // spawned the HTTP/2 driver task that we no longer need. + rt.shutdown_timeout(std::time::Duration::from_secs(1)); + return 0i64; + } + }; + let (tx, rx) = oneshot::channel::<()>(); let cfg_for_task = config; rt.spawn(async move { - if let Err(e) = - crate::drive_tunnel::run_client_with_shutdown(&cfg_for_task, rx).await + if let Err(e) = crate::drive_tunnel::run_client_with_backend( + &cfg_for_task, + backend, + rx, + ) + .await { tracing::error!("android: drive client exited: {}", e); } @@ -386,52 +413,32 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveCompleteAuth<'a>( config_json: JString, code: JString, ) -> jstring { - let result_json = safe( - r#"{"ok":false,"error":"panic"}"#.to_string(), - AssertUnwindSafe(|| { - install_logging_once(); - let json = jstring_to_string(&mut env, &config_json); - let raw = jstring_to_string(&mut env, &code); - if raw.trim().is_empty() { - return r#"{"ok":false,"error":"empty code"}"#.to_string(); - } - let config: Config = match serde_json::from_str(&json) { - Ok(c) => c, - Err(e) => { - return format!( - r#"{{"ok":false,"error":"bad config json: {}"}}"#, - json_escape(&e.to_string()) - ); - } - }; - let backend = - match crate::google_drive::GoogleDriveBackend::from_config(&config) { - Ok(b) => b, - Err(e) => { - return format!( - r#"{{"ok":false,"error":"{}"}}"#, - json_escape(&e.to_string()) - ); - } - }; - let Some(rt) = one_shot_runtime() else { - return r#"{"ok":false,"error":"tokio init failed"}"#.to_string(); - }; - match rt.block_on(backend.apply_auth_code(&raw)) { - Ok(()) => { - let path = backend.token_path().display().to_string(); - format!( - r#"{{"ok":true,"tokenPath":"{}"}}"#, - json_escape(&path) - ) - } - Err(e) => format!( - r#"{{"ok":false,"error":"{}"}}"#, - json_escape(&e.to_string()) - ), + let result_json = safe(error_json("panic"), AssertUnwindSafe(|| { + install_logging_once(); + let json = jstring_to_string(&mut env, &config_json); + let raw = jstring_to_string(&mut env, &code); + if raw.trim().is_empty() { + return error_json("empty code"); + } + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => return error_json(&format!("bad config json: {}", e)), + }; + let backend = match crate::google_drive::GoogleDriveBackend::from_config(&config) { + Ok(b) => b, + Err(e) => return error_json(&e.to_string()), + }; + let Some(rt) = one_shot_runtime() else { + return error_json("tokio init failed"); + }; + match rt.block_on(backend.apply_auth_code(&raw)) { + Ok(()) => { + let path = backend.token_path().display().to_string(); + serde_json::json!({"ok": true, "tokenPath": path}).to_string() } - }), - ); + Err(e) => error_json(&e.to_string()), + } + })); env.new_string(result_json) .map(|s| s.into_raw()) .unwrap_or(std::ptr::null_mut()) @@ -466,8 +473,12 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_driveTokenPresent( })) } -fn json_escape(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") +/// Build a `{"ok": false, "error": ""}` blob with proper JSON +/// escaping. Wraps `serde_json::json!` so callers don't need to spell +/// out the shape on every error site, and so a stray `\n` / `"` in an +/// error message can't poison the parser on the Kotlin side. +fn error_json(msg: &str) -> String { + serde_json::json!({"ok": false, "error": msg}).to_string() } /// `Native.stopProxy(long handle)` -> boolean. Idempotent: calling on an @@ -583,50 +594,58 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>( env: JNIEnv<'a>, _class: JClass, ) -> jstring { - let result_json = safe( - r#"{"kind":"error","reason":"panic"}"#.to_string(), - AssertUnwindSafe(|| { - install_logging_once(); - let Some(rt) = one_shot_runtime() else { - return r#"{"kind":"error","reason":"tokio init failed"}"#.to_string(); - }; - let outcome = rt.block_on(crate::update_check::check( - crate::update_check::Route::Direct, - )); - update_check_to_json(&outcome) - }), - ); + // Default-on-panic value uses the same `kind`/`reason` shape the + // happy path produces so the Kotlin parser doesn't need a special + // case for unwind crashes. + let panic_default = serde_json::json!({"kind": "error", "reason": "panic"}).to_string(); + let result_json = safe(panic_default, AssertUnwindSafe(|| { + install_logging_once(); + let Some(rt) = one_shot_runtime() else { + return serde_json::json!({"kind": "error", "reason": "tokio init failed"}) + .to_string(); + }; + let outcome = + rt.block_on(crate::update_check::check(crate::update_check::Route::Direct)); + update_check_to_json(&outcome) + })); env.new_string(result_json) .map(|s| s.into_raw()) .unwrap_or(std::ptr::null_mut()) } fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String { - // Hand-serialized to keep the JNI side free of serde derive noise on - // the inner enum (which would need `#[derive(Serialize)]`). Short - // enough that the hand-rolled version is simpler than pulling - // serde_json in here for one call. - fn esc(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") - } - match u { - crate::update_check::UpdateCheck::UpToDate { current, latest } => format!( - r#"{{"kind":"upToDate","current":"{}","latest":"{}"}}"#, - esc(current), esc(latest), - ), - crate::update_check::UpdateCheck::UpdateAvailable { current, latest, release_url, .. } => format!( - r#"{{"kind":"updateAvailable","current":"{}","latest":"{}","url":"{}"}}"#, - esc(current), esc(latest), esc(release_url), - ), - crate::update_check::UpdateCheck::Offline(reason) => format!( - r#"{{"kind":"offline","reason":"{}"}}"#, - esc(reason), - ), - crate::update_check::UpdateCheck::Error(reason) => format!( - r#"{{"kind":"error","reason":"{}"}}"#, - esc(reason), - ), - } + // serde_json::json! handles all the JSON escaping (control chars, + // backslashes, embedded quotes, non-BMP code points) in one go; + // the hand-rolled escaper that lived here only handled `\\` and + // `"`, so a `\n` in an offline reason or release-note URL would + // produce malformed JSON the Kotlin side couldn't parse. + let value = match u { + crate::update_check::UpdateCheck::UpToDate { current, latest } => serde_json::json!({ + "kind": "upToDate", + "current": current, + "latest": latest, + }), + crate::update_check::UpdateCheck::UpdateAvailable { + current, + latest, + release_url, + .. + } => serde_json::json!({ + "kind": "updateAvailable", + "current": current, + "latest": latest, + "url": release_url, + }), + crate::update_check::UpdateCheck::Offline(reason) => serde_json::json!({ + "kind": "offline", + "reason": reason, + }), + crate::update_check::UpdateCheck::Error(reason) => serde_json::json!({ + "kind": "error", + "reason": reason, + }), + }; + value.to_string() } /// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob @@ -639,21 +658,21 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( google_ip: JString, sni: JString, ) -> jstring { - let result_json = safe(r#"{"ok":false,"error":"panic"}"#.to_string(), AssertUnwindSafe(|| { + let result_json = safe(error_json("panic"), AssertUnwindSafe(|| { install_logging_once(); let ip = jstring_to_string(&mut env, &google_ip); let s = jstring_to_string(&mut env, &sni); if ip.is_empty() || s.is_empty() { - return r#"{"ok":false,"error":"empty google_ip or sni"}"#.to_string(); + return error_json("empty google_ip or sni"); } let Some(rt) = one_shot_runtime() else { - return r#"{"ok":false,"error":"tokio init failed"}"#.to_string(); + return error_json("tokio init failed"); }; let probe = rt.block_on(crate::scan_sni::probe_one(&ip, &s)); match (probe.latency_ms, probe.error) { (Some(ms), _) => { tracing::info!("sni_probe: {} via {} ok in {}ms", s, ip, ms); - format!(r#"{{"ok":true,"latencyMs":{}}}"#, ms) + serde_json::json!({"ok": true, "latencyMs": ms}).to_string() } (None, Some(e)) => { // Surface the reason in logcat too — otherwise users see a @@ -662,10 +681,9 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( // - "connect: ..." -> TCP to google_ip:443 blocked // - "handshake: ..." -> TLS fail (cert, ALPN, etc.) tracing::warn!("sni_probe: {} via {} FAIL: {}", s, ip, e); - let cleaned = e.replace('\\', "\\\\").replace('"', "\\\""); - format!(r#"{{"ok":false,"error":"{}"}}"#, cleaned) + error_json(&e) } - _ => r#"{"ok":false,"error":"unknown"}"#.to_string(), + _ => error_json("unknown"), } })); env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs index 1ea7c95..9ab3b99 100644 --- a/src/drive_tunnel.rs +++ b/src/drive_tunnel.rs @@ -751,6 +751,20 @@ pub async fn run_client_with_shutdown( shutdown: tokio::sync::oneshot::Receiver<()>, ) -> Result<(), DriveError> { let backend = init_backend(config).await?; + run_client_with_backend(config, backend, shutdown).await +} + +/// Run the SOCKS5 client side with a pre-built (and pre-validated) Drive +/// backend. JNI / UI entry points use this so OAuth refresh + folder +/// discovery can happen synchronously up front and surface any failure +/// before they commit to spawning the listener task. The runtime that +/// drives this future must be the same one the `backend` was built +/// against — its HTTP/2 connection task is already attached to it. +pub async fn run_client_with_backend( + config: &Config, + backend: Arc, + shutdown: tokio::sync::oneshot::Receiver<()>, +) -> Result<(), DriveError> { let client_id = if config.drive_client_id.trim().is_empty() { random_hex(4) } else { @@ -790,6 +804,15 @@ pub async fn run_client_with_shutdown( } } +/// Build and validate a Drive backend (loads credentials JSON, refreshes +/// the OAuth access token, ensures the target folder exists). Surfaces +/// any failure synchronously so JNI / UI callers can early-return +/// before spawning long-lived state. Public so it can be shared by the +/// CLI and the JNI entry points. +pub async fn build_backend(config: &Config) -> Result, DriveError> { + init_backend(config).await +} + pub async fn run_server(config: &Config) -> Result<(), DriveError> { let backend = init_backend(config).await?; let (new_tx, mut new_rx) = mpsc::channel(1024); From a216fb2f45d49f453455f218972deb45cfc19465 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:44:40 +0400 Subject: [PATCH 10/11] fix(drive): rollback uploads, tighten validation, dedupe helpers --- Dockerfile.drive-node | 9 +- README.md | 6 +- android/app/src/main/AndroidManifest.xml | 16 +- .../java/com/therealaleph/mhrv/ConfigStore.kt | 35 +-- .../app/src/main/res/values-fa/strings.xml | 2 +- android/app/src/main/res/values/strings.xml | 2 +- src/bin/ui.rs | 54 +++- src/config.rs | 40 ++- src/drive_tunnel.rs | 257 +++++++++++++++--- src/google_drive.rs | 61 ++++- 10 files changed, 394 insertions(+), 88 deletions(-) diff --git a/Dockerfile.drive-node b/Dockerfile.drive-node index e4111a0..105792e 100644 --- a/Dockerfile.drive-node +++ b/Dockerfile.drive-node @@ -18,10 +18,11 @@ # refresh token, chmod 0600) into the same dir on successful OAuth. # ---- builder ------------------------------------------------------------ -# Need >= 1.85 for the edition2024 stabilization that time-macros (and a -# few other transitive deps in our lockfile) now require. `rust:1` always -# points at the latest 1.x stable — fine for a build image we throw away. -FROM rust:1-slim-bookworm AS builder +# Pin the Rust toolchain so a future `rust:1` retag (or a transitive dep +# bumping its MSRV) can't silently break this image. Need >= 1.85 for +# the edition2024 stabilization that time-macros and a few other +# transitive deps in our lockfile now require; bump deliberately. +FROM rust:1.85-slim-bookworm AS builder WORKDIR /src # `ring` (TLS backend) needs a C compiler + assembler. Everything else is diff --git a/README.md b/README.md index 918f8bb..e8bed0e 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,11 @@ Tune `drive_idle_timeout_secs` (default 300) upward if you tunnel long-poll HTTP ### Onboarding a non-technical user (Android) -Once one device has finished OAuth, you can hand the configured state to another via QR or text — no Cloud Console steps required on the receiving end. In the Drive section: **Share Drive setup** → **Show QR + payload** → copy / send the `mhrv-rs-setup://...` link via WhatsApp / Telegram / SMS. The recipient pastes the link, scans the QR, picks the QR image from their gallery, or just taps the link if their messenger linkifies it. The bundle includes the OAuth refresh token, so they don't run their own consent flow — they share the sharer's Google identity for `drive.file` scope. +Once one device has finished OAuth, you can hand the configured state to another via QR or text — no Cloud Console steps required on the receiving end. In the Drive section: **Share Drive setup** → **Show QR + payload** → copy / send the `mhrv-rs-setup://import/...` link via WhatsApp / Telegram / SMS. The recipient pastes the link, scans the QR, picks the QR image from their gallery, or just taps the link if their messenger linkifies it. The bundle includes the OAuth refresh token, so they don't run their own consent flow — they share the sharer's Google identity for `drive.file` scope. + +> **Read this before you share.** The setup blob bundles the OAuth `client_secret` AND a long-lived refresh token. Anything that can read the QR / link — a chat backup, a screenshot synced to cloud, a compromised device — gets the same `drive.file` access this app has, indefinitely. There is no per-recipient revoke: the only way to invalidate a leaked share is to rotate (or delete) the OAuth client in Google Cloud Console, which also kicks every device you've already onboarded with that client. Treat the share like a long-lived password: keep the recipient list small, prefer scanning camera-to-camera over messengers, and rotate the OAuth client on a schedule if the same identity is shared widely. +> +> If you want per-device revocation without a Cloud Console round-trip, do the OAuth flow separately on each device instead of using setup-share — refresh tokens minted from independent consent flows can be revoked one at a time from your Google Account's "Third-party apps with account access" page. Caveat: the **sharer** still needs an unfiltered path to `accounts.google.com` for the initial OAuth dance, since the consent page opens in their system browser. If your network blocks Google Accounts, do the initial OAuth on a different network (mobile data, friend's Wi-Fi) and then share the resulting setup. Recipients aren't bound by this — they get the refresh token via the QR. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d1b9064..40b034b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -61,17 +61,19 @@ - + - + diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index bfbac9b..fd22fa3 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -298,8 +298,13 @@ object ConfigStore { * + refresh token so a recipient can connect with no manual OAuth. * Different from [HASH_PREFIX] because the payload includes secrets, * the recipient flow needs to write extra files, and we don't want - * to silently fall through to the regular config import path. */ - private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://" + * to silently fall through to the regular config import path. + * + * The fixed `import/` host narrows the deep-link surface: the + * AndroidManifest filter requires `android:host="import"`, so a + * foreign URL like `mhrv-rs-setup://attacker.example/...` won't + * even reach the trust prompt. */ + private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://import/" /** Filename inside the app's filesDir where imported credentials are * written. Must match what the regular Drive import flow uses, so @@ -437,7 +442,14 @@ object ConfigStore { /** Read the on-disk credentials + token files and bundle them with * the user's Drive config knobs into a shareable string. Returns * null when there's nothing to share (no credentials imported, or - * no token cached yet — the sharer has to complete OAuth first). */ + * no token cached yet — the sharer has to complete OAuth first). + * + * Caller is responsible for showing a destructive-action warning + * before producing the QR. The bundle contains the OAuth + * `client_secret` and a long-lived refresh token; anyone with the + * QR (or a backup of the chat that delivered it) keeps `drive.file` + * access until the sharer rotates the OAuth client in Google + * Cloud Console. There is no per-recipient revoke. */ fun encodeDriveSetup(ctx: Context, cfg: MhrvConfig): String? { if (cfg.driveCredentialsPath.isBlank()) return null val credsFile = File(cfg.driveCredentialsPath) @@ -540,19 +552,10 @@ object ConfigStore { tokenFile.writeText(JSONObject().apply { put("refresh_token", setup.refreshToken) }.toString()) - // Best-effort 0600. Android's FileProvider sandbox already - // walls /data/user/0//files/ off from other apps, so - // this is belt-and-braces. - runCatching { - credsFile.setReadable(false, false) - credsFile.setReadable(true, true) - credsFile.setWritable(false, false) - credsFile.setWritable(true, true) - tokenFile.setReadable(false, false) - tokenFile.setReadable(true, true) - tokenFile.setWritable(false, false) - tokenFile.setWritable(true, true) - } + // No setReadable/setWritable dance: Android's per-app + // sandbox under /data/user/0//files/ already walls + // these files off from other apps. The previous gymnastics + // were no-ops on every Android version we support. base.copy( mode = Mode.GOOGLE_DRIVE, driveCredentialsPath = credsFile.absolutePath, diff --git a/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index 1c07c6b..cbcb277 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -130,7 +130,7 @@ وارد کردن اگر کسی QR تنظیمات Drive را با شما به اشتراک گذاشته، اینجا اسکن کنید. برنامه credentials و توکن OAuth را می‌نویسد، folder_id را تنظیم می‌کند و فقط کافی است Connect را بزنید — نیازی به Google Cloud Console یا مرورگر نیست. اشتراک‌گذاری تنظیمات Drive - این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند به همان مقدار دسترسی به Drive شما خواهد داشت که این برنامه دارد. فقط با افراد قابل اعتماد به اشتراک بگذارید. + این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند — یا بعداً در پشتیبان چت، اسکرین‌شات یا دستگاه آلوده پیدا کند — همان دسترسی `drive.file` این برنامه را خواهد داشت و تا زمانی که OAuth client را در Google Cloud Console عوض نکنید نگه می‌دارد (که این دستگاه را هم خارج می‌کند). امکان لغو دسترسی فقط برای یک گیرنده وجود ندارد. آن را مثل یک رمز عبور بلندمدت در نظر بگیرید و فقط با افراد قابل اعتماد به اشتراک بگذارید. هنوز چیزی برای اشتراک‌گذاری وجود ندارد — ابتدا روی این دستگاه OAuth را تکمیل کنید (دکمهٔ احراز Google Drive). حجم بسته برای QR زیاد است. از دکمه‌های کپی / ارسال برای فرستادن متن استفاده کنید. QR تنظیمات Drive را اسکن کنید diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index dfd546f..9506dfd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -145,7 +145,7 @@ Import If someone shared a Drive setup QR with you, scan it here. The app will write their credentials and OAuth token, set the folder ID, and you can tap Connect — no Google Cloud Console or browser steps needed. Share Drive setup - This QR contains your OAuth client secret AND your Drive refresh token. Anyone who scans it gets the same access to your Drive that this app has. Only share with people you trust. + This QR contains your OAuth client secret AND your Drive refresh token. Anyone who scans it — or later finds it in a chat backup, screenshot, or compromised device — gets the same `drive.file` access this app has, and keeps it until you rotate the OAuth client in Google Cloud Console (which also kicks THIS device). There is no way to revoke a single recipient. Treat it like a long-lived password and only share with people you trust. Nothing to share yet — finish OAuth on this device first (Authorize Google Drive button). Setup payload is too large for a QR code. Use the Copy / Send buttons to share the text instead. Scan a Drive setup QR diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 3036728..e5bd18e 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -153,11 +153,6 @@ struct UiState { /// Result of the most recent `DriveCompleteAuth`. `Ok(token_path)` on /// success, `Err(message)` otherwise. drive_auth_result: Option>, - /// Whether the Drive client is the one currently held in `active` - /// on the background thread. Independent from `running` because the - /// drive client doesn't go through ProxyServer / DomainFronter, so - /// the stats panel stays empty while it runs. - drive_running: bool, } #[derive(Clone, Debug)] @@ -294,6 +289,12 @@ struct FormState { drive_poll_ms: u64, drive_flush_ms: u64, drive_idle_timeout_secs: u64, + /// Round-tripped from config.json so a hand-edited override + /// survives a save. Not surfaced as a UI control; `0` means "use + /// built-in default" (currently 8). Hidden because the right value + /// is dictated by the user's network and Drive quota — operators + /// who care can edit config.json directly. + drive_storage_concurrency: usize, } #[derive(Clone, Debug)] @@ -385,6 +386,7 @@ fn load_form() -> (FormState, Option) { drive_poll_ms: c.drive_poll_ms, drive_flush_ms: c.drive_flush_ms, drive_idle_timeout_secs: c.drive_idle_timeout_secs, + drive_storage_concurrency: c.drive_storage_concurrency, } } else { FormState { @@ -421,6 +423,7 @@ fn load_form() -> (FormState, Option) { drive_poll_ms: 500, drive_flush_ms: 300, drive_idle_timeout_secs: 300, + drive_storage_concurrency: 0, } }; (form, load_err) @@ -601,6 +604,7 @@ impl FormState { drive_poll_ms: self.drive_poll_ms, drive_flush_ms: self.drive_flush_ms, drive_idle_timeout_secs: self.drive_idle_timeout_secs, + drive_storage_concurrency: self.drive_storage_concurrency, }) } } @@ -671,12 +675,18 @@ struct ConfigWire<'a> { drive_flush_ms: u64, #[serde(skip_serializing_if = "is_zero_u64")] drive_idle_timeout_secs: u64, + #[serde(skip_serializing_if = "is_zero_usize")] + drive_storage_concurrency: usize, } fn is_zero_u64(v: &u64) -> bool { *v == 0 } +fn is_zero_usize(v: &usize) -> bool { + *v == 0 +} + fn is_false(b: &bool) -> bool { !*b } @@ -759,6 +769,11 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { } else { 0 }, + drive_storage_concurrency: if c.mode == "google_drive" { + c.drive_storage_concurrency + } else { + 0 + }, } } } @@ -2222,12 +2237,27 @@ fn pick_credentials_file() -> Option { $f = New-Object System.Windows.Forms.OpenFileDialog; \ $f.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'; \ if ($f.ShowDialog() -eq 'OK') { Write-Output $f.FileName }"; - let out = std::process::Command::new("powershell") - .args(["-NoProfile", "-Command", script]) - .output() - .ok()?; - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if s.is_empty() { None } else { Some(s) } + // Try PowerShell 7 (`pwsh`) first, then Windows PowerShell. Some + // hardened images ship only one or the other, so falling through + // both lets the dialog work regardless. The script is identical + // for both interpreters. + for exe in &["pwsh", "powershell"] { + let Ok(out) = std::process::Command::new(exe) + .args(["-NoProfile", "-Command", script]) + .output() + else { + continue; + }; + if !out.status.success() { + continue; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + return None; + } + return Some(s); + } + None } #[cfg(target_os = "macos")] { @@ -2325,7 +2355,6 @@ fn background_thread(shared: Arc, rx: Receiver) { let mut s = shared2.state.lock().unwrap(); s.running = true; s.started_at = Some(Instant::now()); - s.drive_running = true; } let port = cfg.socks5_port.unwrap_or(cfg.listen_port + 1); push_log( @@ -2341,7 +2370,6 @@ fn background_thread(shared: Arc, rx: Receiver) { st.running = false; st.started_at = None; st.proxy_active = false; - st.drive_running = false; push_log(&shared2, "[ui] drive client stopped"); }); active = Some((handle, fronter_slot, shutdown_tx)); diff --git a/src/config.rs b/src/config.rs index d024c31..f0cd850 100644 --- a/src/config.rs +++ b/src/config.rs @@ -224,6 +224,13 @@ pub struct Config { /// default of 15 s was too aggressive for real protocols. #[serde(default = "default_drive_idle_timeout_secs")] pub drive_idle_timeout_secs: u64, + /// Max concurrent in-flight Drive uploads/downloads. `0` (default) + /// uses the built-in [`drive_tunnel::STORAGE_CONCURRENCY`] of 8. + /// Bump up if you have a fat pipe and many sessions; HTTP/2 + /// multiplexes everything onto one TLS connection so the cost of + /// raising this is just a few more in-flight streams. + #[serde(default)] + pub drive_storage_concurrency: usize, } fn default_fetch_ips_from_api() -> bool { @@ -315,9 +322,14 @@ impl Config { "drive_poll_ms and drive_flush_ms must be greater than 0".into(), )); } - if self.drive_idle_timeout_secs == 0 { + // Floor at 15s to match the UI sliders. Lower values + // force-close real protocols (TLS, long-poll HTTP, idle + // WebSockets) on every flush and were previously only + // rejected at zero — a hand-edited `config.json` could + // still set 1 and silently break every connection. + if self.drive_idle_timeout_secs < 15 { return Err(ConfigError::Invalid( - "drive_idle_timeout_secs must be greater than 0".into(), + "drive_idle_timeout_secs must be at least 15".into(), )); } // The id is concatenated unsanitised into Drive filenames and @@ -496,6 +508,30 @@ mod tests { assert_eq!(cfg.drive_idle_timeout_secs, 300); } + #[test] + fn rejects_google_drive_idle_timeout_below_floor() { + // Validator floor is 15s — below it a hand-edited config could + // set 1 and force-close every session on each flush. Verify both + // 0 and a low-but-positive value are rejected, and exactly 15 + // is accepted. + let mk = |idle: u64| { + format!( + "{{\"mode\":\"google_drive\",\"drive_credentials_path\":\"c.json\",\"drive_idle_timeout_secs\":{}}}", + idle + ) + }; + for bad in [0u64, 1, 14] { + let cfg: Config = serde_json::from_str(&mk(bad)).unwrap(); + assert!( + cfg.validate().is_err(), + "drive_idle_timeout_secs = {} should reject", + bad + ); + } + let cfg: Config = serde_json::from_str(&mk(15)).unwrap(); + cfg.validate().expect("15s should be accepted"); + } + #[test] fn rejects_google_drive_client_id_with_special_chars() { let s = r#"{ diff --git a/src/drive_tunnel.rs b/src/drive_tunnel.rs index 9ab3b99..77e6a07 100644 --- a/src/drive_tunnel.rs +++ b/src/drive_tunnel.rs @@ -10,18 +10,18 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use futures_util::stream::{self, StreamExt}; -use rand::RngCore; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, Mutex}; use crate::config::Config; -use crate::google_drive::{DriveError, GoogleDriveBackend}; +use crate::google_drive::{random_hex, DriveError, GoogleDriveBackend}; -/// Maximum number of concurrent uploads/downloads in flight against +/// Default ceiling on concurrent uploads/downloads in flight against /// Drive. Matches FlowDriver's `e.sem = make(chan struct{}, 8)`. Uses /// HTTP/2 multiplexing on a single TLS connection, so the cost of bumping /// this is just a few more in-flight streams — no extra handshakes. +/// Operators can override via `drive_storage_concurrency` in config. const STORAGE_CONCURRENCY: usize = 8; const MAGIC_BYTE: u8 = 0x1f; @@ -220,6 +220,10 @@ pub struct DriveEngine { poll_interval: Duration, flush_interval: Duration, idle_timeout: Duration, + /// Max concurrent in-flight Drive uploads/downloads. Resolved from + /// config (`drive_storage_concurrency`); falls back to + /// [`STORAGE_CONCURRENCY`] when unset. + storage_concurrency: usize, new_session_tx: Option>, } @@ -250,6 +254,11 @@ impl DriveEngine { poll_interval: Duration::from_millis(config.drive_poll_ms), flush_interval: Duration::from_millis(config.drive_flush_ms), idle_timeout: Duration::from_secs(config.drive_idle_timeout_secs), + storage_concurrency: if config.drive_storage_concurrency == 0 { + STORAGE_CONCURRENCY + } else { + config.drive_storage_concurrency + }, new_session_tx, }) } @@ -341,12 +350,24 @@ impl DriveEngine { } async fn flush_all(&self) -> Result<(), DriveError> { + // Build phase: drain each session's tx_buf into an envelope, but + // do NOT yet bump tx_seq or clear pending_open_ok. We commit those + // state changes only after the upload returns Ok; on Err, we + // restore the payload (prepended before any new writes) and leave + // tx_seq alone, so the next flush retries the same envelope at the + // same seq number. + // + // Why this matters: pre-fix, a failed upload silently advanced + // tx_seq, and the peer's rx_seq stalled forever waiting for the + // missing seq — the session hung until idle_timeout (5 min) before + // anything user-visible happened. let sessions: Vec>> = self.sessions.lock().await.values().cloned().collect(); - let mut muxes: HashMap> = HashMap::new(); - let mut closed_ids = Vec::new(); + let mut muxes: HashMap>, Envelope)>> = HashMap::new(); + let mut closed_ids: Vec = Vec::new(); for session in sessions { + let session_for_commit = session.clone(); let mut s = session.lock().await; if s.last_activity.elapsed() > self.idle_timeout { s.closed = true; @@ -365,19 +386,23 @@ impl DriveEngine { } if open_ok { flags |= FLAG_OPEN_OK; - s.pending_open_ok = false; } + // Server-side responses don't need to echo target_addr — the + // peer only ever uses target_addr to dial, which is a + // request-side concern. Saves `target_addr.len() + 1` bytes + // per envelope on the response stream. + let target_addr = if self.my_dir == Direction::Req { + s.target_addr.clone() + } else { + String::new() + }; let env = Envelope { session_id: s.id.clone(), seq: s.tx_seq, - target_addr: s.target_addr.clone(), + target_addr, payload, flags, }; - s.tx_seq += 1; - if s.closed { - closed_ids.push(s.id.clone()); - } let cid = if self.my_dir == Direction::Req { self.client_id.clone() } else if s.client_id.is_empty() { @@ -385,7 +410,11 @@ impl DriveEngine { } else { s.client_id.clone() }; - muxes.entry(cid).or_default().push(env); + drop(s); + muxes + .entry(cid) + .or_default() + .push((session_for_commit, env)); } // Encode all mux files up front (CPU only, fast), then ship them @@ -393,33 +422,84 @@ impl DriveEngine { // the server side typically has several active clients and the // parallelism plus HTTP/2 multiplexing folds them into a single // round-trip's worth of latency. - let mut uploads: Vec<(String, Vec)> = Vec::with_capacity(muxes.len()); - for (cid, envelopes) in muxes { + let mut uploads: Vec<(String, Vec, Vec<(Arc>, Envelope)>)> = + Vec::with_capacity(muxes.len()); + for (cid, items) in muxes { let filename = format!("{}-{}-mux-{}.bin", self.my_dir.as_str(), cid, now_nanos()); let mut body = Vec::new(); - for env in &envelopes { + for (_, env) in &items { env.encode(&mut body)?; } - uploads.push((filename, body)); + uploads.push((filename, body, items)); } if !uploads.is_empty() { let backend = self.backend.clone(); - let results: Vec> = stream::iter(uploads.into_iter().map( - |(name, body)| { - let backend = backend.clone(); - async move { backend.upload(&name, body).await } - }, - )) - .buffer_unordered(STORAGE_CONCURRENCY) + let storage_concurrency = self.storage_concurrency; + let results: Vec<( + String, + Vec<(Arc>, Envelope)>, + Result<(), DriveError>, + )> = stream::iter(uploads.into_iter().map(|(name, body, items)| { + let backend = backend.clone(); + async move { + let r = backend.upload(&name, body).await; + (name, items, r) + } + })) + .buffer_unordered(storage_concurrency) .collect() .await; - for r in results { - if let Err(e) = r { - tracing::debug!("Drive upload error: {}", e); + + for (name, items, r) in results { + match r { + Ok(()) => { + // Commit: bump tx_seq, clear pending_open_ok flags, + // and queue closed sessions for teardown. We use + // env.seq + 1 (not s.tx_seq + 1) so a session with + // its tx_seq already advanced by another path is a + // no-op rather than a backwards step. + for (session, env) in items { + let mut s = session.lock().await; + if s.tx_seq <= env.seq { + s.tx_seq = env.seq + 1; + } + if env.flags & FLAG_OPEN_OK != 0 { + s.pending_open_ok = false; + } + if env.flags & FLAG_CLOSE != 0 { + closed_ids.push(s.id.clone()); + } + } + } + Err(e) => { + // Rollback: restore payload to tx_buf (prepended, + // so retry preserves byte order), keep tx_seq and + // pending_open_ok untouched so the next flush + // re-emits the same envelope with the same seq. + // Bump to warn — the previous debug-only log meant + // operators couldn't see why a session looked + // stuck. + tracing::warn!( + "Drive upload {} failed: {} (will retry next flush)", + name, + e + ); + for (session, env) in items { + let mut s = session.lock().await; + if !env.payload.is_empty() { + let mut restored = env.payload; + restored.extend_from_slice(&s.tx_buf); + s.tx_buf = restored; + } + } + } } } } + // Lock order: sessions before closed_sessions. Only one site in + // this file takes both locks; documenting it here so a future + // edit doesn't accidentally invert. if !closed_ids.is_empty() { let mut sessions = self.sessions.lock().await; let mut closed_set = self.closed_sessions.lock().await; @@ -506,7 +586,7 @@ impl DriveEngine { return Ok(true); } - // Concurrent downloads, bounded by STORAGE_CONCURRENCY. With + // Concurrent downloads, bounded by storage_concurrency. With // HTTP/2 these all multiplex onto the same TLS connection — no // extra handshakes, just more in-flight streams. This is the // single biggest win over the v1 sequential implementation. @@ -518,7 +598,7 @@ impl DriveEngine { (name, res) } })) - .buffer_unordered(STORAGE_CONCURRENCY); + .buffer_unordered(self.storage_concurrency); tokio::pin!(downloads); while let Some((name, result)) = downloads.next().await { @@ -1143,21 +1223,15 @@ fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> Result String { - let mut buf = vec![0u8; bytes]; - rand::thread_rng().fill_bytes(&mut buf); - let mut out = String::with_capacity(bytes * 2); - for b in buf { - out.push_str(&format!("{:02x}", b)); - } - out -} - fn now_nanos() -> u128 { + // Floor at 1 so a clock set before 1970 (or a `duration_since` error) + // still produces a non-zero filename suffix — zero would collide with + // any other badly-clocked event and break ordering hints. SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() + .map(|d| d.as_nanos()) + .unwrap_or(1) + .max(1) } fn timestamp_from_filename(filename: &str) -> Option { @@ -1273,4 +1347,107 @@ mod tests { assert!(client_id_from_filename("garbage.txt").is_none()); } + + #[test] + fn apply_rx_env_handles_all_flag_combinations() { + // Pure-function exercise of the rx-side flag decoder. Covers + // each combination so a future flag addition / reorder shows up + // here before it ships as a wire-protocol regression. + let mk = |flags: u8, payload: &[u8]| Envelope { + session_id: "sid".into(), + seq: 0, + target_addr: String::new(), + payload: payload.to_vec(), + flags, + }; + let new_session = || { + DriveSession { + id: "sid".into(), + target_addr: String::new(), + client_id: String::new(), + tx_buf: Vec::new(), + tx_seq: 0, + rx_seq: 0, + rx_queue: BTreeMap::new(), + last_activity: Instant::now(), + closed: false, + rx_closed: false, + pending_open_ok: false, + rx_tx: mpsc::channel(1).0, + } + }; + + // Plain data: one Data emission, rx_seq advances, no close. + let mut s = new_session(); + let mut out = Vec::new(); + apply_rx_env(&mut s, mk(0, b"data"), &mut out); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], DriveRx::Data(ref d) if d == b"data")); + assert_eq!(s.rx_seq, 1); + assert!(!s.rx_closed); + + // Open-only: one Open emission, no Data, rx_seq advances. + let mut s = new_session(); + let mut out = Vec::new(); + apply_rx_env(&mut s, mk(FLAG_OPEN_OK, &[]), &mut out); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], DriveRx::Open)); + assert_eq!(s.rx_seq, 1); + + // Open + Data + Close in one envelope: Open, Data, Close in + // that order, session marked closed. + let mut s = new_session(); + let mut out = Vec::new(); + apply_rx_env(&mut s, mk(FLAG_OPEN_OK | FLAG_CLOSE, b"x"), &mut out); + assert_eq!(out.len(), 3); + assert!(matches!(out[0], DriveRx::Open)); + assert!(matches!(out[1], DriveRx::Data(ref d) if d == b"x")); + assert!(matches!(out[2], DriveRx::Close)); + assert!(s.rx_closed); + assert!(s.closed); + + // Close-only with empty payload: just Close, no Data. + let mut s = new_session(); + let mut out = Vec::new(); + apply_rx_env(&mut s, mk(FLAG_CLOSE, &[]), &mut out); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], DriveRx::Close)); + assert!(s.rx_closed); + } + + #[test] + fn server_side_envelope_omits_target_addr() { + // Confirms the wire-size optimization: Direction::Res envelopes + // encode an empty target_addr (one zero byte), not the session's + // dial address. This isn't just a perf nit — a future change + // that re-introduces target_addr on the response side would + // bloat every byte of return traffic by ~10–80 bytes. + let env_res = Envelope { + session_id: "s".into(), + seq: 0, + target_addr: String::new(), + payload: b"hello".to_vec(), + flags: 0, + }; + let mut buf_res = Vec::new(); + env_res.encode(&mut buf_res).unwrap(); + + let env_req = Envelope { + session_id: "s".into(), + seq: 0, + target_addr: "example.com:443".into(), + payload: b"hello".to_vec(), + flags: 0, + }; + let mut buf_req = Vec::new(); + env_req.encode(&mut buf_req).unwrap(); + + // Same payload, same seq, same flags — the only delta should be + // the target_addr length + bytes. + assert_eq!( + buf_req.len() - buf_res.len(), + "example.com:443".len(), + "Direction::Res envelope should be exactly target_addr.len() bytes shorter" + ); + } } diff --git a/src/google_drive.rs b/src/google_drive.rs index 0553010..e7ba9b8 100644 --- a/src/google_drive.rs +++ b/src/google_drive.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -54,6 +54,11 @@ pub struct QuotaTracker { bucket_start_secs: AtomicU64, bucket_count: AtomicU64, total: AtomicU64, + /// Set the first time the warn fires in the current bucket; cleared + /// at the next bucket reset. Without this, a thread that races past + /// the threshold by more than one increment skips the warn entirely + /// (the previous `count == THRESHOLD` was an exact-match trigger). + warned_this_bucket: AtomicBool, } impl QuotaTracker { @@ -63,6 +68,7 @@ impl QuotaTracker { bucket_start_secs: AtomicU64::new(0), bucket_count: AtomicU64::new(0), total: AtomicU64::new(0), + warned_this_bucket: AtomicBool::new(false), } } @@ -80,10 +86,14 @@ impl QuotaTracker { // off-by-one calls don't matter for a logging counter. self.bucket_start_secs.store(now_secs, Ordering::Relaxed); self.bucket_count.store(0, Ordering::Relaxed); + self.warned_this_bucket.store(false, Ordering::Relaxed); } let count = self.bucket_count.fetch_add(1, Ordering::Relaxed) + 1; self.total.fetch_add(1, Ordering::Relaxed); - if count == DRIVE_QUOTA_WARN_THRESHOLD { + if count >= DRIVE_QUOTA_WARN_THRESHOLD + && count < DRIVE_QUOTA_PER_USER_100S + && !self.warned_this_bucket.swap(true, Ordering::Relaxed) + { tracing::warn!( "Drive API rate climbing: {}/100s — free-tier limit is {}/100s/user. \ Consider increasing drive_poll_ms / drive_flush_ms to slow down.", @@ -900,7 +910,9 @@ fn http_error(resp: HttpResponse) -> DriveError { } } -fn random_hex(bytes: usize) -> String { +/// Lowercase hex string from `bytes` random bytes. Shared with +/// `drive_tunnel` (one helper, one place) — keep the signature stable. +pub(crate) fn random_hex(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::thread_rng().fill_bytes(&mut buf); let mut out = String::with_capacity(bytes * 2); @@ -1068,6 +1080,49 @@ mod tests { assert_eq!(url_path_escape("a+b/c?d"), "a%2Bb%2Fc%3Fd"); } + #[test] + fn quota_tracker_counts_and_snapshots() { + // record_call() returns the in-bucket count and bumps the + // total. The exact warn / error threshold logging is exercised + // implicitly by hitting >= the warn threshold; we can't capture + // tracing output here without a subscriber, but we can at + // least verify the snapshot reflects what we did. + let q = QuotaTracker::new(); + assert_eq!(q.snapshot().total, 0); + assert_eq!(q.snapshot().current_window, 0); + for _ in 0..5 { + q.record_call(); + } + let s = q.snapshot(); + assert_eq!(s.total, 5); + assert_eq!(s.current_window, 5); + assert_eq!(s.window_secs, 100); + assert_eq!(s.quota_per_user, DRIVE_QUOTA_PER_USER_100S); + } + + #[test] + fn quota_tracker_warns_at_or_above_threshold() { + // Regression guard for the `count == THRESHOLD` exact-match + // bug: calling record_call() far past the warn threshold + // (simulating a thread that races past it without ever landing + // exactly on the boundary) should still flip warned_this_bucket + // exactly once. We assert the latch by observing the AtomicBool. + let q = QuotaTracker::new(); + // Pre-load the bucket so the next call lands well above the + // threshold rather than incrementally crossing it. + q.bucket_count + .store(DRIVE_QUOTA_WARN_THRESHOLD + 10, Ordering::Relaxed); + assert!(!q.warned_this_bucket.load(Ordering::Relaxed)); + q.record_call(); + assert!( + q.warned_this_bucket.load(Ordering::Relaxed), + "warn latch should fire even when count overshoots the threshold" + ); + // Subsequent calls in the same bucket don't re-arm. + q.record_call(); + assert!(q.warned_this_bucket.load(Ordering::Relaxed)); + } + #[test] fn parse_rfc3339_handles_drive_timestamps() { // Drive returns timestamps with millisecond fractional precision From ced5cf13aa1b6e0133616d8211d524b8c80f7a8c Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:51:36 +0400 Subject: [PATCH 11/11] feat(ui): hide mode-irrelevant fields on desktop and Android --- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 160 ++++++++++-------- src/bin/ui.rs | 120 +++++++------ 2 files changed, 163 insertions(+), 117 deletions(-) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index dd8fb50..af0222e 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -355,33 +355,37 @@ fun HomeScreen( Spacer(Modifier.height(4.dp)) val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL - // Wrapped in a collapsible so a long ID list (10+ deployments - // is normal in full-tunnel rotations) doesn't dominate the - // screen once it's set up. Starts expanded for first-run users - // (no IDs/key yet) so the form is immediately discoverable. - CollapsibleSection( - title = stringResource(R.string.sec_apps_script_relay), - initiallyExpanded = appsScriptEnabled && - (cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank()), - ) { - DeploymentIdsField( - urls = cfg.appsScriptUrls, - onChange = { persist(cfg.copy(appsScriptUrls = it)) }, - enabled = appsScriptEnabled, - ) + // Apps Script section only renders for the modes that + // actually use Apps Script. google_only / google_drive have + // no Deployment ID or Auth key concept — showing them + // greyed-out (the previous behavior) just confused + // first-time users. Wrapped in a collapsible so a long ID + // list (10+ deployments is normal in full-tunnel rotations) + // doesn't dominate the screen once set up. + if (appsScriptEnabled) { + CollapsibleSection( + title = stringResource(R.string.sec_apps_script_relay), + initiallyExpanded = cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank(), + ) { + DeploymentIdsField( + urls = cfg.appsScriptUrls, + onChange = { persist(cfg.copy(appsScriptUrls = it)) }, + enabled = true, + ) - OutlinedTextField( - value = cfg.authKey, - onValueChange = { persist(cfg.copy(authKey = it)) }, - label = { Text(stringResource(R.string.field_auth_key)) }, - singleLine = true, - enabled = appsScriptEnabled, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - modifier = Modifier.fillMaxWidth(), - supportingText = { - Text(stringResource(R.string.help_auth_key)) - }, - ) + OutlinedTextField( + value = cfg.authKey, + onValueChange = { persist(cfg.copy(authKey = it)) }, + label = { Text(stringResource(R.string.field_auth_key)) }, + singleLine = true, + enabled = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text(stringResource(R.string.help_auth_key)) + }, + ) + } } // ── Google Drive section ────────────────────────────────────── @@ -494,7 +498,11 @@ fun HomeScreen( ) } - // Advanced settings: collapsed by default. + // Advanced settings: collapsed by default. The block + // contains a mix of always-applicable knobs (verify_ssl, + // log_level) and Apps-Script-only knobs (parallel_relay, + // upstream_socks5); the inner composable hides the latter + // when the current mode doesn't use Apps Script. CollapsibleSection(title = stringResource(R.string.sec_advanced)) { AdvancedSettings( cfg = cfg, @@ -506,12 +514,17 @@ fun HomeScreen( // Secondary action — FilledTonalButton signals "helper" against // the primary Connect/Disconnect button at the top. Kept down // here because cert install is a one-time setup step; daily - // users never tap it again. - FilledTonalButton( - onClick = { showInstallDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.btn_install_mitm)) + // users never tap it again. Only meaningful when MITM is + // active: apps_script does the TLS interception, full owns + // a tunnel-node + cert. google_only and google_drive do + // not MITM so hiding the button keeps the flow honest. + if (cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL) { + FilledTonalButton( + onClick = { showInstallDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.btn_install_mitm)) + } } // "Usage today (estimated)" — visible only while a proxy is @@ -532,12 +545,18 @@ fun HomeScreen( // Wrapped in a collapsible so the big prose block doesn't // dominate the form after the user has learned the flow. // Starts expanded once for a fresh install so the first-run - // instructions are immediately visible. - CollapsibleSection( - title = stringResource(R.string.sec_how_to_use), - initiallyExpanded = cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank(), - ) { - HowToUseBody(cfg.listenPort) + // instructions are immediately visible. The body is + // Apps-Script-flavoured (Deployment IDs, MITM cert, Code.gs) + // so it's only relevant in apps_script / full — Drive and + // google_only have their own onboarding inside their + // respective sections. + if (cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL) { + CollapsibleSection( + title = stringResource(R.string.sec_how_to_use), + initiallyExpanded = cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank(), + ) { + HowToUseBody(cfg.listenPort) + } } } } @@ -1896,6 +1915,11 @@ private fun AdvancedSettings( cfg: MhrvConfig, onChange: (MhrvConfig) -> Unit, ) { + // parallel_relay and upstream_socks5 only have an effect on the + // Apps Script relay path; they're no-ops in google_only and + // google_drive. Hide them in those modes so users don't think + // they're tunable knobs that just don't take effect. + val appsScriptRelevant = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { // verify_ssl Row( @@ -1947,36 +1971,38 @@ private fun AdvancedSettings( } } - // parallel_relay slider - Column { - Text( - stringResource(R.string.adv_parallel_relay, cfg.parallelRelay), - style = MaterialTheme.typography.bodyMedium, - ) - Slider( - value = cfg.parallelRelay.toFloat(), - onValueChange = { onChange(cfg.copy(parallelRelay = it.toInt().coerceIn(1, 5))) }, - valueRange = 1f..5f, - steps = 3, // yields 1,2,3,4,5 positions - ) - Text( - stringResource(R.string.adv_parallel_relay_help), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (appsScriptRelevant) { + // parallel_relay slider + Column { + Text( + stringResource(R.string.adv_parallel_relay, cfg.parallelRelay), + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = cfg.parallelRelay.toFloat(), + onValueChange = { onChange(cfg.copy(parallelRelay = it.toInt().coerceIn(1, 5))) }, + valueRange = 1f..5f, + steps = 3, // yields 1,2,3,4,5 positions + ) + Text( + stringResource(R.string.adv_parallel_relay_help), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = cfg.upstreamSocks5, + onValueChange = { onChange(cfg.copy(upstreamSocks5 = it)) }, + label = { Text(stringResource(R.string.adv_upstream_socks5)) }, + placeholder = { Text("host:port") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text(stringResource(R.string.adv_upstream_socks5_help)) + }, ) } - - OutlinedTextField( - value = cfg.upstreamSocks5, - onValueChange = { onChange(cfg.copy(upstreamSocks5 = it)) }, - label = { Text(stringResource(R.string.adv_upstream_socks5)) }, - placeholder = { Text("host:port") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - supportingText = { - Text(stringResource(R.string.adv_upstream_socks5_help)) - }, - ) } } diff --git a/src/bin/ui.rs b/src/bin/ui.rs index e5bd18e..a97ad1f 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -983,10 +983,15 @@ impl eframe::App for App { let google_only = self.form.mode == "google_only"; let google_drive = self.form.mode == "google_drive"; + // Apps Script relay only applies to apps_script + full. Hide + // the section entirely in google_only / google_drive — those + // modes have no Deployment ID or Auth key concept and the + // greyed-out fields were just confusing first-time users. + let needs_apps_script = !google_only && !google_drive; // ── Section: Apps Script relay ──────────────────────────────── - section(ui, "Apps Script relay", |ui| { - ui.add_enabled_ui(!google_only && !google_drive, |ui| { + if needs_apps_script { + section(ui, "Apps Script relay", |ui| { form_row(ui, "Deployment IDs", Some( "One deployment ID per line. Proxy round-robins between them and sidelines \ any ID that hits its daily quota for 10 minutes before retrying." @@ -1022,7 +1027,7 @@ impl eframe::App for App { .desired_width(f32::INFINITY)); }); }); - }); + } // ── Section: Network ────────────────────────────────────────── section(ui, "Network", |ui| { @@ -1082,9 +1087,16 @@ impl eframe::App for App { egui::Label::new(egui::RichText::new("Ports") .color(egui::Color32::from_gray(200))), ); - ui.label(egui::RichText::new("HTTP").small()); - ui.add(egui::TextEdit::singleline(&mut self.form.listen_port).desired_width(70.0)); - ui.add_space(10.0); + // google_drive doesn't bind the HTTP port at all + // (the Drive client is SOCKS5-only). Hiding it + // avoids implying it does something. The form still + // tracks the value so a switch back to apps_script + // recovers the user's previous setting. + if !google_drive { + ui.label(egui::RichText::new("HTTP").small()); + ui.add(egui::TextEdit::singleline(&mut self.form.listen_port).desired_width(70.0)); + ui.add_space(10.0); + } ui.label(egui::RichText::new("SOCKS5").small()); ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(70.0)); }); @@ -1212,26 +1224,32 @@ impl eframe::App for App { .rounding(6.0) .inner_margin(egui::Margin::same(10.0)); frame.show(ui, |ui| { - form_row(ui, "Upstream SOCKS5", Some( - "Optional. host:port of a local xray / v2ray / sing-box SOCKS5 inbound. \ - When set, non-HTTP / raw-TCP traffic (Telegram MTProto, IMAP, SSH, …) \ - is chained through it instead of direct. HTTP/HTTPS still go through \ - the Apps Script relay." - ), |ui| { - ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5) - .hint_text("empty = direct; 127.0.0.1:50529 for local xray") - .desired_width(f32::INFINITY)); - }); + // Apps-Script-specific tweaks. Drive mode bypasses + // the relay entirely and google_only doesn't relay + // either, so these knobs are no-ops there — hide + // rather than just disable. + if needs_apps_script { + form_row(ui, "Upstream SOCKS5", Some( + "Optional. host:port of a local xray / v2ray / sing-box SOCKS5 inbound. \ + When set, non-HTTP / raw-TCP traffic (Telegram MTProto, IMAP, SSH, …) \ + is chained through it instead of direct. HTTP/HTTPS still go through \ + the Apps Script relay." + ), |ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5) + .hint_text("empty = direct; 127.0.0.1:50529 for local xray") + .desired_width(f32::INFINITY)); + }); - form_row(ui, "Parallel dispatch", Some( - "Fire N Apps Script IDs in parallel per request and take the first \ - response. 0/1 = off. 2-3 kills long-tail latency at N× quota cost. \ - Only effective with multiple IDs configured." - ), |ui| { - ui.add(egui::DragValue::new(&mut self.form.parallel_relay) - .speed(1) - .range(0..=8)); - }); + form_row(ui, "Parallel dispatch", Some( + "Fire N Apps Script IDs in parallel per request and take the first \ + response. 0/1 = off. 2-3 kills long-tail latency at N× quota cost. \ + Only effective with multiple IDs configured." + ), |ui| { + ui.add(egui::DragValue::new(&mut self.form.parallel_relay) + .speed(1) + .range(0..=8)); + }); + } form_row(ui, "Log level", None, |ui| { egui::ComboBox::from_id_source("loglevel") @@ -1247,33 +1265,35 @@ impl eframe::App for App { ui.add_space(120.0 + 8.0); ui.checkbox(&mut self.form.verify_ssl, "Verify TLS server certificate (recommended)"); }); - ui.horizontal(|ui| { - ui.add_space(120.0 + 8.0); - ui.checkbox(&mut self.form.show_auth_key, "Show auth key"); - }); - ui.horizontal(|ui| { - ui.add_space(120.0 + 8.0); - ui.checkbox(&mut self.form.normalize_x_graphql, "Normalize X/Twitter GraphQL URLs") + if needs_apps_script { + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.checkbox(&mut self.form.show_auth_key, "Show auth key"); + }); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.checkbox(&mut self.form.normalize_x_graphql, "Normalize X/Twitter GraphQL URLs") + .on_hover_text( + "Trim the `features` / `fieldToggles` query params from x.com/i/api/graphql/… \ + requests before relaying. Massively improves cache hit rate when browsing \ + Twitter/X. Off by default — some endpoints may reject trimmed requests. \ + Credit: seramo_ir + Persian Python community (issue #16).", + ); + }); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.checkbox( + &mut self.form.youtube_via_relay, + "Send YouTube through relay (no SNI rewrite)", + ) .on_hover_text( - "Trim the `features` / `fieldToggles` query params from x.com/i/api/graphql/… \ - requests before relaying. Massively improves cache hit rate when browsing \ - Twitter/X. Off by default — some endpoints may reject trimmed requests. \ - Credit: seramo_ir + Persian Python community (issue #16).", + "YouTube normally uses the same direct Google-edge tunnel as google.com (TLS SNI is \ + the front domain, not youtube.com). That can trigger restricted mode or sign-out \ + prompts. Enable this to route youtube.com / youtu.be / ytimg.com through the Apps \ + Script relay instead — slower for video, but the visible SNI matches the site.", ); - }); - ui.horizontal(|ui| { - ui.add_space(120.0 + 8.0); - ui.checkbox( - &mut self.form.youtube_via_relay, - "Send YouTube through relay (no SNI rewrite)", - ) - .on_hover_text( - "YouTube normally uses the same direct Google-edge tunnel as google.com (TLS SNI is \ - the front domain, not youtube.com). That can trigger restricted mode or sign-out \ - prompts. Enable this to route youtube.com / youtu.be / ytimg.com through the Apps \ - Script relay instead — slower for video, but the visible SNI matches the site.", - ); - }); + }); + } }); });