diff --git a/Cargo.lock b/Cargo.lock index a37b7fd..4596d12 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 05d2ac0..af5e7f9 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"] @@ -46,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/Dockerfile.drive-node b/Dockerfile.drive-node new file mode 100644 index 0000000..105792e --- /dev/null +++ b/Dockerfile.drive-node @@ -0,0 +1,52 @@ +# 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 ------------------------------------------------------------ +# 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 +# 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"] diff --git a/README.md b/README.md index 353bd0f..e8bed0e 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,54 @@ 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. + +### 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://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. + +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/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d74ca5..40b034b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -53,14 +53,28 @@ - + + + + + + + + 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..fd22fa3 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() } @@ -244,6 +294,28 @@ 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. + * + * 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 + * 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 { @@ -255,6 +327,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 +350,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) @@ -325,6 +407,173 @@ 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). + * + * 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) + 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()) + // 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, + 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() @@ -350,6 +599,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 +634,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/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/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 6c499e5..0123293 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,6 +63,19 @@ 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() @@ -66,16 +83,27 @@ fun ConfigSharingBar( var showExportDialog by remember { mutableStateOf(false) } var showImportConfirm by remember { mutableStateOf(false) } var pendingImport by remember { mutableStateOf(null) } + var pendingDriveSetup by remember { mutableStateOf(null) } // 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)) } + } } } @@ -88,17 +116,29 @@ fun ConfigSharingBar( Icon(Icons.Default.Share, contentDescription = stringResource(R.string.btn_export_config)) } // Manual paste — reads clipboard on tap. Android 13+ restricts - // background clipboard access, so auto-detect doesn't work. - // User interaction (tap) grants clipboard permission. + // background clipboard access, so auto-detect doesn't work + // (the OS only grants the read after a foreground tap). One + // button handles both regular config blobs and Drive-setup + // blobs; we disambiguate on the prefix and route to the right + // confirmation dialog. OutlinedButton( onClick = { val text = clipboard.getText()?.text.orEmpty() - val decoded = ConfigStore.decode(text) - if (decoded != null) { - pendingImport = decoded - showImportConfirm = true + if (ConfigStore.looksLikeDriveSetup(text)) { + val setup = ConfigStore.decodeDriveSetup(text) + 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(text) + if (decoded != null) { + pendingImport = decoded + showImportConfirm = true + } else { + scope.launch { onSnackbar(ctx.getString(R.string.snack_invalid_config)) } + } } }, ) { @@ -242,6 +282,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)) } + }, + ) } // ========================================================================= @@ -266,6 +363,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..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 @@ -1,7 +1,11 @@ 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.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -19,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 @@ -26,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 @@ -254,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) }, ) @@ -317,6 +331,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, @@ -340,33 +355,54 @@ 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 ────────────────────────────────────── + // 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)) @@ -462,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, @@ -474,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 @@ -500,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) + } } } } @@ -849,10 +900,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 +938,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 +952,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 +1225,686 @@ 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"}") } + } + } + + 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)) } + } + } + // 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( + stringResource(R.string.help_google_drive), + style = MaterialTheme.typography.bodySmall, + 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)) + } + // 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, + 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, + 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 ✓") } + }, + ) + } + + 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)) } + }, + ) +} + +/** + * 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 + * 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 +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. // ========================================================================= @@ -1176,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( @@ -1227,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/android/app/src/main/res/values-fa/strings.xml b/android/app/src/main/res/values-fa/strings.xml index 23c3cfd..cbcb277 100644 --- a/android/app/src/main/res/values-fa/strings.xml +++ b/android/app/src/main/res/values-fa/strings.xml @@ -93,6 +93,57 @@ مشاهدهٔ سهمیه در گوگل ← تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است. + + صف 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 تأیید کپی شد + + + اشتراک‌گذاری تنظیمات Drive + اسکن QR تنظیمات + نمایش QR + بسته + ارسال + وارد کردن + اگر کسی QR تنظیمات Drive را با شما به اشتراک گذاشته، اینجا اسکن کنید. برنامه credentials و توکن OAuth را می‌نویسد، folder_id را تنظیم می‌کند و فقط کافی است Connect را بزنید — نیازی به Google Cloud Console یا مرورگر نیست. + اشتراک‌گذاری تنظیمات Drive + این QR شامل client secret و refresh token شما است. هر کس آن را اسکن کند — یا بعداً در پشتیبان چت، اسکرین‌شات یا دستگاه آلوده پیدا کند — همان دسترسی `drive.file` این برنامه را خواهد داشت و تا زمانی که OAuth client را در Google Cloud Console عوض نکنید نگه می‌دارد (که این دستگاه را هم خارج می‌کند). امکان لغو دسترسی فقط برای یک گیرنده وجود ندارد. آن را مثل یک رمز عبور بلندمدت در نظر بگیرید و فقط با افراد قابل اعتماد به اشتراک بگذارید. + هنوز چیزی برای اشتراک‌گذاری وجود ندارد — ابتدا روی این دستگاه OAuth را تکمیل کنید (دکمهٔ احراز Google Drive). + حجم بسته برای QR زیاد است. از دکمه‌های کپی / ارسال برای فرستادن متن استفاده کنید. + QR تنظیمات Drive را اسکن کنید + وارد کردن تنظیمات Drive؟ + این عمل credentials فعلی Drive روی این دستگاه را بازنویسی می‌کند. فقط در صورتی ادامه دهید که QR را از فردی قابل اعتماد دریافت کرده‌اید — این به برنامه اجازهٔ دسترسی به فضای Drive ایشان را می‌دهد. + شناسهٔ پوشه: %1$s\nنام پوشه: %2$s + تنظیمات Drive وارد شد — برای شروع Connect را بزنید + وارد کردن تنظیمات Drive ناموفق بود (نوشتن فایل‌ها ممکن نشد) + QR معتبر تنظیمات 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 5f4d637..9506dfd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -108,6 +108,57 @@ 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 + + + 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 — 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 + 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 + 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. 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/android_jni.rs b/src/android_jni.rs index 6bb5a97..f69615d 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,234 @@ 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; + } + }; + + // 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_backend( + &cfg_for_task, + backend, + 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(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()) +} + +/// `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 + } + })) +} + +/// 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 /// unknown handle returns false quietly. /// @@ -367,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 @@ -423,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 @@ -446,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/bin/drive_node.rs b/src/bin/drive_node.rs new file mode 100644 index 0000000..11b70ce --- /dev/null +++ b/src/bin/drive_node.rs @@ -0,0 +1,104 @@ +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); + // 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 => { + 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 67695a8..da94332 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,12 @@ 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>, } #[derive(Clone, Debug)] @@ -194,6 +204,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 +219,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)] @@ -247,6 +274,31 @@ struct FormState { /// users edit `disable_padding` directly when needed (Issue #391). /// Default false (padding active). disable_padding: 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, + /// 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)] @@ -331,6 +383,15 @@ fn load_form() -> (FormState, Option) { passthrough_hosts: c.passthrough_hosts.clone(), block_quic: c.block_quic, disable_padding: c.disable_padding, + 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, + drive_storage_concurrency: c.drive_storage_concurrency, } } else { FormState { @@ -360,6 +421,15 @@ fn load_form() -> (FormState, Option) { passthrough_hosts: Vec::new(), block_quic: false, disable_padding: 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, + drive_storage_concurrency: 0, } }; (form, load_err) @@ -412,7 +482,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()); } @@ -420,6 +496,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() @@ -509,6 +598,22 @@ impl FormState { // Issue #391: disable_padding is config-only for now. // Round-trip preserves the user's choice. disable_padding: self.disable_padding, + 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, + drive_storage_concurrency: self.drive_storage_concurrency, }) } } @@ -560,6 +665,35 @@ 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, + #[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 { @@ -608,6 +742,47 @@ 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 + }, + drive_storage_concurrency: if c.mode == "google_drive" { + c.drive_storage_concurrency + } else { + 0 + }, } } } @@ -753,12 +928,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| { @@ -777,6 +954,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" { @@ -797,13 +979,28 @@ 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"; + // 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, |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." @@ -839,7 +1036,7 @@ impl eframe::App for App { .desired_width(f32::INFINITY)); }); }); - }); + } // ── Section: Network ────────────────────────────────────────── section(ui, "Network", |ui| { @@ -899,14 +1096,127 @@ 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)); }); }); + // ── 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( @@ -923,26 +1233,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") @@ -958,33 +1274,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.", - ); - }); + }); + } }); }); @@ -1005,6 +1323,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 ──────────────────────────────────────── @@ -1778,6 +2101,216 @@ 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 }"; + // 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")] + { + 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 { @@ -1834,6 +2367,43 @@ 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()); + } + 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; + 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 @@ -2176,6 +2746,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/config.rs b/src/config.rs index a580826..d8d0285 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,6 +192,7 @@ pub struct Config { /// failure modes later. Issue #213. #[serde(default)] pub block_quic: bool, + /// When true, suppress the random `_pad` field that v1.8.0+ adds /// to outbound Apps Script requests for DPI evasion. Default off /// (padding active). Some users on heavily-throttled ISPs find @@ -205,12 +208,74 @@ pub struct Config { /// flip on your specific ISP path. #[serde(default)] pub disable_padding: 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, + /// 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 { 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() @@ -262,6 +327,54 @@ 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(), + )); + } + // 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 at least 15".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(), @@ -280,8 +393,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 ))), } @@ -355,7 +469,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); } @@ -394,6 +509,63 @@ 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_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#"{ + "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..77e6a07 --- /dev/null +++ b/src/drive_tunnel.rs @@ -0,0 +1,1453 @@ +//! 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 futures_util::stream::{self, StreamExt}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, Mutex}; + +use crate::config::Config; +use crate::google_drive::{random_hex, DriveError, GoogleDriveBackend}; + +/// 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; +/// 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, + /// 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>, +} + +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), + storage_concurrency: if config.drive_storage_concurrency == 0 { + STORAGE_CONCURRENCY + } else { + config.drive_storage_concurrency + }, + 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> { + // 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>, 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; + } + 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; + } + // 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, + payload, + flags, + }; + 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() + }; + drop(s); + muxes + .entry(cid) + .or_default() + .push((session_for_commit, 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<(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 &items { + env.encode(&mut body)?; + } + uploads.push((filename, body, items)); + } + if !uploads.is_empty() { + let backend = self.backend.clone(); + 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 (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; + 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); + + // 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; + } + } + if processed.contains_key(&file.name) { + 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); + } + + // 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(self.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(&name); + tracing::debug!("Drive download {} failed: {}", name, e); + } + } + } + + 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; + } + } + } + + // 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; + } + } + } + } + } + } + } + } +} + +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> { + // 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?; + 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 { + 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."); + + let mut shutdown = shutdown; + loop { + 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); + } + }); + } + } + } +} + +/// 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); + 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 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) + .map(|d| d.as_nanos()) + .unwrap_or(1) + .max(1) +} + +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()); + } + + #[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 new file mode 100644 index 0000000..e7ba9b8 --- /dev/null +++ b/src/google_drive.rs @@ -0,0 +1,1143 @@ +//! 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::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, 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; +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::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::timeout; +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); + +/// 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, + /// 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 { + fn new() -> Self { + Self { + start: Instant::now(), + bucket_start_secs: AtomicU64::new(0), + bucket_count: AtomicU64::new(0), + total: AtomicU64::new(0), + warned_this_bucket: AtomicBool::new(false), + } + } + + /// 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); + 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 + && 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.", + 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}")] + 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), +} + +/// 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>>>, + /// 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 { + status: u16, + body: Vec, +} + +impl GoogleApiClient { + fn new(connect_host: String, sni: String, host_header: String, verify_ssl: bool) -> Self { + let mut 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() + }; + // 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), + quota: Arc::new(QuotaTracker::new()), + } + } + + /// 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())?; + 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( + &self, + method: &str, + path: &str, + 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. + 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 { + builder = builder.header(k, v); + } + let req = builder + .body(Full::new(Bytes::from(body.to_vec()))) + .map_err(|e| DriveError::BadResponse(format!("build request: {}", e)))?; + + 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)))?; + + let status = resp.status().as_u16(); + let body_bytes = timeout(HTTP_TIMEOUT, resp.into_body().collect()) + .await + .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, + }) + } +} + +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()), + }) + } + + /// 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 self.try_login_with_cached_token().await? { + return Ok(()); + } + self.interactive_login().await + } + + /// 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) + } + + /// 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()) + } + + /// 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() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| DriveError::OAuth("redirect URL did not contain a code".into()))? + } else { + trimmed.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)?; + 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!(); + 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 + } + + /// 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 { + DriveError::Http { + status: resp.status, + body: String::from_utf8_lossy(&resp.body) + .chars() + .take(500) + .collect(), + } +} + +/// 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); + 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(()) +} + +#[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::*; + + #[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 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 + // 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()); + } +} 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..344eaa5 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,38 @@ 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 (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 { + eprintln!("google_drive client error: {}", e); + return ExitCode::FAILURE; + } + } + _ = tokio::signal::ctrl_c() => { + tracing::warn!("Ctrl+C — shutting down google_drive client."); + let _ = shutdown_tx.send(()); + } + } + 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;