From c124f1dc2a987d9d1f29562d213293b81b8c1121 Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 21:48:19 +0800 Subject: [PATCH 1/5] refactor: lazy-load account mapping on cache miss instead of startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the cache_guid → email mapping was built once at Chrome startup, before sync initialized — so cache_guid didn't exist yet and the mapping was always empty on first run. Now the proxy scans Preferences files on demand when a client_id is first seen, then caches the result. Works correctly for: - First-time sync (cache_guid appears after sync init) - Multiple profiles (scans all Profile dirs on miss) - New accounts added mid-session --- crates/payload/src/lib.rs | 4 +- crates/payload/src/mapping.rs | 134 +++++++++++++++++++--------------- crates/payload/src/proxy.rs | 2 +- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/crates/payload/src/lib.rs b/crates/payload/src/lib.rs index 1661dcc..a67602d 100644 --- a/crates/payload/src/lib.rs +++ b/crates/payload/src/lib.rs @@ -150,8 +150,8 @@ pub unsafe extern "C" fn __libc_start_main( info!(user_data_dir, "chrome browser process detected"); - let account_mapping = AccountMapping::build(&user_data_dir); - info!(?account_mapping, "built account mapping"); + let account_mapping = AccountMapping::new(&user_data_dir); + info!(?account_mapping, "initialized account mapping (lazy-load)"); MAPPING.set(account_mapping).ok(); let upstream = upstream_url(); diff --git a/crates/payload/src/mapping.rs b/crates/payload/src/mapping.rs index 51d4d08..7fe53c7 100644 --- a/crates/payload/src/mapping.rs +++ b/crates/payload/src/mapping.rs @@ -1,16 +1,18 @@ use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use serde::Deserialize; use sha2::{Digest, Sha256}; -/// cache_guid → email 映射表 -#[derive(Debug, Default)] +/// 懒加载的 cache_guid → email 映射。 +/// 收到请求时按需扫描 Preferences,命中后缓存。 pub struct AccountMapping { - map: HashMap, + user_data_dir: PathBuf, + cache: Mutex>, } #[derive(Deserialize)] @@ -50,38 +52,65 @@ struct TransportData { } impl AccountMapping { - /// 扫描所有 profile 目录,构建 cache_guid → email 映射 - pub fn build(user_data_dir: &str) -> Self { - let mut mapping = AccountMapping::default(); - let base = Path::new(user_data_dir); - - // 扫描 Default + Profile N 目录 - let mut profile_dirs = vec![base.join("Default")]; - if let Ok(entries) = fs::read_dir(base) { - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.starts_with("Profile ") { - profile_dirs.push(entry.path()); - } - } + pub fn new(user_data_dir: &str) -> Self { + Self { + user_data_dir: PathBuf::from(user_data_dir), + cache: Mutex::new(HashMap::new()), } + } + + /// 通过 client_id (cache_guid) 查找 email。 + /// 先查缓存,miss 时扫描所有 profile 的 Preferences 文件。 + pub fn lookup(&self, client_id: &str) -> Option { + // 1. 查缓存 + if let Some(email) = self.cache.lock().unwrap().get(client_id) { + return Some(email.clone()); + } + + // 2. cache miss — 扫描所有 profile + let found = self.scan_profiles(client_id); + + // 3. 命中则缓存 + if let Some(ref email) = found { + self.cache + .lock() + .unwrap() + .insert(client_id.to_string(), email.clone()); + tracing::info!(client_id, email, "resolved and cached account mapping"); + } + + found + } + + fn scan_profiles(&self, target_cache_guid: &str) -> Option { + let profile_dirs = self.list_profile_dirs(); for dir in &profile_dirs { - if let Err(e) = mapping.load_profile(dir) { - tracing::debug!("skipping {}: {e}", dir.display()); + if let Some(email) = self.find_email_in_profile(dir, target_cache_guid) { + return Some(email); } } + None + } - mapping + fn list_profile_dirs(&self) -> Vec { + let mut dirs = vec![self.user_data_dir.join("Default")]; + if let Ok(entries) = fs::read_dir(&self.user_data_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with("Profile ") { + dirs.push(entry.path()); + } + } + } + dirs } - fn load_profile(&mut self, profile_dir: &Path) -> Result<(), Box> { - let prefs_path = profile_dir.join("Preferences"); - let content = fs::read_to_string(&prefs_path)?; - let prefs: Preferences = serde_json::from_str(&content)?; + fn find_email_in_profile(&self, profile_dir: &Path, target_cache_guid: &str) -> Option { + let content = fs::read_to_string(profile_dir.join("Preferences")).ok()?; + let prefs: Preferences = serde_json::from_str(&content).ok()?; - // 构建 gaia_id → email 映射 + // gaia_id → email let mut gaia_to_email: HashMap = HashMap::new(); if let Some(accounts) = &prefs.account_info { @@ -92,7 +121,6 @@ impl AccountMapping { } } - // 从 google.services 补充(如果 account_info 没有对应的 email) if let Some(google) = &prefs.google && let Some(services) = &google.services && let Some(account_id) = &services.account_id @@ -108,44 +136,36 @@ impl AccountMapping { } } - // 遍历 transport_data_per_account,通过 gaia_id_hash 关联 cache_guid 和 email - if let Some(sync) = &prefs.sync - && let Some(transport) = &sync.transport_data_per_account - { - for (gaia_id_hash, data) in transport { - if let Some(cache_guid) = &data.cache_guid { - let email = gaia_to_email.iter().find_map(|(gaia_id, email)| { - let hash = gaia_id_to_hash(gaia_id); - if &hash == gaia_id_hash { - Some(email.clone()) - } else { - None - } - }); - - if let Some(email) = email { - let profile = profile_dir - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - tracing::info!(profile, cache_guid, email, "mapped account"); - self.map.insert(cache_guid.clone(), email); + // 在 transport_data_per_account 中找 target_cache_guid + let transport = prefs.sync?.transport_data_per_account?; + for (gaia_id_hash, data) in &transport { + if data.cache_guid.as_deref() == Some(target_cache_guid) { + // 反查 gaia_id_hash → gaia_id → email + let email = gaia_to_email.iter().find_map(|(gaia_id, email)| { + if gaia_id_to_hash(gaia_id) == *gaia_id_hash { + Some(email.clone()) + } else { + None } - } + }); + return email; } } - Ok(()) + None } +} - /// 通过 client_id (cache_guid) 查找 email - pub fn lookup(&self, client_id: &str) -> Option<&str> { - self.map.get(client_id).map(String::as_str) +impl std::fmt::Debug for AccountMapping { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cached = self.cache.lock().unwrap().len(); + f.debug_struct("AccountMapping") + .field("user_data_dir", &self.user_data_dir) + .field("cached_entries", &cached) + .finish() } } -/// gaia_id → base64(sha256(gaia_id)) fn gaia_id_to_hash(gaia_id: &str) -> String { let hash = Sha256::digest(gaia_id.as_bytes()); BASE64.encode(hash) diff --git a/crates/payload/src/proxy.rs b/crates/payload/src/proxy.rs index a4c3622..beb6afa 100644 --- a/crates/payload/src/proxy.rs +++ b/crates/payload/src/proxy.rs @@ -61,7 +61,7 @@ fn handle_request( match &email { Some(email) => info!( - email, + email = email.as_str(), client_id = client_id.as_deref().unwrap_or("?"), "sync request" ), From 89167d58e6c19bf47579e38bf1ec578a64249a9a Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 21:53:01 +0800 Subject: [PATCH 2/5] fix: fallback to single-account email when cache_guid not yet in Preferences Chrome generates cache_guid in memory and starts syncing before writing transport_data_per_account to disk. For profiles with a single logged-in account, use that email directly instead of returning unknown. --- crates/payload/src/mapping.rs | 37 +++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/payload/src/mapping.rs b/crates/payload/src/mapping.rs index 7fe53c7..9f06a09 100644 --- a/crates/payload/src/mapping.rs +++ b/crates/payload/src/mapping.rs @@ -137,19 +137,32 @@ impl AccountMapping { } // 在 transport_data_per_account 中找 target_cache_guid - let transport = prefs.sync?.transport_data_per_account?; - for (gaia_id_hash, data) in &transport { - if data.cache_guid.as_deref() == Some(target_cache_guid) { - // 反查 gaia_id_hash → gaia_id → email - let email = gaia_to_email.iter().find_map(|(gaia_id, email)| { - if gaia_id_to_hash(gaia_id) == *gaia_id_hash { - Some(email.clone()) - } else { - None - } - }); - return email; + if let Some(transport) = prefs + .sync + .as_ref() + .and_then(|s| s.transport_data_per_account.as_ref()) + { + for (gaia_id_hash, data) in transport { + if data.cache_guid.as_deref() == Some(target_cache_guid) { + let email = gaia_to_email.iter().find_map(|(gaia_id, email)| { + if gaia_id_to_hash(gaia_id) == *gaia_id_hash { + Some(email.clone()) + } else { + None + } + }); + return email; + } + } + } + + // Fallback: transport_data 还没写入磁盘,但 profile 只有一个账号时直接用 + if gaia_to_email.len() == 1 { + let email = gaia_to_email.into_values().next(); + if email.is_some() { + tracing::debug!("cache_guid not in Preferences yet, using single-account fallback"); } + return email; } None From b0f8904630f4f4e086dccaf032e13a8a459b6f9b Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 22:04:51 +0800 Subject: [PATCH 3/5] feat: read user email from protobuf share field instead of header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientToServerMessage.share contains the user's email on every request. This eliminates the need for LD_PRELOAD account mapping to identify users — the server can now work correctly without the payload proxy. --- crates/sync-server/src/handler/sync.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/sync-server/src/handler/sync.rs b/crates/sync-server/src/handler/sync.rs index ecc4071..487d3a7 100644 --- a/crates/sync-server/src/handler/sync.rs +++ b/crates/sync-server/src/handler/sync.rs @@ -15,18 +15,24 @@ pub async fn handle_command( Extension(UserEmail(email)): Extension, body: Bytes, ) -> impl IntoResponse { - tracing::info!(email, body_len = body.len(), "incoming sync request"); - let msg = match sync_pb::ClientToServerMessage::decode(body.as_ref()) { Ok(m) => m, Err(e) => { - tracing::error!(email, "failed to decode ClientToServerMessage: {e}"); + tracing::error!("failed to decode ClientToServerMessage: {e}"); return (StatusCode::BAD_REQUEST, Vec::new()); } }; + // Prefer email from protobuf `share` field (Chrome always sends it), + // fall back to X-Sync-User-Email header, then default. + let email = if !msg.share.is_empty() { + msg.share.clone() + } else { + email + }; + let msg_type = message_type_name(msg.message_contents); - tracing::info!(email, msg_type, "processing sync message"); + tracing::info!(email, msg_type, body_len = body.len(), "sync request"); let user = match find_or_create_user(&db, &email).await { Ok(u) => u, From 86714dff6dd84e35a451fd19fba3eaf78938b3f1 Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 22:11:56 +0800 Subject: [PATCH 4/5] refactor: remove LD_PRELOAD payload, use protobuf share field for auth Chrome sends the signed-in account email in ClientToServerMessage.share on every request. This eliminates the need for LD_PRELOAD injection, HTTP proxy, and Preferences file mapping entirely. - Remove crates/payload/ (LD_PRELOAD .so, HTTP proxy, account mapping) - Remove docs/account-mapping.md - Server reads email from msg.share, falls back to anonymous@localhost - Remove auth middleware (no longer needed) - Remove tower dependency - Update CLAUDE.md and README.md --- CLAUDE.md | 45 +--- Cargo.lock | 331 +------------------------ Cargo.toml | 2 +- README.md | 47 +--- crates/payload/Cargo.toml | 19 -- crates/payload/src/lib.rs | 202 --------------- crates/payload/src/mapping.rs | 185 -------------- crates/payload/src/proxy.rs | 135 ---------- crates/sync-server/Cargo.toml | 3 +- crates/sync-server/src/auth.rs | 23 +- crates/sync-server/src/handler/sync.rs | 12 +- crates/sync-server/src/main.rs | 3 +- docs/account-mapping.md | 112 --------- lazycat/lzc-build.yml | 3 + lazycat/lzc-manifest.yml | 44 ++++ 15 files changed, 73 insertions(+), 1093 deletions(-) delete mode 100644 crates/payload/Cargo.toml delete mode 100644 crates/payload/src/lib.rs delete mode 100644 crates/payload/src/mapping.rs delete mode 100644 crates/payload/src/proxy.rs delete mode 100644 docs/account-mapping.md create mode 100644 lazycat/lzc-build.yml create mode 100644 lazycat/lzc-manifest.yml diff --git a/CLAUDE.md b/CLAUDE.md index adfe6fb..33aa452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,25 +4,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -**selfsync** — self-hosted Chrome sync solution. A Cargo workspace with three crates: +**selfsync** — self-hosted Chrome sync solution. A Cargo workspace with two crates: -- **selfsync-payload** — LD_PRELOAD shared library (cdylib) that injects into Google Chrome. Hooks `__libc_start_main` to redirect sync traffic to a local server via `--sync-url`, identifies users by `cache_guid → email` mapping from Chrome Preferences. -- **selfsync-server** — Chrome sync server (axum + sea-orm + SQLite). Handles `COMMIT` and `GET_UPDATES` via protobuf. Auth from `X-Sync-User-Email` header. +- **selfsync-server** — Chrome sync server (axum + sea-orm + SQLite). Handles `COMMIT` and `GET_UPDATES` via protobuf. User identity from protobuf `share` field. - **selfsync-nigori** — Nigori encryption library (AES-128-CBC + HMAC-SHA256, PBKDF2/Scrypt key derivation). ## Build & Test ```bash cargo build --release # Build all -cargo build --release -p selfsync-payload # Payload .so only cargo build --release -p selfsync-server # Server only cargo check # Type check workspace cargo clippy # Lint check -``` - -Run payload with Chrome: -```bash -LD_PRELOAD=./target/release/libselfsync_payload.so google-chrome-stable +cargo test # Run tests ``` ## Project Structure @@ -30,11 +24,6 @@ LD_PRELOAD=./target/release/libselfsync_payload.so google-chrome-stable ``` selfsync/ ├── crates/ -│ ├── payload/ # LD_PRELOAD .so (cdylib) -│ │ └── src/ -│ │ ├── lib.rs # __libc_start_main hook, argv injection -│ │ ├── mapping.rs # cache_guid → email mapping from Preferences -│ │ └── proxy.rs # HTTP proxy, adds X-Sync-User-Email header │ ├── nigori/ # Nigori encryption library │ │ └── src/ │ │ ├── lib.rs # Nigori struct: encrypt/decrypt/get_key_name @@ -47,8 +36,9 @@ selfsync/ │ └── src/ │ ├── main.rs # axum server entry point │ ├── proto.rs # Generated protobuf types -│ ├── auth.rs # X-Sync-User-Email middleware +│ ├── auth.rs # Default email constant │ ├── progress.rs # Progress token encoding/decoding +│ ├── util.rs # Shared utilities (gen_id, now_millis, etc.) │ ├── db/ │ │ ├── mod.rs # SQLite connection + WAL mode │ │ ├── migration.rs # Schema creation (users, sync_entities) @@ -56,25 +46,17 @@ selfsync/ │ └── handler/ │ ├── sync.rs # POST /command/ dispatch │ ├── commit.rs # COMMIT: create/update entities -│ └── get_updates.rs # GET_UPDATES: fetch by version -└── docs/ - └── account-mapping.md +│ ├── get_updates.rs # GET_UPDATES: fetch by version +│ ├── init.rs # User initialization (Nigori + bookmarks) +│ └── users.rs # GET / user list page ``` -## Payload Architecture - -- **lib.rs** — `__libc_start_main` hook. Detects Chrome browser process (checks `argv[0]` ends with `/chrome`; skips `--type=` subprocesses and non-Chrome binaries). Reads `--user-data-dir` from argv. Injects `--sync-url` pointing to embedded proxy. Starts proxy thread. - -- **mapping.rs** — Builds `cache_guid -> email` mapping by scanning all Chrome profile directories. Algorithm: `account_info[].gaia` → `base64(sha256(gaia_id))` → match key in `sync.transport_data_per_account` → extract `sync.cache_guid`. See `docs/account-mapping.md`. - -- **proxy.rs** — HTTP proxy on dynamic port (OS-assigned). Extracts `client_id` from URL query, looks up email, adds `X-Sync-User-Email` header, forwards to upstream. - ## Sync Server - **Endpoint**: `POST /command/` — handles protobuf `ClientToServerMessage` → `ClientToServerResponse` - **Alternate**: `POST /chrome-sync/command/` — same handler, for `--sync-url=http://host:port/chrome-sync` - **Dashboard**: `GET /` — HTML user list -- **Auth**: reads `X-Sync-User-Email` header (injected by payload proxy), fallback `anonymous@localhost` +- **Auth**: reads email from protobuf `share` field (Chrome always sends the signed-in account email); fallback `anonymous@localhost` - **Storage**: SQLite (WAL mode), single `sync_entities` table (no sharding) - **Version**: per-user monotonic counter (`users.next_version`), assigned on commit - **Progress tokens**: `v1,{data_type_id},{version}` base64-encoded @@ -85,6 +67,7 @@ selfsync/ ## Chrome Sync Protocol Gotchas - `--sync-url=http://host:port` — Chrome appends `/command/` automatically; do NOT include it in the URL +- `ClientToServerMessage.share` contains the user's email — no need for external auth/headers - `ClientToServerResponse.error_code` must be explicitly set to `SUCCESS (0)` — proto default is `UNKNOWN`, Chrome treats it as error - `NigoriSpecifics.passphrase_type`: `KEYSTORE_PASSPHRASE = 2`, `CUSTOM_PASSPHRASE = 4` — wrong value causes "Needs passphrase" error - Chrome caches Nigori state locally; after server DB reset, must use fresh Chrome profile (`--user-data-dir=/tmp/test`) @@ -104,11 +87,3 @@ Relevant paths in `~/modous/chromium/src/`: - `components/sync/engine/net/http_bridge.cc` — `MakeAsynchronousPost()`, HTTP request construction - `components/sync/protocol/sync.proto` — `ClientToServerMessage`, `ClientToServerResponse` - `components/sync/engine/loopback_server/loopback_server.cc` — Reference sync server implementation - -## Important Constraints - -- `LD_PRELOAD` affects ALL child processes. `is_chrome_browser_process()` must verify `argv[0]` to skip non-Chrome binaries. -- Chrome runs multiple profiles in a single browser process. Proxy differentiates via `client_id` (cache_guid). -- Chrome's BoringSSL is statically linked — cannot hook SSL functions via LD_PRELOAD. -- Proxy uses HTTP for local endpoint; Chrome's `--sync-url` accepts `http://`. -- `GURL::ReplaceComponents` with `SetPathStr` preserves existing query parameters. diff --git a/Cargo.lock b/Cargo.lock index 40ffddd..c1c784e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,12 +122,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - [[package]] name = "async-compression" version = "0.4.41" @@ -418,12 +412,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - [[package]] name = "cipher" version = "0.4.4" @@ -897,10 +885,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -910,11 +896,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1079,23 +1063,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -1104,21 +1071,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", "tokio", "tower-service", - "tracing", ] [[package]] @@ -1293,22 +1252,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1336,8 +1279,6 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -1419,12 +1360,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "mac_address" version = "1.1.8" @@ -1920,61 +1855,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -2117,46 +1997,6 @@ dependencies = [ "bytecheck", ] -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.6", -] - [[package]] name = "ring" version = "0.17.14" @@ -2237,12 +2077,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustc_version" version = "0.4.1" @@ -2285,7 +2119,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -2522,20 +2355,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "selfsync-payload" -version = "0.1.0" -dependencies = [ - "base64", - "libc", - "reqwest", - "serde", - "serde_json", - "sha2", - "tiny_http", - "url", -] - [[package]] name = "selfsync-server" version = "0.1.0" @@ -2551,7 +2370,6 @@ dependencies = [ "sea-orm-migration", "selfsync-nigori", "tokio", - "tower", "tower-http", "tracing", "tracing-subscriber", @@ -3020,9 +2838,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] [[package]] name = "synstructure" @@ -3114,18 +2929,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -3179,16 +2982,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -3269,15 +3062,12 @@ dependencies = [ "bitflags", "bytes", "futures-core", - "futures-util", "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", ] @@ -3356,12 +3146,6 @@ 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 = "typenum" version = "1.19.0" @@ -3461,15 +3245,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[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" @@ -3514,16 +3289,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -3590,26 +3355,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "0.26.11" @@ -3737,15 +3482,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3779,30 +3515,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3815,12 +3534,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3833,12 +3546,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3851,24 +3558,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3881,12 +3576,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3899,12 +3588,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3917,12 +3600,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3935,12 +3612,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 46baa98..ad6d786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/payload", "crates/sync-server", "crates/nigori"] +members = ["crates/sync-server", "crates/nigori"] resolver = "3" diff --git a/README.md b/README.md index cbd99d4..837aa4e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted Chrome Sync server. Keep your bookmarks, passwords, preferences, and ## How It Works -Chrome natively supports syncing to a custom server via the `--sync-url` flag. selfsync implements the Chrome Sync protocol and stores everything locally in a single SQLite file. +Chrome natively supports syncing to a custom server via the `--sync-url` flag. selfsync implements the Chrome Sync protocol and stores everything locally in a single SQLite file. Multi-user support works out of the box — Chrome sends the signed-in account email with every sync request. ## Quick Start @@ -51,52 +51,22 @@ Done. All your sync data now stays on your machine. ## Configuration -Environment variables: - | Variable | Default | Description | |----------|---------|-------------| | `SELFSYNC_ADDR` | `127.0.0.1:8080` | Server listen address | | `SELFSYNC_DB` | `selfsync.db` | SQLite database path | -| `SELFSYNC_UPSTREAM` | Google's sync server | Upstream URL for LD\_PRELOAD proxy | | `RUST_LOG` | `selfsync_server=info` | Log level | When running via Docker, the database defaults to `/data/selfsync.db` and listens on `0.0.0.0:8080`. -## Multi-User Support (Optional) - -By default, all data goes under a single anonymous user — perfectly fine for personal use. - -For shared servers with multiple users, the server needs to know which Google account each sync request belongs to. Chrome does not send this information on its own, so selfsync uses an LD\_PRELOAD injector to intercept Chrome's sync traffic and tag each request with the user's email. - -```bash -SELFSYNC_UPSTREAM=http://127.0.0.1:8080/chrome-sync \ -LD_PRELOAD=./target/release/libselfsync_payload.so \ -google-chrome-stable -``` - -The injector hooks into Chrome at startup, reads the local profile data to figure out which Google account is active, and injects the corresponding email header into every sync request. `SELFSYNC_UPSTREAM` tells the proxy where to forward traffic — set it to your selfsync-server instance. - -### Platform Support +## Multi-User -| Platform | Single-user sync | Multi-user sync | -|----------|-----------------|-----------------| -| Linux | Yes | Yes (via LD\_PRELOAD) | -| macOS | Yes | Not yet | -| Windows | Yes | Not yet | -| iOS / Android | Not applicable | Not applicable | - -**Why only Linux for multi-user?** Multi-user support requires injecting code into the Chrome process to intercept sync requests. On Linux this is done via `LD_PRELOAD`, a standard mechanism for hooking shared libraries. macOS and Windows have no direct equivalent — macOS has `DYLD_INSERT_LIBRARIES` but SIP blocks it for system-protected binaries, and Windows would require DLL injection techniques. Support for these platforms is planned but not yet implemented. - -Single-user sync works on any platform — just launch Chrome with `--sync-url` and all data goes under the default anonymous user. - -### Roadmap: Custom Chromium Build - -We are planning to build a custom Chromium browser that natively sends user identity with sync requests. This would eliminate the need for LD\_PRELOAD hooking entirely — multi-user sync would work out of the box on all platforms without any injection. +Multi-user works automatically. Chrome includes the signed-in Google account email in every sync request (via the protobuf `share` field). The server uses this to create separate data stores per user — no additional configuration needed. ## Things to Watch Out For - **Do NOT include `/command/` in `--sync-url`**. Chrome appends it automatically. Just use `http://127.0.0.1:8080`. -- **Multi-user sync only works on Linux for now**. See [Platform Support](#platform-support) above. +- **After resetting the server database**, use a fresh Chrome profile (`--user-data-dir=/tmp/test`) to avoid stale sync state. ## Building @@ -105,16 +75,9 @@ Requires Rust 1.85+: ```bash cargo build --release # Build everything cargo build --release -p selfsync-server # Server only -cargo build --release -p selfsync-payload # Injector only +cargo test # Run tests ``` -## Documentation - -For implementation details, see the [docs/](docs/) directory: - -- [architecture.md](docs/architecture.md) — Architecture and internals -- [account-mapping.md](docs/account-mapping.md) — Multi-user account mapping algorithm - ## Prior Art - Chromium `loopback_server.cc` — Reference sync server implementation diff --git a/crates/payload/Cargo.toml b/crates/payload/Cargo.toml deleted file mode 100644 index 0b64858..0000000 --- a/crates/payload/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "selfsync-payload" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -libc = "0.2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -base64 = "0.22" -reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false } -tiny_http = "0.12" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -url = "2" diff --git a/crates/payload/src/lib.rs b/crates/payload/src/lib.rs deleted file mode 100644 index a67602d..0000000 --- a/crates/payload/src/lib.rs +++ /dev/null @@ -1,202 +0,0 @@ -mod mapping; -mod proxy; - -use std::env; -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_int, c_void}; -use std::sync::OnceLock; - -use mapping::AccountMapping; -use tracing::{error, info}; - -const DEFAULT_UPSTREAM_URL: &str = "https://clients4.google.com/chrome-sync"; - -fn upstream_url() -> String { - std::env::var("SELFSYNC_UPSTREAM").unwrap_or_else(|_| DEFAULT_UPSTREAM_URL.to_string()) -} - -static MAPPING: OnceLock = OnceLock::new(); -static TRACING_INIT: OnceLock<()> = OnceLock::new(); - -type MainFn = unsafe extern "C" fn(c_int, *mut *mut c_char, *mut *mut c_char) -> c_int; - -static mut REAL_MAIN: Option = None; - -unsafe extern "C" fn wrapped_main( - argc: c_int, - argv: *mut *mut c_char, - envp: *mut *mut c_char, -) -> c_int { - unsafe { - if let Some(main) = REAL_MAIN { - main(argc, argv, envp) - } else { - 1 - } - } -} - -fn init_tracing() { - TRACING_INIT.get_or_init(|| { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "selfsync_payload=info".parse().unwrap()), - ) - .with_writer(std::io::stderr) - .init(); - }); -} - -/// 判断是否是 Chrome browser 主进程: -/// 1. argv[0] 必须以 "chrome" 结尾(排除 grep、readlink 等系统命令) -/// 2. 没有 --type= 参数(排除 renderer、gpu 等子进程) -fn is_chrome_browser_process(argc: c_int, argv: *mut *mut c_char) -> bool { - if argc < 1 { - return false; - } - - let argv0 = unsafe { CStr::from_ptr(*argv) }; - let is_chrome = argv0.to_str().is_ok_and(|s| { - let bin = s.rsplit('/').next().unwrap_or(s); - matches!( - bin, - "chrome" | "chrome-stable" | "google-chrome" | "google-chrome-stable" | "chromium" - ) - }); - - if !is_chrome { - return false; - } - - for i in 1..argc as isize { - let arg = unsafe { CStr::from_ptr(*argv.offset(i)) }; - if let Ok(s) = arg.to_str() - && s.starts_with("--type=") - { - return false; - } - } - true -} - -fn get_switch_value(argc: c_int, argv: *mut *mut c_char, name: &str) -> Option { - let prefix = format!("--{name}="); - for i in 0..argc as isize { - let arg = unsafe { CStr::from_ptr(*argv.offset(i)) }; - if let Ok(s) = arg.to_str() - && let Some(value) = s.strip_prefix(&prefix) - { - return Some(value.to_string()); - } - } - None -} - -fn default_user_data_dir() -> String { - let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - format!("{home}/.config/google-chrome") -} - -pub fn get_mapping() -> Option<&'static AccountMapping> { - MAPPING.get() -} - -/// # Safety -/// Called by the dynamic linker as the process entry point. -/// `argv` must point to a valid null-terminated array of `argc` C strings. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn __libc_start_main( - main: MainFn, - argc: c_int, - argv: *mut *mut c_char, - init: Option, - fini: Option, - rtld_fini: Option, - stack_end: *mut c_void, -) -> c_int { - init_tracing(); - - let real_start_main: unsafe extern "C" fn( - MainFn, - c_int, - *mut *mut c_char, - Option, - Option, - Option, - *mut c_void, - ) -> c_int = unsafe { - let sym = libc::dlsym(libc::RTLD_NEXT, c"__libc_start_main".as_ptr()); - std::mem::transmute(sym) - }; - - let argv0 = if argc > 0 { - unsafe { CStr::from_ptr(*argv) }.to_str().unwrap_or("?") - } else { - "?" - }; - let is_chrome = is_chrome_browser_process(argc, argv); - info!(argv0, is_chrome, "hooked process"); - - if !is_chrome { - unsafe { - REAL_MAIN = Some(main); - return real_start_main(wrapped_main, argc, argv, init, fini, rtld_fini, stack_end); - } - } - - let user_data_dir = - get_switch_value(argc, argv, "user-data-dir").unwrap_or_else(default_user_data_dir); - - info!(user_data_dir, "chrome browser process detected"); - - let account_mapping = AccountMapping::new(&user_data_dir); - info!(?account_mapping, "initialized account mapping (lazy-load)"); - MAPPING.set(account_mapping).ok(); - - let upstream = upstream_url(); - info!(upstream, "upstream sync server"); - - let (server, port) = match proxy::start(&upstream) { - Ok(v) => v, - Err(e) => { - error!("failed to start proxy: {e}"); - unsafe { - REAL_MAIN = Some(main); - return real_start_main(wrapped_main, argc, argv, init, fini, rtld_fini, stack_end); - } - } - }; - std::thread::spawn(move || { - if let Err(e) = proxy::run(server, &upstream) { - error!("proxy error: {e}"); - } - }); - - let sync_url = format!("http://127.0.0.1:{port}/chrome-sync"); - info!(sync_url, "injecting --sync-url"); - let sync_url_arg = CString::new(format!("--sync-url={sync_url}")).unwrap(); - - let new_argc = argc + 1; - let mut new_argv: Vec<*mut c_char> = Vec::with_capacity(new_argc as usize + 1); - for i in 0..argc as isize { - unsafe { - new_argv.push(*argv.offset(i)); - } - } - new_argv.push(sync_url_arg.into_raw()); - new_argv.push(std::ptr::null_mut()); - - unsafe { - REAL_MAIN = Some(main); - real_start_main( - wrapped_main, - new_argc, - new_argv.as_mut_ptr(), - init, - fini, - rtld_fini, - stack_end, - ) - } -} diff --git a/crates/payload/src/mapping.rs b/crates/payload/src/mapping.rs deleted file mode 100644 index 9f06a09..0000000 --- a/crates/payload/src/mapping.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use serde::Deserialize; -use sha2::{Digest, Sha256}; - -/// 懒加载的 cache_guid → email 映射。 -/// 收到请求时按需扫描 Preferences,命中后缓存。 -pub struct AccountMapping { - user_data_dir: PathBuf, - cache: Mutex>, -} - -#[derive(Deserialize)] -struct Preferences { - account_info: Option>, - google: Option, - sync: Option, -} - -#[derive(Deserialize)] -struct AccountInfoEntry { - email: Option, - gaia: Option, -} - -#[derive(Deserialize)] -struct GoogleServices { - services: Option, -} - -#[derive(Deserialize)] -struct ServicesData { - last_username: Option, - last_signed_in_username: Option, - account_id: Option, -} - -#[derive(Deserialize)] -struct SyncData { - transport_data_per_account: Option>, -} - -#[derive(Deserialize)] -struct TransportData { - #[serde(rename = "sync.cache_guid")] - cache_guid: Option, -} - -impl AccountMapping { - pub fn new(user_data_dir: &str) -> Self { - Self { - user_data_dir: PathBuf::from(user_data_dir), - cache: Mutex::new(HashMap::new()), - } - } - - /// 通过 client_id (cache_guid) 查找 email。 - /// 先查缓存,miss 时扫描所有 profile 的 Preferences 文件。 - pub fn lookup(&self, client_id: &str) -> Option { - // 1. 查缓存 - if let Some(email) = self.cache.lock().unwrap().get(client_id) { - return Some(email.clone()); - } - - // 2. cache miss — 扫描所有 profile - let found = self.scan_profiles(client_id); - - // 3. 命中则缓存 - if let Some(ref email) = found { - self.cache - .lock() - .unwrap() - .insert(client_id.to_string(), email.clone()); - tracing::info!(client_id, email, "resolved and cached account mapping"); - } - - found - } - - fn scan_profiles(&self, target_cache_guid: &str) -> Option { - let profile_dirs = self.list_profile_dirs(); - - for dir in &profile_dirs { - if let Some(email) = self.find_email_in_profile(dir, target_cache_guid) { - return Some(email); - } - } - None - } - - fn list_profile_dirs(&self) -> Vec { - let mut dirs = vec![self.user_data_dir.join("Default")]; - if let Ok(entries) = fs::read_dir(&self.user_data_dir) { - for entry in entries.flatten() { - let name = entry.file_name(); - if name.to_string_lossy().starts_with("Profile ") { - dirs.push(entry.path()); - } - } - } - dirs - } - - fn find_email_in_profile(&self, profile_dir: &Path, target_cache_guid: &str) -> Option { - let content = fs::read_to_string(profile_dir.join("Preferences")).ok()?; - let prefs: Preferences = serde_json::from_str(&content).ok()?; - - // gaia_id → email - let mut gaia_to_email: HashMap = HashMap::new(); - - if let Some(accounts) = &prefs.account_info { - for acc in accounts { - if let (Some(gaia), Some(email)) = (&acc.gaia, &acc.email) { - gaia_to_email.insert(gaia.clone(), email.clone()); - } - } - } - - if let Some(google) = &prefs.google - && let Some(services) = &google.services - && let Some(account_id) = &services.account_id - { - let email = services - .last_username - .as_deref() - .or(services.last_signed_in_username.as_deref()); - if let Some(email) = email { - gaia_to_email - .entry(account_id.clone()) - .or_insert_with(|| email.to_string()); - } - } - - // 在 transport_data_per_account 中找 target_cache_guid - if let Some(transport) = prefs - .sync - .as_ref() - .and_then(|s| s.transport_data_per_account.as_ref()) - { - for (gaia_id_hash, data) in transport { - if data.cache_guid.as_deref() == Some(target_cache_guid) { - let email = gaia_to_email.iter().find_map(|(gaia_id, email)| { - if gaia_id_to_hash(gaia_id) == *gaia_id_hash { - Some(email.clone()) - } else { - None - } - }); - return email; - } - } - } - - // Fallback: transport_data 还没写入磁盘,但 profile 只有一个账号时直接用 - if gaia_to_email.len() == 1 { - let email = gaia_to_email.into_values().next(); - if email.is_some() { - tracing::debug!("cache_guid not in Preferences yet, using single-account fallback"); - } - return email; - } - - None - } -} - -impl std::fmt::Debug for AccountMapping { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let cached = self.cache.lock().unwrap().len(); - f.debug_struct("AccountMapping") - .field("user_data_dir", &self.user_data_dir) - .field("cached_entries", &cached) - .finish() - } -} - -fn gaia_id_to_hash(gaia_id: &str) -> String { - let hash = Sha256::digest(gaia_id.as_bytes()); - BASE64.encode(hash) -} diff --git a/crates/payload/src/proxy.rs b/crates/payload/src/proxy.rs deleted file mode 100644 index beb6afa..0000000 --- a/crates/payload/src/proxy.rs +++ /dev/null @@ -1,135 +0,0 @@ -use std::io::Cursor; - -use tiny_http::{Header, Response, Server}; -use tracing::{debug, error, info, warn}; -use url::Url; - -use crate::get_mapping; - -const EMAIL_HEADER: &str = "X-Sync-User-Email"; - -pub fn start(_upstream_base: &str) -> Result<(Server, u16), Box> { - let server = Server::http("127.0.0.1:0").map_err(|e| format!("bind 127.0.0.1:0: {e}"))?; - let port = server - .server_addr() - .to_ip() - .ok_or("failed to get server address")? - .port(); - info!(port, "proxy listening"); - Ok((server, port)) -} - -pub fn run(server: Server, upstream_base: &str) -> Result<(), Box> { - let client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(false) - .build()?; - - for mut request in server.incoming_requests() { - let result = handle_request(&client, &mut request, upstream_base); - match result { - Ok(response) => { - if let Err(e) = request.respond(response) { - error!("respond error: {e}"); - } - } - Err(e) => { - error!("request error: {e}"); - let resp = Response::from_string(format!("proxy error: {e}")).with_status_code(502); - let _ = request.respond(resp); - } - } - } - - Ok(()) -} - -fn handle_request( - client: &reqwest::blocking::Client, - request: &mut tiny_http::Request, - upstream_base: &str, -) -> Result>>, Box> { - let request_url = format!("http://localhost{}", request.url()); - let parsed = Url::parse(&request_url)?; - let client_id = parsed - .query_pairs() - .find(|(k, _)| k == "client_id") - .map(|(_, v)| v.to_string()); - - let email = client_id - .as_deref() - .and_then(|id| get_mapping().and_then(|m| m.lookup(id))); - - match &email { - Some(email) => info!( - email = email.as_str(), - client_id = client_id.as_deref().unwrap_or("?"), - "sync request" - ), - None => warn!(client_id = ?client_id, "sync request from unknown user"), - } - - let upstream_url = build_upstream_url(upstream_base, request.url())?; - debug!(upstream_url, "forwarding request"); - - let mut body = Vec::new(); - request.as_reader().read_to_end(&mut body)?; - - let mut upstream_req = client.post(&upstream_url); - - for header in request.headers() { - let name = header.field.as_str().as_str(); - let value = header.value.as_str(); - if matches!( - name.to_lowercase().as_str(), - "host" | "connection" | "transfer-encoding" | "content-length" - ) { - continue; - } - upstream_req = upstream_req.header(name, value); - } - - if let Some(email) = email { - upstream_req = upstream_req.header(EMAIL_HEADER, email); - } - - let upstream_resp = upstream_req.body(body).send()?; - - let status = upstream_resp.status().as_u16(); - debug!(status, "upstream response"); - - let resp_headers: Vec
= upstream_resp - .headers() - .iter() - .filter_map(|(name, value)| { - let name_str = name.as_str(); - if matches!( - name_str.to_lowercase().as_str(), - "transfer-encoding" | "connection" - ) { - return None; - } - let value_str = value.to_str().ok()?; - Header::from_bytes(name_str.as_bytes(), value_str.as_bytes()).ok() - }) - .collect(); - - let resp_body = upstream_resp.bytes()?.to_vec(); - let mut response = Response::from_data(resp_body).with_status_code(status); - for header in resp_headers { - response.add_header(header); - } - - Ok(response) -} - -fn build_upstream_url( - upstream_base: &str, - local_path: &str, -) -> Result> { - let base = Url::parse(upstream_base)?; - let stripped = local_path - .strip_prefix("/chrome-sync") - .unwrap_or(local_path); - let base_str = base.as_str().trim_end_matches('/'); - Ok(format!("{base_str}{stripped}")) -} diff --git a/crates/sync-server/Cargo.toml b/crates/sync-server/Cargo.toml index b6086a1..55f2b8e 100644 --- a/crates/sync-server/Cargo.toml +++ b/crates/sync-server/Cargo.toml @@ -18,8 +18,7 @@ sea-orm = { version = "1", features = [ sea-orm-migration = "1" selfsync-nigori = { path = "../nigori" } tokio = { version = "1", features = ["full"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["compression-gzip", "decompression-gzip"] } +tower-http = { version = "0.6", features = ["decompression-gzip"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4"] } diff --git a/crates/sync-server/src/auth.rs b/crates/sync-server/src/auth.rs index 95fc310..1e5991e 100644 --- a/crates/sync-server/src/auth.rs +++ b/crates/sync-server/src/auth.rs @@ -1,21 +1,2 @@ -use axum::{extract::Request, middleware::Next, response::Response}; - -const DEFAULT_EMAIL: &str = "anonymous@localhost"; -const EMAIL_HEADER: &str = "x-sync-user-email"; - -/// Extract user email from X-Sync-User-Email header, fallback to default. -#[derive(Clone, Debug)] -pub struct UserEmail(pub String); - -pub async fn auth_middleware(mut request: Request, next: Next) -> Response { - let email = request - .headers() - .get(EMAIL_HEADER) - .and_then(|v| v.to_str().ok()) - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_EMAIL) - .to_string(); - - request.extensions_mut().insert(UserEmail(email)); - next.run(request).await -} +/// Default email when protobuf `share` field is empty. +pub const DEFAULT_EMAIL: &str = "anonymous@localhost"; diff --git a/crates/sync-server/src/handler/sync.rs b/crates/sync-server/src/handler/sync.rs index 487d3a7..37d9c24 100644 --- a/crates/sync-server/src/handler/sync.rs +++ b/crates/sync-server/src/handler/sync.rs @@ -2,7 +2,7 @@ use axum::{Extension, body::Bytes, http::StatusCode, response::IntoResponse}; use prost::Message; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; -use crate::auth::UserEmail; +use crate::auth::DEFAULT_EMAIL; use crate::db::entity::user; use crate::proto::sync_pb; use crate::util::gen_encryption_key; @@ -12,7 +12,6 @@ use super::{commit, get_updates}; /// POST /command/ — handles all Chrome sync protocol messages. pub async fn handle_command( Extension(db): Extension, - Extension(UserEmail(email)): Extension, body: Bytes, ) -> impl IntoResponse { let msg = match sync_pb::ClientToServerMessage::decode(body.as_ref()) { @@ -23,12 +22,11 @@ pub async fn handle_command( } }; - // Prefer email from protobuf `share` field (Chrome always sends it), - // fall back to X-Sync-User-Email header, then default. - let email = if !msg.share.is_empty() { - msg.share.clone() + // Email from protobuf `share` field (Chrome sends the signed-in account email). + let email = if msg.share.is_empty() { + DEFAULT_EMAIL.to_string() } else { - email + msg.share.clone() }; let msg_type = message_type_name(msg.message_contents); diff --git a/crates/sync-server/src/main.rs b/crates/sync-server/src/main.rs index 55437b1..768d5a0 100644 --- a/crates/sync-server/src/main.rs +++ b/crates/sync-server/src/main.rs @@ -6,7 +6,7 @@ mod proto; mod util; use axum::{ - Extension, Router, middleware, + Extension, Router, routing::{get, post}, }; use tower_http::decompression::RequestDecompressionLayer; @@ -32,7 +32,6 @@ async fn main() -> anyhow::Result<()> { .route("/command", post(handler::handle_command)) .route("/chrome-sync/command/", post(handler::handle_command)) .route("/chrome-sync/command", post(handler::handle_command)) - .layer(middleware::from_fn(auth::auth_middleware)) .layer(RequestDecompressionLayer::new()) .layer(Extension(db)); diff --git a/docs/account-mapping.md b/docs/account-mapping.md deleted file mode 100644 index 995d75c..0000000 --- a/docs/account-mapping.md +++ /dev/null @@ -1,112 +0,0 @@ -# Chrome Sync 账号映射算法 - -## 目标 - -在不修改 Chromium 源码的前提下,通过 LD_PRELOAD 注入的代理服务,将 Chrome Sync 请求与用户邮箱关联。 - -## 数据来源 - -Chrome 的用户数据目录(默认 `~/.config/google-chrome/`)下,每个 Profile 目录中有一个 `Preferences` JSON 文件,包含以下关键字段: - -### 1. 账号信息 — `account_info` - -```json -"account_info": [ - { - "email": "user@gmail.com", - "gaia": "109944815437949063750" - } -] -``` - -### 2. Sync 传输数据 — `sync.transport_data_per_account` - -按 `gaia_id_hash` 分组,每个账号有独立的 `cache_guid`: - -```json -"sync": { - "transport_data_per_account": { - "": { - "sync.cache_guid": "cGVaHRBTN6hqTw/PjD1XdQ==" - } - } -} -``` - -### 3. gaia_id_hash 计算方式 - -``` -gaia_id_hash = base64_encode(sha256(gaia_id)) -``` - -示例: - -``` -gaia_id: "109944815437949063750" -sha256: 5d0ec7f8a11eaf3fe93da37776b2b933aaac43b870cb43422f620fd9ec4797e1 -base64: "XQ7H+KEerz/pPaN3srkzOqxDuHDLQ0LvYg/Z7EeX4ZY=" -``` - -## 映射算法 - -``` -输入: Chrome Sync 请求 URL 中的 client_id 参数 -输出: 用户邮箱 - -步骤: -1. 扫描 user-data-dir 下所有 Profile 目录(Default, Profile 1, Profile 2, ...) -2. 对每个 Profile,读取 Preferences 文件 -3. 遍历 account_info 数组,获取所有 (gaia_id, email) 对 -4. 对每个 gaia_id 计算 hash: base64(sha256(gaia_id)) -5. 用 hash 在 sync.transport_data_per_account 中查找对应的 sync.cache_guid -6. 建立映射表: cache_guid → email - -代理收到请求时: -1. 从 URL 解析 client_id 参数(即 cache_guid) -2. 在映射表中查找对应的 email -3. 添加 X-Sync-User-Email header -``` - -## 请求 URL 格式 - -Chrome 发出的 Sync 请求 URL 由 Chromium 内部拼接: - -``` -原始 sync-url: http://127.0.0.1:PORT/chrome-sync - -最终请求 URL: http://127.0.0.1:PORT/chrome-sync/command/?client=Google+Chrome&client_id= -``` - -源码路径: -- `components/sync/engine/sync_manager_impl.cc` — `MakeConnectionURL()` 追加 `/command/` 路径 -- `components/sync/engine/net/url_translator.cc` — `AppendSyncQueryString()` 追加 `client` 和 `client_id` 参数 - -## 多 Profile 场景 - -Chrome 多 Profile 共享一个主进程(browser process)。每个 Profile 的 Sync 请求使用各自账号的 `cache_guid` 作为 `client_id`,因此代理可通过 `client_id` 区分不同 Profile 的请求。 - -## 完整示例 - -``` -Profile: Default - account_info[0].email = "alice@gmail.com" - account_info[0].gaia = "109944815437949063750" - gaia_id_hash = "XQ7H+KEerz/pPaN3srkzOqxDuHDLQ0LvYg/Z7EeX4ZY=" - cache_guid = "cGVaHRBTN6hqTw/PjD1XdQ==" - -Profile: Profile 1 - account_info[0].email = "bob@gmail.com" - account_info[0].gaia = "112978327937825080111" - gaia_id_hash = "" - cache_guid = "pWuIq2P6R9lBOM4TA34P4Q==" - -映射表: - cGVaHRBTN6hqTw/PjD1XdQ== → alice@gmail.com - pWuIq2P6R9lBOM4TA34P4Q== → bob@gmail.com - -收到请求: - POST /chrome-sync/command/?client=Google+Chrome&client_id=cGVaHRBTN6hqTw/PjD1XdQ== - → 查表得到 alice@gmail.com - → 添加 X-Sync-User-Email: alice@gmail.com - → 转发到 https://clients4.google.com/chrome-sync/command/... -``` diff --git a/lazycat/lzc-build.yml b/lazycat/lzc-build.yml new file mode 100644 index 0000000..5b15143 --- /dev/null +++ b/lazycat/lzc-build.yml @@ -0,0 +1,3 @@ +pkgout: ./ +icon: ./icon.png +manifest: ./lzc-manifest.yml diff --git a/lazycat/lzc-manifest.yml b/lazycat/lzc-manifest.yml new file mode 100644 index 0000000..d279b11 --- /dev/null +++ b/lazycat/lzc-manifest.yml @@ -0,0 +1,44 @@ +name: SelfSync +description: 自托管 Chrome 同步服务器,让浏览器数据留在本地 +version: 0.1.0 +package: selfsync.loyalpartner +homepage: https://github.com/loyalpartner/selfsync +author: loyalpartner +license: GPL-3.0 +lzc-sdk-version: 0.1 + +locales: + zh-CN: + name: SelfSync + description: 自托管 Chrome 同步服务器 — 书签、密码、偏好等浏览器数据留在本地,不经过 Google + en: + name: SelfSync + description: Self-hosted Chrome Sync server — keep bookmarks, passwords, and preferences on your own device + +application: + subdomain: chrome-sync + upstreams: + - location: / + backend: http://selfsync:8080 + public_path: + - / + depends_on: + - selfsync + +services: + selfsync: + # TODO: 替换为 lzc-cli appstore copy-image 推送后的地址 + # 执行: lzc-cli appstore copy-image selfsync:latest + # 然后将下面的 image 替换为输出的 registry.lazycat.cloud/... 地址 + image: selfsync:latest + environment: + - SELFSYNC_DB=/lzcapp/var/data/selfsync.db + - SELFSYNC_ADDR=0.0.0.0:8080 + - RUST_LOG=selfsync_server=info + binds: + - /lzcapp/var/data:/data + healthcheck: + test: wget -q -O - http://localhost:8080/ || exit 1 + interval: 30s + timeout: 10s + retries: 3 From 506f0d504ee3432a71154522264037e824117ca6 Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 22:14:58 +0800 Subject: [PATCH 5/5] docs: remove LD_PRELOAD references from README and architecture docs --- README.zh-CN.md | 44 ++++------------------------------ docs/architecture.md | 56 ++++++++++++++++++-------------------------- 2 files changed, 28 insertions(+), 72 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 55956b0..9f0ebae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ ## 工作原理 -Chrome 本身就支持把同步数据发到自定义服务器(`--sync-url` 参数)。selfsync 实现了 Chrome 的同步协议,用一个 SQLite 文件把数据存在本地。 +Chrome 本身就支持把同步数据发到自定义服务器(`--sync-url` 参数)。selfsync 实现了 Chrome 的同步协议,用一个 SQLite 文件把数据存在本地。多用户天然支持——Chrome 每次同步请求都会带上登录账号的邮箱。 ## 快速开始 @@ -51,8 +51,6 @@ docker run -d -p 8080:8080 -v ./data:/data selfsync ## 配置 -通过环境变量配置: - | 变量 | 默认值 | 说明 | |------|--------|------| | `SELFSYNC_ADDR` | `127.0.0.1:8080` | 监听地址 | @@ -61,39 +59,14 @@ docker run -d -p 8080:8080 -v ./data:/data selfsync Docker 方式下,数据库默认在 `/data/selfsync.db`,监听 `0.0.0.0:8080`。 -## 多用户支持(可选) - -默认所有数据归到一个匿名用户,一个人用完全够了。 - -多人共用一台服务器时,服务器需要知道每个同步请求属于哪个 Google 账号。Chrome 本身不会发送这个信息,所以 selfsync 通过 LD\_PRELOAD 注入器劫持 Chrome 的同步流量,给每个请求打上用户邮箱标记。 - -```bash -LD_PRELOAD=./target/release/libselfsync_payload.so google-chrome-stable -``` - -注入器在 Chrome 启动时介入,读取本地配置文件识别当前登录的 Google 账号,然后在每个同步请求里注入对应的邮箱信息。 - -### 平台支持 +## 多用户 -| 平台 | 单用户同步 | 多用户同步 | -|------|-----------|-----------| -| Linux | 支持 | 支持(通过 LD\_PRELOAD) | -| macOS | 支持 | 暂不支持 | -| Windows | 支持 | 暂不支持 | -| iOS / Android | 不适用 | 不适用 | - -**为什么多用户只支持 Linux?** 多用户需要往 Chrome 进程里注入代码来拦截同步请求。Linux 上可以用 `LD_PRELOAD` 这个标准机制来实现。macOS 虽然有类似的 `DYLD_INSERT_LIBRARIES`,但系统完整性保护(SIP)会阻止对受保护程序的注入;Windows 则需要 DLL 注入技术。这些平台的支持在规划中,但还没实现。 - -单用户同步在所有平台都能用——只要启动 Chrome 时加上 `--sync-url`,数据会归到默认的匿名用户下。 - -### 规划中:自编译 Chromium 浏览器 - -我们正在筹划编译一个定制的 Chromium 浏览器,让它在同步请求里直接带上用户身份信息。这样就完全不需要 LD\_PRELOAD 注入了——所有平台都能开箱即用地支持多用户同步。 +多用户自动支持。Chrome 在每个同步请求的 protobuf `share` 字段里会带上当前登录的 Google 账号邮箱,服务器据此为每个用户创建独立的数据空间,无需额外配置。 ## 注意事项 - **`--sync-url` 不要带 `/command/`**。Chrome 会自己追加,写 `http://127.0.0.1:8080` 就行。 -- **多用户同步目前只支持 Linux**。详见上面的[平台支持](#平台支持)。 +- **重置服务器数据库后**,需要用全新 Chrome Profile 测试(`--user-data-dir=/tmp/test`),避免本地缓存的旧状态冲突。 ## 编译 @@ -102,16 +75,9 @@ LD_PRELOAD=./target/release/libselfsync_payload.so google-chrome-stable ```bash cargo build --release # 全部编译 cargo build --release -p selfsync-server # 只编译服务器 -cargo build --release -p selfsync-payload # 只编译注入器 +cargo test # 运行测试 ``` -## 技术文档 - -实现细节见 [docs/](docs/) 目录: - -- [architecture.md](docs/architecture.md) — 架构与技术细节 -- [account-mapping.md](docs/account-mapping.md) — 多用户账号映射算法 - ## 参考 - Chromium `loopback_server.cc` — Chrome 内置的参考同步服务器实现 diff --git a/docs/architecture.md b/docs/architecture.md index bbb570f..08d1327 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,9 @@ ## 整体架构 ``` -Chrome ──(LD_PRELOAD)──> 本地代理 ──(X-Sync-User-Email)──> selfsync-server ──> SQLite +Chrome ──(--sync-url)──> selfsync-server ──> SQLite + ↑ + msg.share = 用户邮箱 ``` ## 项目结构 @@ -17,24 +19,22 @@ selfsync/ │ │ └── src/ │ │ ├── main.rs # axum 服务入口 │ │ ├── proto.rs # 生成的 protobuf 类型 -│ │ ├── auth.rs # X-Sync-User-Email 中间件 +│ │ ├── auth.rs # 默认邮箱常量 │ │ ├── progress.rs # 进度 token 编解码 +│ │ ├── util.rs # 共享工具 (gen_id, now_millis 等) │ │ ├── db/ # 数据库层 (sea-orm + SQLite) -│ │ └── handler/ # 请求处理 (commit, get_updates) -│ ├── nigori/ # Nigori 加密库 -│ │ └── src/ -│ │ ├── lib.rs # Nigori: encrypt/decrypt/get_key_name -│ │ ├── keys.rs # PBKDF2 / Scrypt 密钥派生 -│ │ ├── stream.rs # NigoriStream 二进制序列化 -│ │ └── error.rs # 错误类型 -│ └── payload/ # LD_PRELOAD 注入器 +│ │ └── handler/ # 请求处理 +│ │ ├── sync.rs # POST /command/ 分发 +│ │ ├── commit.rs # COMMIT: 创建/更新实体 +│ │ ├── get_updates.rs # GET_UPDATES: 按版本查询 +│ │ ├── init.rs # 用户初始化 (Nigori + 书签) +│ │ └── users.rs # GET / 用户列表页 +│ └── nigori/ # Nigori 加密库 │ └── src/ -│ ├── lib.rs # __libc_start_main hook, argv 注入 -│ ├── mapping.rs # cache_guid → email 映射 -│ └── proxy.rs # HTTP 代理, 添加用户 header -└── docs/ - ├── architecture.md # 本文件 - └── account-mapping.md # 账号映射算法 +│ ├── lib.rs # Nigori: encrypt/decrypt/get_key_name +│ ├── keys.rs # PBKDF2 / Scrypt 密钥派生 +│ ├── stream.rs # NigoriStream 二进制序列化 +│ └── error.rs # 错误类型 ``` ## 同步服务器 @@ -57,21 +57,11 @@ selfsync/ 首次同步时自动创建: - Nigori 加密节点(keystore passphrase) -- 书签根文件夹(书签栏、其他书签、移动书签、阅读清单) +- 书签根文件夹(书签栏、其他书签、移动书签) ### 认证 -- 读取 `X-Sync-User-Email` 请求头识别用户 -- 无 payload 时,默认用户为 `anonymous@localhost`(单用户够用) -- 有 payload 时,代理自动注入邮箱 header,支持多用户 - -## LD_PRELOAD Payload - -1. Hook `__libc_start_main`,检测 Chrome 主进程(跳过子进程和非 Chrome 二进制) -2. 读取 `--user-data-dir`,扫描所有 Profile 的 Preferences 文件 -3. 构建 `cache_guid → email` 映射表(算法见 [account-mapping.md](account-mapping.md)) -4. 启动本地 HTTP 代理(动态端口),注入 `--sync-url` 指向代理 -5. 代理从 URL 的 `client_id` 参数查找邮箱,添加 `X-Sync-User-Email` header 后转发 +Chrome 在每个同步请求的 `ClientToServerMessage.share` 字段中发送登录账号的邮箱。服务器直接读取该字段识别用户,无需额外认证机制。`share` 为空时默认为 `anonymous@localhost`。 ## Nigori 加密库 @@ -79,15 +69,15 @@ Rust 实现的 [Nigori 协议](https://www.cl.cam.ac.uk/~drt24/nigori/nigori-ove - AES-128-CBC 加密 + HMAC-SHA256 认证 - 支持 PBKDF2 和 Scrypt 密钥派生 -- 已通过 Chromium 和 [go-nigori](https://github.com/nicktcortes/nicktcortes) 测试向量验证 +- 已通过 Chromium 测试向量验证 ## Chrome Sync 协议注意事项 - `--sync-url=http://host:port` — Chrome 自动追加 `/command/`,URL 里不要带 -- `ClientToServerResponse.error_code` 必须显式设为 `SUCCESS (0)`,proto 默认值是 `UNKNOWN`,Chrome 会当作错误 -- `NigoriSpecifics.passphrase_type`:`KEYSTORE_PASSPHRASE = 2`、`CUSTOM_PASSPHRASE = 4`,值错了会报 "Needs passphrase" +- `ClientToServerMessage.share` 包含用户邮箱——无需外部认证 +- `ClientToServerResponse.error_code` 必须显式设为 `SUCCESS (0)`,proto 默认值是 `UNKNOWN` +- `NigoriSpecifics.passphrase_type`:`KEYSTORE_PASSPHRASE = 2`、`CUSTOM_PASSPHRASE = 4` - Chrome 会在本地缓存 Nigori 状态,服务端数据库重置后需要用全新 Profile 测试 -- 重置数据库后必须用 `--user-data-dir=/tmp/test` 启动新 Profile ## Chromium 源码参考 @@ -95,6 +85,6 @@ Rust 实现的 [Nigori 协议](https://www.cl.cam.ac.uk/~drt24/nigori/nigori-ove - `components/sync/base/sync_util.cc` — `GetSyncServiceURL()`,读取 `--sync-url` - `components/sync/engine/sync_manager_impl.cc` — `MakeConnectionURL()`,追加 `/command/` -- `components/sync/engine/net/url_translator.cc` — `AppendSyncQueryString()`,追加 `client` 和 `client_id` 参数 +- `components/sync/engine/net/url_translator.cc` — `AppendSyncQueryString()`,追加参数 - `components/sync/protocol/sync.proto` — `ClientToServerMessage`、`ClientToServerResponse` - `components/sync/engine/loopback_server/loopback_server.cc` — 参考同步服务器实现