From df769d20f57f6642f868c5f0913ad0bcedc8f5d0 Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 15 Oct 2026 20:39:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=96=B0=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E4=B8=8E=E7=8B=AC=E7=AB=8B=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 URL 的 client_id 识别设备,审核开关打开后未批准设备收到 TRANSIENT_ERROR - 判断放在建用户之前;开关关闭时记录设备并自动批准 - 管理页挪到 SELFSYNC_ADMIN_ADDR(默认 127.0.0.1:8081),askama 渲染,中英文 - 管理页拒绝跨站 POST,回环监听时拒绝非本机 Host;同步端口去掉 GET / - 懒猫:打开应用进管理页,仅同步路径免登录;加 dev 构建配置 - 设计见 docs/device-approval.md --- CLAUDE.md | 10 +- Cargo.lock | 97 +++- Dockerfile | 3 +- README.md | 13 +- README.zh-CN.md | 13 +- crates/sync-server/Cargo.toml | 3 +- crates/sync-server/src/admin/i18n.rs | 292 ++++++++++++ crates/sync-server/src/admin/mod.rs | 444 ++++++++++++++++++ crates/sync-server/src/admin/ua.rs | 82 ++++ crates/sync-server/src/db/entity/device.rs | 76 +++ crates/sync-server/src/db/entity/mod.rs | 2 + crates/sync-server/src/db/entity/setting.rs | 15 + .../db/migrator/m20261015_000001_devices.rs | 113 +++++ crates/sync-server/src/db/migrator/mod.rs | 2 + crates/sync-server/src/device.rs | 362 ++++++++++++++ crates/sync-server/src/handler/mod.rs | 2 - crates/sync-server/src/handler/sync.rs | 56 ++- crates/sync-server/src/handler/users.rs | 38 -- crates/sync-server/src/main.rs | 41 +- crates/sync-server/templates/admin.html | 206 ++++++++ docker-compose.yml | 2 + docs/architecture.md | 3 +- docs/device-approval.md | 238 ++++++++++ docs/faq.md | 8 + lazycat/lzc-build.dev.yml | 9 + lazycat/lzc-manifest.yml | 20 +- 26 files changed, 2065 insertions(+), 85 deletions(-) create mode 100644 crates/sync-server/src/admin/i18n.rs create mode 100644 crates/sync-server/src/admin/mod.rs create mode 100644 crates/sync-server/src/admin/ua.rs create mode 100644 crates/sync-server/src/db/entity/device.rs create mode 100644 crates/sync-server/src/db/entity/setting.rs create mode 100644 crates/sync-server/src/db/migrator/m20261015_000001_devices.rs create mode 100644 crates/sync-server/src/device.rs delete mode 100644 crates/sync-server/src/handler/users.rs create mode 100644 crates/sync-server/templates/admin.html create mode 100644 docs/device-approval.md create mode 100644 lazycat/lzc-build.dev.yml diff --git a/CLAUDE.md b/CLAUDE.md index 588a868..adb022c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,8 @@ selfsync/ │ ├── main.rs # axum server entry point + routes + CLI │ ├── proto.rs # Generated protobuf types │ ├── auth.rs # BrowserKind + ClientIdentity::from_request +│ ├── device.rs # Per-device admission (client_id query param, approval switch) +│ ├── admin/ # Admin page on SELFSYNC_ADMIN_ADDR (askama, zh/en, sync UA labels) │ ├── progress.rs # Progress token encoding/decoding │ ├── util.rs # Shared utilities (gen_id, now_millis, BASE64) │ ├── db/ @@ -48,8 +50,7 @@ selfsync/ │ ├── sync.rs # POST /command dispatch │ ├── commit.rs # COMMIT: create/update entities │ ├── get_updates.rs # GET_UPDATES: fetch by version -│ ├── init.rs # User initialization (Nigori + bookmarks per browser) -│ └── users.rs # GET / user list page +│ └── init.rs # User initialization (Nigori + bookmarks per browser) ``` ## Sync Server @@ -58,13 +59,14 @@ selfsync/ - **Endpoints** (trailing slashes are stripped by `NormalizePathLayer`, so browser-appended `/command/` matches `/command`): - `POST /command`, `POST /chrome-sync/command` — Chromium-family entry points - `POST /v1/feeds/me/syncEntities/command` — Edge entry point (Edge sets `--sync-url=".../v1/feeds/me/syncEntities"`; engine appends `/command/`) - - `GET /` — HTML user dashboard - `GET /healthz` — liveness check +- **Admin page** (separate listener `SELFSYNC_ADMIN_ADDR`, default `127.0.0.1:8081`): `GET /`, `POST /settings/device-approval`, `POST /devices/{id}/approve|reject`. Template in `templates/admin.html`; copy in `admin/i18n.rs` (ZH/EN consts of one struct). Refuses cross-site POSTs; on a loopback bind also refuses non-loopback `Host` (DNS rebinding). +- **Device approval**: `device::admit` runs before `find_or_create_user`. Device id = `client_id` query param (cache GUID). Refused devices get `TRANSIENT_ERROR` so they resume after approval. See `docs/device-approval.md`. - **Auth**: `auth::ClientIdentity::from_request` parses `(email, browser)` per request. Email from protobuf `share` (Chromium puts the signed-in account email; Edge puts a base64 cache_guid → fallback to `anonymous@localhost`). Browser from `X-AFS-ClientInfo: app=Microsoft Edge` (Edge sets this; vanilla Chromium omits it). - **Storage**: SQLite (WAL mode), single `sync_entities` table. Per-user monotonic counter `users.next_version`. - **Progress tokens**: `v1,{data_type_id},{version}` base64-encoded. - **Schema migrations**: `sea-orm-migration` runs at startup (`Migrator::up`). Append new files in `db/migrator/`, never edit released ones. SQLite has no `ALTER TABLE DROP CONSTRAINT` so removing column-level UNIQUE requires a table rebuild via raw SQL. -- **Config env vars**: `SELFSYNC_DB` (default `selfsync.db`), `SELFSYNC_ADDR` (default `127.0.0.1:8080`). +- **Config env vars**: `SELFSYNC_DB` (default `selfsync.db`), `SELFSYNC_ADDR` (default `127.0.0.1:8080`), `SELFSYNC_ADMIN_ADDR` (default `127.0.0.1:8081`). - **User init** (per-browser branching in `handler/init.rs`): - Chromium → KEYSTORE_PASSPHRASE Nigori (server-built, PBKDF2-derived) + 4 bookmark permanent folders - Edge → empty IMPLICIT_PASSPHRASE Nigori (triggers client-side init via MSA keys) + 5 folders (extra: `workspace_bookmarks`) diff --git a/Cargo.lock b/Cargo.lock index 00b85a6..73ee4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,59 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.9", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -252,6 +305,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bigdecimal" version = "0.4.10" @@ -1000,15 +1062,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "html-escape" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] - [[package]] name = "http" version = "1.4.0" @@ -2086,6 +2139,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2370,12 +2429,12 @@ name = "selfsync-server" version = "0.1.0" dependencies = [ "anyhow", + "askama", "async-trait", "axum", "base64", "clap", "heck 0.5.0", - "html-escape", "prost", "prost-build", "prost-types", @@ -2383,6 +2442,7 @@ dependencies = [ "sea-orm", "sea-orm-migration", "selfsync-nigori", + "serde", "tokio", "tower", "tower-http", @@ -2848,6 +2908,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3218,12 +3289,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Dockerfile b/Dockerfile index 358a388..01eac38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ COPY --from=builder /src/target/release/selfsync-server /usr/local/bin/ ENV SELFSYNC_DB=/data/selfsync.db ENV SELFSYNC_ADDR=0.0.0.0:8080 +ENV SELFSYNC_ADMIN_ADDR=0.0.0.0:8081 VOLUME /data -EXPOSE 8080 +EXPOSE 8080 8081 ENTRYPOINT ["selfsync-server"] diff --git a/README.md b/README.md index 1dcea3e..c0d2b19 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,22 @@ Replace `` with your selfsync host (LAN IP, NAS hostname, or Tailscale nam | Variable | Default | Description | |----------|---------|-------------| | `SELFSYNC_ADDR` | `127.0.0.1:8080` | TCP address to bind | +| `SELFSYNC_ADMIN_ADDR` | `127.0.0.1:8081` | TCP address of the admin page | | `SELFSYNC_DB` | `selfsync.db` | SQLite database path | | `RUST_LOG` | `selfsync_server=info,http=info` | Log filter (tracing-subscriber syntax) | -In the Docker image the defaults are overridden to `0.0.0.0:8080` and `/data/selfsync.db`. +In the Docker image the defaults are overridden to `0.0.0.0:8080`, `0.0.0.0:8081` and `/data/selfsync.db`. + +## Admin page and device approval + +The admin page runs on its own port, `SELFSYNC_ADMIN_ADDR` (default `127.0.0.1:8081`, this machine only). It lists accounts and devices, in English or Chinese. + +Turn on **Require approval for new devices** and any browser you haven't approved is refused until you click **Approve**; it then resumes on its next retry, no restart needed. Devices already in use when you turn it on keep working. See [docs/device-approval.md](docs/device-approval.md). + +The sync port has no authentication, so never expose the admin port the same way. To reach it remotely, put it behind a reverse proxy that authenticates you. + +> Upgrading from 0.1.x: `GET /` on the sync port is gone; the page moved to the admin port. ## For developers diff --git a/README.zh-CN.md b/README.zh-CN.md index c1518af..1671388 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,11 +86,22 @@ adb shell am force-stop com.android.chrome | 变量 | 默认值 | 说明 | |------|--------|------| | `SELFSYNC_ADDR` | `127.0.0.1:8080` | 监听地址 | +| `SELFSYNC_ADMIN_ADDR` | `127.0.0.1:8081` | 管理页监听地址 | | `SELFSYNC_DB` | `selfsync.db` | SQLite 数据库路径 | | `RUST_LOG` | `selfsync_server=info,http=info` | 日志过滤(tracing-subscriber 语法) | -Docker 镜像里默认值被覆盖为 `0.0.0.0:8080` 和 `/data/selfsync.db`。 +Docker 镜像里默认值被覆盖为 `0.0.0.0:8080`、`0.0.0.0:8081` 和 `/data/selfsync.db`。 + +## 管理页与设备审核 + +管理页在单独的端口上,`SELFSYNC_ADMIN_ADDR`(默认 `127.0.0.1:8081`,只有本机能访问),列出账号和设备,支持中文和英文。 + +打开「新设备需要审核」后,没批准过的浏览器同步会被拒绝,直到你点「批准」;批准后浏览器下次重试就恢复,不用重启。打开开关之前已经在用的设备不受影响。设计见 [docs/device-approval.md](docs/device-approval.md)。 + +同步端口没有任何认证,管理端口千万别照着同步端口那样开放出去。要远程访问,请放在带认证的反向代理后面。 + +> 从 0.1.x 升级:同步端口上的 `GET /` 去掉了,页面挪到了管理端口。 ## 开发 diff --git a/crates/sync-server/Cargo.toml b/crates/sync-server/Cargo.toml index 77a646f..55ee566 100644 --- a/crates/sync-server/Cargo.toml +++ b/crates/sync-server/Cargo.toml @@ -5,10 +5,10 @@ edition = "2024" [dependencies] anyhow = "1" +askama = "0.16" axum = "0.8" base64 = "0.22" clap = { version = "4", features = ["derive", "env"] } -html-escape = "0.2" prost = "0.13" rand = "0.9" sea-orm = { version = "1", features = [ @@ -22,6 +22,7 @@ sea-orm-migration = { version = "1", default-features = false, features = [ ] } async-trait = "0.1" selfsync-nigori = { path = "../nigori" } +serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } tower = "0.5" tower-http = { version = "0.6", features = [ diff --git a/crates/sync-server/src/admin/i18n.rs b/crates/sync-server/src/admin/i18n.rs new file mode 100644 index 0000000..1bad2dc --- /dev/null +++ b/crates/sync-server/src/admin/i18n.rs @@ -0,0 +1,292 @@ +//! Admin page copy in Chinese and English. +//! +//! Both languages are `const` instances of one struct, so a string missing +//! from either fails to compile instead of leaking a key onto the page. + +use axum::http::HeaderMap; + +pub const LANG_COOKIE: &str = "selfsync_lang"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lang { + Zh, + En, +} + +impl Lang { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "zh" | "zh-cn" => Some(Lang::Zh), + "en" => Some(Lang::En), + _ => None, + } + } + + pub const fn code(self) -> &'static str { + match self { + Lang::Zh => "zh", + Lang::En => "en", + } + } + + pub const fn texts(self) -> &'static Texts { + match self { + Lang::Zh => &ZH, + Lang::En => &EN, + } + } + + /// Pick the page language: explicit `?lang=`, then the cookie, then + /// `Accept-Language`, then English. + pub fn negotiate(query: Option<&str>, headers: &HeaderMap) -> Self { + if let Some(lang) = query.and_then(Lang::parse) { + return lang; + } + if let Some(lang) = cookie(headers, LANG_COOKIE).and_then(Lang::parse) { + return lang; + } + let accept = headers + .get("accept-language") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + // Honor the first listed language only; good enough for two choices. + let first = accept.split(',').next().unwrap_or("").trim(); + if first.to_ascii_lowercase().starts_with("zh") { + Lang::Zh + } else { + Lang::En + } + } +} + +fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get_all("cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find(|(k, _)| *k == name) + .map(|(_, v)| v) +} + +pub struct Texts { + pub html_lang: &'static str, + pub page_title: &'static str, + pub admin: &'static str, + pub admin_port: &'static str, + pub local_only: &'static str, + pub exposed: &'static str, + pub language: &'static str, + + pub approval_title: &'static str, + pub approval_desc: &'static str, + pub status_on: &'static str, + pub status_off: &'static str, + + pub pending_title: &'static str, + pub pending_hint: &'static str, + pub off_note: &'static str, + pub no_pending: &'static str, + pub ip: &'static str, + pub first_seen: &'static str, + pub last_attempt: &'static str, + pub device_id: &'static str, + + pub approved_title: &'static str, + pub rejected_title: &'static str, + pub rejected_hint: &'static str, + pub no_approved: &'static str, + pub col_device: &'static str, + pub col_account: &'static str, + pub col_last_sync: &'static str, + pub col_actions: &'static str, + pub name_after_sync: &'static str, + + pub approve: &'static str, + pub reject: &'static str, + pub revoke: &'static str, + pub re_approve: &'static str, + + pub users_title: &'static str, + pub no_users: &'static str, + pub col_browser: &'static str, + pub col_approved_devices: &'static str, + pub col_data_version: &'static str, + + pub footer: &'static str, + + pub done_approved: &'static str, + pub done_rejected: &'static str, + pub done_on: &'static str, + pub done_off: &'static str, +} + +impl Texts { + /// Relative time for "last seen" columns. `then` and `now` are Unix + /// milliseconds. + pub fn ago(&self, now: i64, then: i64) -> String { + let secs = (now - then).max(0) / 1000; + let (n, zh, en) = match secs { + 0..60 => return self.just_now().to_string(), + 60..3600 => (secs / 60, "分钟前", "min ago"), + 3600..86400 => (secs / 3600, "小时前", "h ago"), + _ => (secs / 86400, "天前", "days ago"), + }; + match self.html_lang { + "zh-CN" => format!("{n} {zh}"), + _ if n == 1 && en == "days ago" => "1 day ago".to_string(), + _ => format!("{n} {en}"), + } + } + + fn just_now(&self) -> &'static str { + if self.html_lang == "zh-CN" { + "刚刚" + } else { + "just now" + } + } +} + +pub const ZH: Texts = Texts { + html_lang: "zh-CN", + page_title: "selfsync 管理", + admin: "管理", + admin_port: "管理端口", + local_only: "仅本机可访问", + exposed: "已对外监听,请在反向代理上加认证", + language: "界面语言", + + approval_title: "新设备需要审核", + approval_desc: "打开后,没批准过的浏览器无法同步,会出现在下方「待审核」里,等你批准。关闭时,新设备连上来会自动批准。", + status_on: "已开启 · 新设备批准后才能同步", + status_off: "已关闭 · 新设备自动批准", + + pending_title: "待审核", + pending_hint: "批准后,浏览器下次重试时自动恢复同步,一般几分钟内,不用重启浏览器。", + off_note: "审核已关闭:下面这些设备下次连接时会被自动批准。", + no_pending: "没有待审核的设备。", + ip: "来源 IP", + first_seen: "首次出现", + last_attempt: "最近一次尝试", + device_id: "设备 ID", + + approved_title: "已批准设备", + rejected_title: "已拒绝", + rejected_hint: "这些设备会一直被拒绝,开关关闭也不会放行。", + no_approved: "还没有已批准的设备。", + col_device: "设备", + col_account: "账号", + col_last_sync: "最近同步", + col_actions: "操作", + name_after_sync: "设备名在首次同步后显示", + + approve: "批准", + reject: "拒绝", + revoke: "撤销", + re_approve: "改为批准", + + users_title: "账号", + no_users: "还没有账号。", + col_browser: "浏览器", + col_approved_devices: "已批准设备", + col_data_version: "数据版本", + + footer: "设备 ID 由浏览器自己生成。在浏览器里退出账号、关掉再打开同步、或换一个浏览器配置文件后,设备 ID 会变,同一台电脑会以新设备的身份重新出现在「待审核」里。", + + done_approved: "已批准", + done_rejected: "已拒绝", + done_on: "审核已打开,新设备需要批准", + done_off: "审核已关闭,新设备会自动批准", +}; + +pub const EN: Texts = Texts { + html_lang: "en", + page_title: "selfsync admin", + admin: "Admin", + admin_port: "Admin port", + local_only: "local access only", + exposed: "listening publicly; put authentication in front of it", + language: "Language", + + approval_title: "Require approval for new devices", + approval_desc: "When on, browsers you haven't approved can't sync. They show up under Pending below until you approve them. When off, new devices are approved automatically.", + status_on: "On · new devices sync only after approval", + status_off: "Off · new devices are approved automatically", + + pending_title: "Pending", + pending_hint: "Once approved, the browser resumes on its next retry, usually within a few minutes. No restart needed.", + off_note: "Approval is off: these devices will be approved the next time they connect.", + no_pending: "No devices waiting for approval.", + ip: "IP", + first_seen: "First seen", + last_attempt: "Last attempt", + device_id: "Device ID", + + approved_title: "Approved devices", + rejected_title: "Rejected", + rejected_hint: "These devices stay blocked, even when approval is turned off.", + no_approved: "No approved devices yet.", + col_device: "Device", + col_account: "Account", + col_last_sync: "Last sync", + col_actions: "Actions", + name_after_sync: "Device name appears after its first sync", + + approve: "Approve", + reject: "Reject", + revoke: "Revoke", + re_approve: "Approve", + + users_title: "Accounts", + no_users: "No accounts yet.", + col_browser: "Browser", + col_approved_devices: "Approved devices", + col_data_version: "Data version", + + footer: "Device IDs are generated by the browser. Signing out, turning sync off and on, or switching browser profiles gives the same computer a new ID, and it shows up under Pending again.", + + done_approved: "Approved", + done_rejected: "Rejected", + done_on: "Approval is on; new devices need approval", + done_off: "Approval is off; new devices are approved automatically", +}; + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.append(*k, v.parse().unwrap()); + } + h + } + + #[test] + fn query_beats_cookie_beats_accept_language() { + let h = headers(&[ + ("cookie", "a=1; selfsync_lang=en"), + ("accept-language", "zh-CN,zh;q=0.9"), + ]); + assert_eq!(Lang::negotiate(Some("zh"), &h), Lang::Zh); + assert_eq!(Lang::negotiate(None, &h), Lang::En); + let h = headers(&[("accept-language", "zh-TW,en;q=0.8")]); + assert_eq!(Lang::negotiate(None, &h), Lang::Zh); + assert_eq!(Lang::negotiate(Some("fr"), &HeaderMap::new()), Lang::En); + } + + #[test] + fn ago_formats_per_language() { + let now = 10 * 86_400_000; + assert_eq!(ZH.ago(now, now - 5_000), "刚刚"); + assert_eq!(EN.ago(now, now - 5_000), "just now"); + assert_eq!(ZH.ago(now, now - 120_000), "2 分钟前"); + assert_eq!(EN.ago(now, now - 7_200_000), "2 h ago"); + assert_eq!(EN.ago(now, now - 86_400_000), "1 day ago"); + assert_eq!(EN.ago(now, now - 3 * 86_400_000), "3 days ago"); + assert_eq!(ZH.ago(now, now + 5_000), "刚刚"); + } +} diff --git a/crates/sync-server/src/admin/mod.rs b/crates/sync-server/src/admin/mod.rs new file mode 100644 index 0000000..056957a --- /dev/null +++ b/crates/sync-server/src/admin/mod.rs @@ -0,0 +1,444 @@ +//! Admin page, served on its own listener (`SELFSYNC_ADMIN_ADDR`). +//! +//! It lives apart from the sync port because the sync protocol carries no +//! credentials: anything that can reach `/command` could otherwise reach the +//! approve buttons too. Server-rendered HTML with plain form POSTs; no +//! JavaScript. + +mod i18n; +mod ua; + +use std::collections::HashMap; + +use askama::Template; +use axum::{ + Extension, Form, Router, + extract::{Path, Query, Request, rejection::QueryRejection}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, + middleware::{self, Next}, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use prost::Message; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder}; +use serde::Deserialize; + +use crate::db::entity::{device, sync_entity, user}; +use crate::device::{approval_required, set_approval_required, set_status}; +use crate::proto::sync_pb; +use crate::util::now_millis; +use i18n::{LANG_COOKIE, Lang, Texts}; + +/// `EntitySpecifics.device_info` field number, used as the data type id. +const DATA_TYPE_DEVICE_INFO: i32 = 154522; + +/// How the admin listener is exposed; drives the header note and the +/// Host check. +#[derive(Clone, Debug)] +pub struct AdminConfig { + pub addr: String, + /// Bound to a loopback address, so only this machine can reach it. + pub loopback: bool, +} + +pub fn router(db: DatabaseConnection, config: AdminConfig) -> Router { + Router::new() + .route("/", get(index)) + .route("/settings/device-approval", post(toggle_approval)) + .route("/devices/{id}/approve", post(approve)) + .route("/devices/{id}/reject", post(reject)) + .layer(middleware::from_fn(check_request)) + .layer(Extension(db)) + .layer(Extension(config)) +} + +/// Refuse requests a browser could be tricked into sending. +/// +/// - Loopback listener: the Host must be a loopback name, which stops DNS +/// rebinding from turning a hostile page into a same-origin client. +/// - POST: must not come from another site (cross-site form submission). +async fn check_request( + Extension(config): Extension, + req: Request, + next: Next, +) -> Response { + let headers = req.headers(); + if config.loopback && !host_is_loopback(headers) { + return (StatusCode::FORBIDDEN, "admin page: unexpected Host header").into_response(); + } + if req.method() == Method::POST && !same_origin(headers) { + return ( + StatusCode::FORBIDDEN, + "admin page: cross-site request refused", + ) + .into_response(); + } + next.run(req).await +} + +fn host_is_loopback(headers: &HeaderMap) -> bool { + let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else { + return false; + }; + let name = if let Some(rest) = host.strip_prefix('[') { + // [::1]:8081 + rest.split(']').next().unwrap_or("") + } else { + host.rsplit_once(':').map_or(host, |(name, _)| name) + }; + matches!(name, "localhost" | "127.0.0.1" | "::1") +} + +/// `Sec-Fetch-Site` when the browser sends it; otherwise compare `Origin` +/// with `Host`. Neither header (curl, scripts) is allowed. +fn same_origin(headers: &HeaderMap) -> bool { + let get = |name| { + headers + .get(name) + .and_then(|v: &HeaderValue| v.to_str().ok()) + }; + if let Some(site) = get("sec-fetch-site") { + return matches!(site, "same-origin" | "none"); + } + match (get("origin"), get("host")) { + (None, _) => true, + (Some(origin), Some(host)) => origin.split_once("://").map(|(_, rest)| rest) == Some(host), + (Some(_), None) => false, + } +} + +#[derive(Deserialize, Default)] +struct IndexParams { + lang: Option, + done: Option, + device: Option, +} + +struct DeviceRow { + id: i32, + title: String, + sub: String, + email: String, + ip: String, + first_seen: String, + last_seen: String, + guid: String, +} + +struct UserRow { + email: String, + browser: String, + devices: usize, + version: i64, +} + +#[derive(Template)] +#[template(path = "admin.html")] +struct AdminPage { + t: &'static Texts, + lang: &'static str, + approval_on: bool, + admin_addr: String, + loopback: bool, + flash: Option, + pending: Vec, + approved: Vec, + rejected: Vec, + users: Vec, +} + +async fn index( + Extension(db): Extension, + Extension(config): Extension, + params: Result, QueryRejection>, + headers: HeaderMap, +) -> Response { + let Query(params) = params.unwrap_or_default(); + let lang = Lang::negotiate(params.lang.as_deref(), &headers); + match render_index(&db, &config, lang, ¶ms).await { + Ok(html) => { + let mut resp = Html(html).into_response(); + // Remember an explicit choice from the language switch. + if params.lang.as_deref().and_then(Lang::parse).is_some() { + let cookie = format!( + "{LANG_COOKIE}={}; Path=/; Max-Age=31536000; SameSite=Lax", + lang.code() + ); + if let Ok(v) = HeaderValue::from_str(&cookie) { + resp.headers_mut().insert(header::SET_COOKIE, v); + } + } + resp + } + Err(e) => { + tracing::error!("admin page failed: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +async fn render_index( + db: &DatabaseConnection, + config: &AdminConfig, + lang: Lang, + params: &IndexParams, +) -> anyhow::Result { + let t = lang.texts(); + let now = now_millis(); + let approval_on = approval_required(db).await?; + let devices = device::Entity::find() + .order_by(device::Column::LastSeen, Order::Desc) + .all(db) + .await?; + let names = device_names(db).await?; + + let row = |d: &device::Model| { + let ua_label = ua::label(&d.user_agent); + let (title, sub) = match names.get(&d.cache_guid) { + Some(name) => (name.clone(), ua_label), + None => (ua_label, t.name_after_sync.to_string()), + }; + DeviceRow { + id: d.id, + title, + sub, + email: d.email.clone(), + ip: d.remote_addr.clone(), + first_seen: utc_date(d.first_seen), + last_seen: t.ago(now, d.last_seen), + guid: short_guid(&d.cache_guid), + } + }; + let with_status = |s: device::Status| -> Vec { + devices + .iter() + .filter(|d| d.status() == s) + .map(row) + .collect() + }; + let pending = with_status(device::Status::Pending); + let approved = with_status(device::Status::Approved); + let rejected = with_status(device::Status::Rejected); + + let users = user::Entity::find() + .order_by(user::Column::Id, Order::Asc) + .all(db) + .await? + .into_iter() + .map(|u| UserRow { + devices: devices + .iter() + .filter(|d| { + d.status() == device::Status::Approved + && d.email == u.email + && d.browser_kind == u.browser_kind + }) + .count(), + email: u.email, + browser: u.browser_kind, + version: u.next_version, + }) + .collect(); + + let flash = flash_message(t, params, &devices, &names); + + let page = AdminPage { + t, + lang: lang.code(), + approval_on, + admin_addr: config.addr.clone(), + loopback: config.loopback, + flash, + pending, + approved, + rejected, + users, + }; + Ok(page.render()?) +} + +/// One line confirming the action that redirected here. +fn flash_message( + t: &Texts, + params: &IndexParams, + devices: &[device::Model], + names: &HashMap, +) -> Option { + let described = |prefix: &str| { + let d = devices.iter().find(|d| Some(d.id) == params.device)?; + let label = names + .get(&d.cache_guid) + .cloned() + .unwrap_or_else(|| ua::label(&d.user_agent)); + Some(format!("{prefix}: {label} ({})", d.email)) + }; + match params.done.as_deref()? { + "approved" => described(t.done_approved), + "rejected" => described(t.done_rejected), + "on" => Some(t.done_on.to_string()), + "off" => Some(t.done_off.to_string()), + _ => None, + } +} + +/// Device names keyed by cache GUID, from the DeviceInfo entities each +/// browser syncs about itself. Only devices that have synced have one. +async fn device_names(db: &DatabaseConnection) -> anyhow::Result> { + use sync_pb::entity_specifics::SpecificsVariant; + + let rows = sync_entity::Entity::find() + .filter(sync_entity::Column::DataTypeId.eq(DATA_TYPE_DEVICE_INFO)) + .filter(sync_entity::Column::Deleted.eq(false)) + .all(db) + .await?; + let mut names = HashMap::new(); + for row in rows { + let Some(bytes) = row.specifics else { continue }; + let Ok(specifics) = sync_pb::EntitySpecifics::decode(bytes.as_slice()) else { + continue; + }; + if let Some(SpecificsVariant::DeviceInfo(info)) = specifics.specifics_variant + && let (Some(guid), Some(name)) = (info.cache_guid, info.client_name) + && !name.is_empty() + { + names.insert(guid, name); + } + } + Ok(names) +} + +#[derive(Deserialize)] +struct ToggleForm { + enabled: String, +} + +async fn toggle_approval( + Extension(db): Extension, + Form(form): Form, +) -> Response { + let on = form.enabled == "1"; + match set_approval_required(&db, on).await { + Ok(()) => { + tracing::info!(on, "device approval switched"); + Redirect::to(if on { "/?done=on" } else { "/?done=off" }).into_response() + } + Err(e) => { + tracing::error!("toggle device approval failed: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +async fn approve(Extension(db): Extension, Path(id): Path) -> Response { + change_status(&db, id, device::Status::Approved, "approved").await +} + +async fn reject(Extension(db): Extension, Path(id): Path) -> Response { + change_status(&db, id, device::Status::Rejected, "rejected").await +} + +async fn change_status( + db: &DatabaseConnection, + id: i32, + status: device::Status, + done: &str, +) -> Response { + match set_status(db, id, status).await { + Ok(Some(d)) => { + tracing::info!( + id, + email = %d.email, + cache_guid = %d.cache_guid, + status = status.as_db_str(), + "device status changed" + ); + Redirect::to(&format!("/?done={done}&device={id}")).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "no such device").into_response(), + Err(e) => { + tracing::error!(id, "device status change failed: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +/// `Qm9r…x7Ta`: enough to tell devices apart without printing the whole +/// GUID, which works like a password for that device. +fn short_guid(guid: &str) -> String { + let chars: Vec = guid.chars().collect(); + if chars.len() <= 10 { + return guid.to_string(); + } + let head: String = chars[..4].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{head}…{tail}") +} + +/// `YYYY-MM-DD` in UTC for Unix milliseconds. +fn utc_date(ms: i64) -> String { + // Howard Hinnant's civil_from_days. + let z = ms.div_euclid(86_400_000) + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.insert(*k, v.parse().unwrap()); + } + h + } + + #[test] + fn utc_date_known_values() { + assert_eq!(utc_date(0), "1970-01-01"); + assert_eq!(utc_date(951_782_400_000), "2000-02-29"); + assert_eq!(utc_date(1_792_065_600_000), "2026-10-15"); + } + + #[test] + fn short_guid_keeps_ends() { + assert_eq!(short_guid("Qm9rAAAAAAAAx7Ta"), "Qm9r…x7Ta"); + assert_eq!(short_guid("short"), "short"); + } + + #[test] + fn loopback_host_names() { + assert!(host_is_loopback(&headers(&[("host", "127.0.0.1:8081")]))); + assert!(host_is_loopback(&headers(&[("host", "localhost:8081")]))); + assert!(host_is_loopback(&headers(&[("host", "[::1]:8081")]))); + assert!(!host_is_loopback(&headers(&[( + "host", + "evil.example:8081" + )]))); + assert!(!host_is_loopback(&HeaderMap::new())); + } + + #[test] + fn cross_site_posts_are_refused() { + assert!(same_origin(&headers(&[("sec-fetch-site", "same-origin")]))); + assert!(!same_origin(&headers(&[("sec-fetch-site", "cross-site")]))); + assert!(!same_origin(&headers(&[("sec-fetch-site", "same-site")]))); + assert!(same_origin(&headers(&[ + ("origin", "http://127.0.0.1:8081"), + ("host", "127.0.0.1:8081"), + ]))); + assert!(!same_origin(&headers(&[ + ("origin", "https://evil.example"), + ("host", "127.0.0.1:8081"), + ]))); + assert!(same_origin(&HeaderMap::new())); + } +} diff --git a/crates/sync-server/src/admin/ua.rs b/crates/sync-server/src/admin/ua.rs new file mode 100644 index 0000000..f7c9911 --- /dev/null +++ b/crates/sync-server/src/admin/ua.rs @@ -0,0 +1,82 @@ +//! Turn the sync engine's User-Agent into a short label. +//! +//! Chromium's sync requests do not send the web User-Agent. They send +//! ` () channel()`, built by +//! `FormatUserAgentForSync()` in `components/sync/base/sync_util.cc`, e.g. +//! `Chrome WIN 141.0.7390.54 (abc-refs/branch-heads/7390@{#1}) channel(stable)`. +//! The system token is a family only — there is no OS version. + +/// Longest label shown for a User-Agent we can't parse. +const MAX_RAW_LEN: usize = 60; + +/// `Chrome 141 · Windows`, or a truncated raw string when the format is not +/// the sync-engine one (other forks, non-browser clients). +pub fn label(user_agent: &str) -> String { + if let Some(parsed) = parse(user_agent) { + return parsed; + } + let raw = user_agent.trim(); + if raw.chars().count() <= MAX_RAW_LEN { + raw.to_string() + } else { + let head: String = raw.chars().take(MAX_RAW_LEN).collect(); + format!("{head}…") + } +} + +fn parse(user_agent: &str) -> Option { + let mut parts = user_agent.split_whitespace(); + let product = parts.next()?; + let system = system_name(parts.next()?)?; + let major = parts.next()?.split('.').next()?; + if major.is_empty() || !major.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(format!("{product} {major} · {system}")) +} + +fn system_name(token: &str) -> Option<&'static str> { + Some(match token { + "WIN" => "Windows", + "MAC" => "macOS", + "LINUX" => "Linux", + "CROS" => "ChromeOS", + "FREEBSD" => "FreeBSD", + "OPENBSD" => "OpenBSD", + "ANDROID" | "ANDROID-PHONE" => "Android", + "ANDROID-TABLET" => "Android tablet", + "ANDROID-DESKTOP" => "Android desktop", + "IOS-PHONE" => "iOS", + "IOS-TABLET" => "iPadOS", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_sync_engine_format() { + assert_eq!( + label("Chrome WIN 141.0.7390.54 (abc-refs/branch-heads/7390@{#1}) channel(stable)"), + "Chrome 141 · Windows" + ); + assert_eq!( + label("Chromium LINUX 147.0.7727.116 (deadbeef)-devel"), + "Chromium 147 · Linux" + ); + assert_eq!( + label("Chrome ANDROID-PHONE 140.0.1.2 (x) channel(stable)"), + "Chrome 140 · Android" + ); + } + + #[test] + fn falls_back_to_truncated_raw() { + assert_eq!(label("curl/8.4.0"), "curl/8.4.0"); + assert_eq!(label(""), ""); + let long = "x".repeat(100); + assert_eq!(label(&long).chars().count(), MAX_RAW_LEN + 1); + } +} diff --git a/crates/sync-server/src/db/entity/device.rs b/crates/sync-server/src/db/entity/device.rs new file mode 100644 index 0000000..306cad3 --- /dev/null +++ b/crates/sync-server/src/db/entity/device.rs @@ -0,0 +1,76 @@ +use sea_orm::entity::prelude::*; + +/// One browser install seen on the sync endpoint. Identified by the cache +/// GUID the browser sends as `?client_id=` plus the user it acted as. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "devices")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub cache_guid: String, + pub email: String, + /// Same values as `users.browser_kind`. + pub browser_kind: String, + /// Stable string from [`Status::as_db_str`]. + pub status: String, + /// Sync-engine User-Agent, e.g. `Chrome WIN 141.0.7390.54 (...)`. + pub user_agent: String, + pub remote_addr: String, + /// Milliseconds since Unix epoch. + pub first_seen: i64, + pub last_seen: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl Model { + pub fn status(&self) -> Status { + Status::from_db(&self.status) + } +} + +/// Approval state of a device. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Approved, + Pending, + Rejected, +} + +impl Status { + /// Stored in `devices.status`. Changing these values is a breaking + /// schema change. + pub const fn as_db_str(self) -> &'static str { + match self { + Status::Approved => "approved", + Status::Pending => "pending", + Status::Rejected => "rejected", + } + } + + /// Unknown values map to `Rejected`: a row we can't interpret must not + /// be allowed to sync. + pub fn from_db(s: &str) -> Self { + match s { + "approved" => Status::Approved, + "pending" => Status::Pending, + _ => Status::Rejected, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_round_trips_through_db_column() { + for s in [Status::Approved, Status::Pending, Status::Rejected] { + assert_eq!(Status::from_db(s.as_db_str()), s); + } + assert_eq!(Status::from_db("garbage"), Status::Rejected); + } +} diff --git a/crates/sync-server/src/db/entity/mod.rs b/crates/sync-server/src/db/entity/mod.rs index 42bbead..e222259 100644 --- a/crates/sync-server/src/db/entity/mod.rs +++ b/crates/sync-server/src/db/entity/mod.rs @@ -1,2 +1,4 @@ +pub mod device; +pub mod setting; pub mod sync_entity; pub mod user; diff --git a/crates/sync-server/src/db/entity/setting.rs b/crates/sync-server/src/db/entity/setting.rs new file mode 100644 index 0000000..a1073f3 --- /dev/null +++ b/crates/sync-server/src/db/entity/setting.rs @@ -0,0 +1,15 @@ +use sea_orm::entity::prelude::*; + +/// Admin-page switches, stored as plain key/value strings. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "settings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub value: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/sync-server/src/db/migrator/m20261015_000001_devices.rs b/crates/sync-server/src/db/migrator/m20261015_000001_devices.rs new file mode 100644 index 0000000..b6012a9 --- /dev/null +++ b/crates/sync-server/src/db/migrator/m20261015_000001_devices.rs @@ -0,0 +1,113 @@ +//! Add `devices` (one row per browser install seen on the sync endpoint) and +//! `settings` (admin-page key/value switches such as `device_approval`). +//! +//! A device is keyed by `(cache_guid, email, browser_kind)`: the cache GUID +//! comes from the `client_id` query parameter Chromium appends to every sync +//! request, and the email/browser pair is the user it was seen acting as. +//! See `docs/device-approval.md`. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Devices::Table) + .if_not_exists() + .col( + ColumnDef::new(Devices::Id) + .integer() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Devices::CacheGuid).string().not_null()) + .col(ColumnDef::new(Devices::Email).string().not_null()) + .col(ColumnDef::new(Devices::BrowserKind).string().not_null()) + .col(ColumnDef::new(Devices::Status).string().not_null()) + .col( + ColumnDef::new(Devices::UserAgent) + .string() + .not_null() + .default(""), + ) + .col( + ColumnDef::new(Devices::RemoteAddr) + .string() + .not_null() + .default(""), + ) + .col(ColumnDef::new(Devices::FirstSeen).big_integer().not_null()) + .col(ColumnDef::new(Devices::LastSeen).big_integer().not_null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_devices_guid_user") + .table(Devices::Table) + .col(Devices::CacheGuid) + .col(Devices::Email) + .col(Devices::BrowserKind) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Settings::Table) + .if_not_exists() + .col( + ColumnDef::new(Settings::Key) + .string() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Settings::Value).string().not_null()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Settings::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Devices::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Devices { + Table, + Id, + CacheGuid, + Email, + BrowserKind, + Status, + UserAgent, + RemoteAddr, + FirstSeen, + LastSeen, +} + +#[derive(DeriveIden)] +enum Settings { + Table, + Key, + Value, +} diff --git a/crates/sync-server/src/db/migrator/mod.rs b/crates/sync-server/src/db/migrator/mod.rs index 48d2d5c..c2b7009 100644 --- a/crates/sync-server/src/db/migrator/mod.rs +++ b/crates/sync-server/src/db/migrator/mod.rs @@ -7,6 +7,7 @@ use sea_orm_migration::prelude::*; mod m20260101_000001_initial; mod m20260425_000001_users_add_browser_kind; +mod m20261015_000001_devices; pub struct Migrator; @@ -16,6 +17,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20260101_000001_initial::Migration), Box::new(m20260425_000001_users_add_browser_kind::Migration), + Box::new(m20261015_000001_devices::Migration), ] } } diff --git a/crates/sync-server/src/device.rs b/crates/sync-server/src/device.rs new file mode 100644 index 0000000..8b5bff7 --- /dev/null +++ b/crates/sync-server/src/device.rs @@ -0,0 +1,362 @@ +//! Per-device admission for sync requests. +//! +//! Chromium appends `?client=...&client_id=` to every sync +//! request URL (`components/sync/engine/net/url_translator.cc`), so each +//! browser install can be told apart before any protobuf work happens. When +//! the admin turns on "require approval for new devices", unknown installs are +//! recorded as pending and refused until approved on the admin page. With the +//! switch off they are recorded and approved automatically, so turning it on +//! later does not lock out devices already in use. +//! +//! See `docs/device-approval.md` for the design. + +use std::net::SocketAddr; + +use axum::http::HeaderMap; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, + sea_query::OnConflict, +}; + +use crate::auth::ClientIdentity; +use crate::db::entity::{device, setting}; +use crate::util::now_millis; + +const SETTING_DEVICE_APPROVAL: &str = "device_approval"; +const MAX_USER_AGENT_LEN: usize = 256; + +/// Device metadata taken from the HTTP layer of a sync request. +#[derive(Debug, Clone, Default)] +pub struct DeviceRequest { + /// Cache GUID from the `client_id` query parameter. + pub cache_guid: Option, + pub user_agent: String, + pub remote_addr: String, +} + +impl DeviceRequest { + pub fn new(client_id: Option<&str>, headers: &HeaderMap, peer: Option) -> Self { + let cache_guid = client_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let user_agent: String = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .chars() + .take(MAX_USER_AGENT_LEN) + .collect(); + Self { + cache_guid, + user_agent, + remote_addr: remote_addr(headers, peer), + } + } +} + +/// Client address for display on the admin page. Behind a reverse proxy the +/// peer is the proxy itself, so prefer the first `X-Forwarded-For` hop. This +/// is informational only — never use it for access decisions, it is +/// client-controlled. +fn remote_addr(headers: &HeaderMap, peer: Option) -> String { + let forwarded = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()); + match (forwarded, peer) { + (Some(ip), _) => ip.to_string(), + (None, Some(addr)) => addr.ip().to_string(), + (None, None) => String::new(), + } +} + +/// Outcome of [`admit`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Admission { + Allow, + Deny, +} + +/// What to do with a request, given the device's stored status (if any) and +/// whether approval is required. `store` is the status to persist; `None` +/// means leave the row alone (or create none). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Decision { + admission: Admission, + store: Option, +} + +fn decide(existing: Option, approval_required: bool) -> Decision { + use device::Status::*; + match (existing, approval_required) { + (Some(Approved), _) => Decision { + admission: Admission::Allow, + store: None, + }, + (Some(Rejected), _) => Decision { + admission: Admission::Deny, + store: None, + }, + // A device left pending when the switch was turned off is let in on + // its next attempt, as the admin page promises. + (Some(Pending) | None, false) => Decision { + admission: Admission::Allow, + store: Some(Approved), + }, + (Some(Pending) | None, true) => Decision { + admission: Admission::Deny, + store: Some(Pending), + }, + } +} + +/// Decide whether a sync request may proceed, recording the device as a +/// side effect. Must run before the user row is looked up or created, so an +/// unapproved device cannot make the server allocate a user. +pub async fn admit( + db: &DatabaseConnection, + identity: &ClientIdentity, + req: &DeviceRequest, +) -> anyhow::Result { + let approval_required = approval_required(db).await?; + + let Some(cache_guid) = req.cache_guid.as_deref() else { + // Old or non-standard client: nothing to key a device row on. + return Ok(if approval_required { + Admission::Deny + } else { + Admission::Allow + }); + }; + + let now = now_millis(); + let existing = find(db, cache_guid, identity).await?; + let decision = decide( + existing.as_ref().map(device::Model::status), + approval_required, + ); + + match existing { + Some(row) => { + let mut active: device::ActiveModel = row.into(); + if let Some(status) = decision.store { + active.status = Set(status.as_db_str().to_string()); + } + active.user_agent = Set(req.user_agent.clone()); + active.remote_addr = Set(req.remote_addr.clone()); + active.last_seen = Set(now); + active.update(db).await?; + } + None => { + let status = decision.store.unwrap_or(device::Status::Pending); + let row = device::ActiveModel { + cache_guid: Set(cache_guid.to_string()), + email: Set(identity.email.clone()), + browser_kind: Set(identity.browser.as_db_str().to_string()), + status: Set(status.as_db_str().to_string()), + user_agent: Set(req.user_agent.clone()), + remote_addr: Set(req.remote_addr.clone()), + first_seen: Set(now), + last_seen: Set(now), + ..Default::default() + }; + // Concurrent first requests from one device race here; the + // unique index keeps a single row and the loser is a no-op. + device::Entity::insert(row) + .on_conflict( + OnConflict::columns([ + device::Column::CacheGuid, + device::Column::Email, + device::Column::BrowserKind, + ]) + .do_nothing() + .to_owned(), + ) + .exec_without_returning(db) + .await?; + if status == device::Status::Pending { + tracing::info!( + email = %identity.email, + browser = %identity.browser, + cache_guid, + "new device waiting for approval" + ); + } + } + } + + Ok(decision.admission) +} + +async fn find( + db: &DatabaseConnection, + cache_guid: &str, + identity: &ClientIdentity, +) -> anyhow::Result> { + Ok(device::Entity::find() + .filter(device::Column::CacheGuid.eq(cache_guid)) + .filter(device::Column::Email.eq(&identity.email)) + .filter(device::Column::BrowserKind.eq(identity.browser.as_db_str())) + .one(db) + .await?) +} + +/// Whether new devices need approval. Defaults to off. +pub async fn approval_required(db: &DatabaseConnection) -> anyhow::Result { + let row = setting::Entity::find_by_id(SETTING_DEVICE_APPROVAL) + .one(db) + .await?; + Ok(row.is_some_and(|r| r.value == "1")) +} + +pub async fn set_approval_required(db: &DatabaseConnection, on: bool) -> anyhow::Result<()> { + let row = setting::ActiveModel { + key: Set(SETTING_DEVICE_APPROVAL.to_string()), + value: Set(if on { "1" } else { "0" }.to_string()), + }; + setting::Entity::insert(row) + .on_conflict( + OnConflict::column(setting::Column::Key) + .update_column(setting::Column::Value) + .to_owned(), + ) + .exec_without_returning(db) + .await?; + Ok(()) +} + +/// Change a device's status from the admin page. Returns the updated row, or +/// `None` when the id does not exist. +pub async fn set_status( + db: &DatabaseConnection, + id: i32, + status: device::Status, +) -> anyhow::Result> { + let Some(row) = device::Entity::find_by_id(id).one(db).await? else { + return Ok(None); + }; + let mut active: device::ActiveModel = row.into(); + active.status = Set(status.as_db_str().to_string()); + Ok(Some(active.update(db).await?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::BrowserKind; + use device::Status::*; + + #[test] + fn decide_matrix() { + let allow = |store| Decision { + admission: Admission::Allow, + store, + }; + let deny = |store| Decision { + admission: Admission::Deny, + store, + }; + assert_eq!(decide(Some(Approved), true), allow(None)); + assert_eq!(decide(Some(Approved), false), allow(None)); + assert_eq!(decide(Some(Rejected), true), deny(None)); + assert_eq!(decide(Some(Rejected), false), deny(None)); + assert_eq!(decide(Some(Pending), true), deny(Some(Pending))); + assert_eq!(decide(Some(Pending), false), allow(Some(Approved))); + assert_eq!(decide(None, true), deny(Some(Pending))); + assert_eq!(decide(None, false), allow(Some(Approved))); + } + + #[test] + fn remote_addr_prefers_first_forwarded_hop() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "203.0.113.5, 10.0.0.1".parse().unwrap()); + let peer: SocketAddr = "127.0.0.1:9999".parse().unwrap(); + assert_eq!(remote_addr(&h, Some(peer)), "203.0.113.5"); + assert_eq!(remote_addr(&HeaderMap::new(), Some(peer)), "127.0.0.1"); + assert_eq!(remote_addr(&HeaderMap::new(), None), ""); + } + + #[test] + fn blank_client_id_is_no_guid() { + let r = DeviceRequest::new(Some(" "), &HeaderMap::new(), None); + assert!(r.cache_guid.is_none()); + } + + /// A fresh on-disk database. `:memory:` would give each pooled + /// connection its own empty database. + async fn test_db() -> DatabaseConnection { + let path = std::env::temp_dir().join(format!("selfsync-test-{}.db", uuid::Uuid::new_v4())); + crate::db::connect(path.to_str().unwrap()).await.unwrap() + } + + fn alice() -> ClientIdentity { + ClientIdentity { + email: "alice@example.com".into(), + browser: BrowserKind::Chromium, + } + } + + fn from(guid: &str) -> DeviceRequest { + DeviceRequest { + cache_guid: Some(guid.into()), + user_agent: "Chrome MAC 141.0.0.0 (x) channel(stable)".into(), + remote_addr: "192.0.2.1".into(), + } + } + + #[tokio::test] + async fn existing_devices_survive_turning_approval_on() { + let db = test_db().await; + let me = alice(); + + // Switch off (default): device is recorded and approved. + assert_eq!( + admit(&db, &me, &from("dev-a")).await.unwrap(), + Admission::Allow + ); + + set_approval_required(&db, true).await.unwrap(); + assert!(approval_required(&db).await.unwrap()); + + // Known device still allowed; a new one is held. + assert_eq!( + admit(&db, &me, &from("dev-a")).await.unwrap(), + Admission::Allow + ); + assert_eq!( + admit(&db, &me, &from("dev-b")).await.unwrap(), + Admission::Deny + ); + + let b = find(&db, "dev-b", &me).await.unwrap().unwrap(); + assert_eq!(b.status(), Pending); + + // Approve from the admin page, next attempt goes through. + set_status(&db, b.id, Approved).await.unwrap(); + assert_eq!( + admit(&db, &me, &from("dev-b")).await.unwrap(), + Admission::Allow + ); + + // Revoke: denied even after the switch is turned off again. + set_status(&db, b.id, Rejected).await.unwrap(); + set_approval_required(&db, false).await.unwrap(); + assert_eq!( + admit(&db, &me, &from("dev-b")).await.unwrap(), + Admission::Deny + ); + } + + #[tokio::test] + async fn missing_client_id_follows_the_switch() { + let db = test_db().await; + let me = alice(); + let anon = DeviceRequest::default(); + assert_eq!(admit(&db, &me, &anon).await.unwrap(), Admission::Allow); + set_approval_required(&db, true).await.unwrap(); + assert_eq!(admit(&db, &me, &anon).await.unwrap(), Admission::Deny); + } +} diff --git a/crates/sync-server/src/handler/mod.rs b/crates/sync-server/src/handler/mod.rs index ab5cd6c..e98e90f 100644 --- a/crates/sync-server/src/handler/mod.rs +++ b/crates/sync-server/src/handler/mod.rs @@ -2,10 +2,8 @@ mod commit; mod get_updates; mod init; pub(crate) mod sync; -mod users; pub use sync::handle_command; -pub use users::list_users; use axum::{extract::Request, middleware::Next, response::Response}; diff --git a/crates/sync-server/src/handler/sync.rs b/crates/sync-server/src/handler/sync.rs index d45476d..d700846 100644 --- a/crates/sync-server/src/handler/sync.rs +++ b/crates/sync-server/src/handler/sync.rs @@ -1,6 +1,10 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + use axum::{ Extension, body::Bytes, + extract::{ConnectInfo, Query, rejection::QueryRejection}, http::{HeaderMap, StatusCode}, response::IntoResponse, }; @@ -9,14 +13,21 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, Qu use crate::auth::ClientIdentity; use crate::db::entity::user; +use crate::device::{self, Admission, DeviceRequest}; use crate::proto::sync_pb; use crate::util::gen_encryption_key; use super::{commit, get_updates}; +/// Store birthday sent to every client. See `find_or_create_user` for why it +/// is a fixed sentinel rather than a per-user value. +const STORE_BIRTHDAY: &str = "ProductionEnvironmentDefinition"; + /// POST /command/ — handles all Chrome sync protocol messages. pub async fn handle_command( Extension(db): Extension, + peer: Option>>, + query: Result>, QueryRejection>, headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { @@ -29,6 +40,14 @@ pub async fn handle_command( }; let identity = ClientIdentity::from_request(&headers, &msg); + // Chromium appends `client_id=` to every request URL; it is + // the only per-device id present on the very first GET_UPDATES. + let client_id = query + .as_ref() + .ok() + .and_then(|Query(q)| q.get("client_id").map(String::as_str)); + let peer = peer.map(|Extension(ConnectInfo(addr))| addr); + let device_req = DeviceRequest::new(client_id, &headers, peer); let msg_type = message_type_name(msg.message_contents); tracing::info!( email = %identity.email, @@ -36,11 +55,35 @@ pub async fn handle_command( msg_type, body_len = body.len(), share = %msg.share, - cache_guid = request_cache_guid(&msg), + cache_guid = device_req.cache_guid.as_deref().unwrap_or(""), client_birthday = msg.store_birthday.as_deref().unwrap_or(""), "sync request" ); + match device::admit(&db, &identity, &device_req).await { + Ok(Admission::Allow) => {} + Ok(Admission::Deny) => { + tracing::info!( + email = %identity.email, + cache_guid = device_req.cache_guid.as_deref().unwrap_or(""), + msg_type, + "device not approved, request refused" + ); + // TRANSIENT_ERROR makes the client back off and retry, so an + // approved device resumes on its own. DISABLED_BY_ADMIN would + // stop sync until the browser restarts. + let resp = error_response( + STORE_BIRTHDAY, + sync_pb::sync_enums::ErrorType::TransientError, + ); + return (StatusCode::OK, resp.encode_to_vec()); + } + Err(e) => { + tracing::error!(email = %identity.email, "device lookup failed: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, Vec::new()); + } + } + let user = match find_or_create_user(&db, &identity).await { Ok(u) => u, Err(e) => { @@ -77,15 +120,6 @@ pub async fn handle_command( (StatusCode::OK, resp_bytes) } -/// Cache GUID from either the COMMIT envelope (preferred) or the deprecated -/// `invalidator_client_id` field. Used as a per-device identifier in logs. -fn request_cache_guid(msg: &sync_pb::ClientToServerMessage) -> &str { - let from_commit = msg.commit.as_ref().and_then(|c| c.cache_guid.as_deref()); - #[allow(deprecated)] - let from_invalidator = msg.invalidator_client_id.as_deref(); - from_commit.or(from_invalidator).unwrap_or("") -} - async fn dispatch( db: &DatabaseConnection, user: &user::Model, @@ -175,7 +209,7 @@ async fn find_or_create_user( // the BookmarkDataTypeProcessor in Edge's MSA-aware fork) may validate // this exact value as part of their initial-merge sanity checks. // Chromium accepts any non-empty birthday so this is safe across both. - let birthday = "ProductionEnvironmentDefinition".to_string(); + let birthday = STORE_BIRTHDAY.to_string(); let enc_key = gen_encryption_key(); let mut next_version = 1i64; diff --git a/crates/sync-server/src/handler/users.rs b/crates/sync-server/src/handler/users.rs deleted file mode 100644 index 62a6880..0000000 --- a/crates/sync-server/src/handler/users.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::{Extension, response::Html}; -use sea_orm::{DatabaseConnection, EntityTrait}; - -use crate::db::entity::user; - -/// GET / — display current user list. -pub async fn list_users(Extension(db): Extension) -> Html { - let users = user::Entity::find().all(&db).await.unwrap_or_default(); - - let mut html = String::from( - "selfsync\ - \ -

selfsync users

", - ); - - if users.is_empty() { - html.push_str("

No users yet.

"); - } else { - html.push_str( - "", - ); - for u in &users { - html.push_str(&format!( - "", - u.id, - html_escape::encode_text(&u.email), - html_escape::encode_text(&u.browser_kind), - html_escape::encode_text(&u.store_birthday), - u.next_version, - )); - } - html.push_str("
IDEmailBrowserStore BirthdayNext Version
{}{}{}{}{}
"); - } - - html.push_str(""); - Html(html) -} diff --git a/crates/sync-server/src/main.rs b/crates/sync-server/src/main.rs index 2a5ac7e..0252125 100644 --- a/crates/sync-server/src/main.rs +++ b/crates/sync-server/src/main.rs @@ -1,10 +1,14 @@ +mod admin; mod auth; mod db; +mod device; mod handler; mod progress; mod proto; mod util; +use std::net::SocketAddr; + use axum::{ Extension, Router, middleware, routing::{get, post}, @@ -24,6 +28,11 @@ struct Cli { #[arg(long, env = "SELFSYNC_ADDR", default_value = "127.0.0.1:8080")] addr: String, + /// TCP address for the admin page. Keep it on loopback unless a + /// reverse proxy with authentication sits in front of it. + #[arg(long, env = "SELFSYNC_ADMIN_ADDR", default_value = "127.0.0.1:8081")] + admin_addr: String, + /// SQLite database path. #[arg(long, env = "SELFSYNC_DB", default_value = "selfsync.db")] db: String, @@ -44,7 +53,6 @@ async fn main() -> anyhow::Result<()> { tracing::info!(db_path = %cli.db, "database connected"); let app = Router::new() - .route("/", get(handler::list_users)) .route("/healthz", get(|| async { "ok" })) .route("/command", post(handler::handle_command)) .route("/chrome-sync/command", post(handler::handle_command)) @@ -56,19 +64,40 @@ async fn main() -> anyhow::Result<()> { ) .layer(middleware::from_fn(handler::log_request)) .layer(RequestDecompressionLayer::new()) - .layer(Extension(db)); + .layer(Extension(db.clone())); // Browsers append a trailing slash (`/command/`); normalize it away so the // route table only declares the slashless form. let app = NormalizePathLayer::trim_trailing_slash().layer(app); + let admin_listener = tokio::net::TcpListener::bind(&cli.admin_addr).await?; + let admin_bound = admin_listener.local_addr()?; + let admin_app = admin::router( + db, + admin::AdminConfig { + addr: admin_bound.to_string(), + loopback: admin_bound.ip().is_loopback(), + }, + ); + if !admin_bound.ip().is_loopback() { + tracing::warn!( + bind_addr = %admin_bound, + "admin page is not on loopback; protect it with an authenticating reverse proxy" + ); + } + tracing::info!(bind_addr = %admin_bound, "admin page listening"); + let listener = tokio::net::TcpListener::bind(&cli.addr).await?; tracing::info!(bind_addr = %cli.addr, "selfsync server listening"); - axum::serve( + // Peer address is shown on the admin page next to each device. + let sync = axum::serve( listener, - axum::ServiceExt::::into_make_service(app), - ) - .await?; + axum::ServiceExt::::into_make_service_with_connect_info::( + app, + ), + ); + let admin = axum::serve(admin_listener, admin_app); + tokio::try_join!(async { sync.await }, async { admin.await },)?; Ok(()) } diff --git a/crates/sync-server/templates/admin.html b/crates/sync-server/templates/admin.html new file mode 100644 index 0000000..176eb19 --- /dev/null +++ b/crates/sync-server/templates/admin.html @@ -0,0 +1,206 @@ + + + + + +{{ t.page_title }} + + + +
+
+
selfsync{{ t.admin }}
+
+{{ t.admin_port }} {{ admin_addr }} · {% if loopback %}{{ t.local_only }}{% else %}{{ t.exposed }}{% endif %} + +
+
+
+ +
+ +
+
+

{{ t.approval_title }}

+

{{ t.approval_desc }}

+

{% if approval_on %}{{ t.status_on }}{% else %}{{ t.status_off }}{% endif %}

+
+
+ + +
+
+ +{% if let Some(msg) = flash %}

{{ msg }}

{% endif %} + +
+
+

{{ t.pending_title }}

{{ pending.len() }}
+{{ t.pending_hint }} +
+{% if !approval_on && !pending.is_empty() %}
{{ t.off_note }}
{% endif %} +{% for d in pending %} +
+
+
{{ d.title }}{{ d.email }}
+
+{{ t.ip }} {{ d.ip }} +{{ t.first_seen }} {{ d.first_seen }} +{{ t.last_attempt }} {{ d.last_seen }} +{{ t.device_id }} {{ d.guid }} +
+
+
+
+
+
+
+{% else %} +
{{ t.no_pending }}
+{% endfor %} +
+ +
+

{{ t.approved_title }}

{{ approved.len() }}
+{% if approved.is_empty() %} +
{{ t.no_approved }}
+{% else %} +
+ + + +{% for d in approved %} + + + + + + + + +{% endfor %} + +
{{ t.col_device }}{{ t.col_account }}{{ t.ip }}{{ t.first_seen }}{{ t.col_last_sync }}{{ t.col_actions }}
{{ d.title }}
{{ d.sub }}
{{ d.email }}{{ d.ip }}{{ d.first_seen }}{{ d.last_seen }}
+
+{% endif %} +
+ +{% if !rejected.is_empty() %} +
+
+

{{ t.rejected_title }}

{{ rejected.len() }}
+{{ t.rejected_hint }} +
+
+ + + +{% for d in rejected %} + + + + + + + + +{% endfor %} + +
{{ t.col_device }}{{ t.col_account }}{{ t.ip }}{{ t.first_seen }}{{ t.last_attempt }}{{ t.col_actions }}
{{ d.title }}
{{ d.sub }}
{{ d.email }}{{ d.ip }}{{ d.first_seen }}{{ d.last_seen }}
+
+
+{% endif %} + +
+

{{ t.users_title }}

+{% if users.is_empty() %} +
{{ t.no_users }}
+{% else %} +
+ + + +{% for u in users %} + +{% endfor %} + +
{{ t.col_account }}{{ t.col_browser }}{{ t.col_approved_devices }}{{ t.col_data_version }}
{{ u.email }}{{ u.browser }}{{ u.devices }}{{ u.version }}
+
+{% endif %} +
+ + +
+ + diff --git a/docker-compose.yml b/docker-compose.yml index fb193ee..dc53ac2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: build: . ports: - "8080:8080" + # Admin page: host-local only. + - "127.0.0.1:8081:8081" volumes: - selfsync-data:/data restart: unless-stopped diff --git a/docs/architecture.md b/docs/architecture.md index 2313f83..588111b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,8 +43,7 @@ selfsync/ │ │ ├── sync.rs # POST /command/ 分发 │ │ ├── commit.rs # COMMIT: 创建/更新实体 │ │ ├── get_updates.rs # GET_UPDATES: 按版本查询 -│ │ ├── init.rs # 用户初始化(Nigori + 永久书签文件夹) -│ │ └── users.rs # GET / 用户列表页 +│ │ └── init.rs # 用户初始化(Nigori + 永久书签文件夹) │ └── nigori/ # Nigori 加密库 │ └── src/ │ ├── lib.rs # Nigori: encrypt/decrypt/get_key_name diff --git a/docs/device-approval.md b/docs/device-approval.md new file mode 100644 index 0000000..76d4cce --- /dev/null +++ b/docs/device-approval.md @@ -0,0 +1,238 @@ +# 设计:新设备审核(Device Approval) + +> 状态:已实现(`device.rs`、`admin/`)。对应 issue [loyalpartner/selfsync#13](https://github.com/loyalpartner/selfsync/issues/13)。 + +## 背景 + +selfsync 没有任何认证:只要能访问到同步端口,并且知道某个账号的邮箱,就能以这个账号的身份拉取和覆盖全部同步数据。 +README 的建议是只在内网 / 私有网络里运行,但 #13 的提出者因为公司和家里之间的网络限制做不到,只能把服务放到公网。 + +Chrome 同步客户端不支持 OIDC、basic auth、mTLS(#13 里都试过,mTLS 会报 `ERR_BAD_SSL_CLIENT_AUTH_CERT`),所以没办法在反向代理层加认证。 + +#13 原本提的是一个只控制"能否新建用户"的开关 `SELFSYNC_ALLOW_NEW_REG`,它有个漏洞:攻击者只要用**已经存在的邮箱**发请求,就会命中已有用户,开关根本拦不住。本设计把粒度从"用户"细化到"设备": + +- 管理界面上有一个审核开关。 +- 开关打开后,没见过的设备一律进入「待审核」,同步请求被拒绝。 +- 管理员在界面上批准之后,这台设备才能正常同步。 + +这样即使对方知道你的邮箱,换一台没批准过的设备也读不到数据。 + +## 设备怎么识别 + +Chromium 发的每一个同步请求,URL 后面都带着设备的 cache_guid: + +``` +POST /command/?client=Google+Chrome&client_id= +``` + +来源(Chromium main 分支): + +- `components/sync/engine/engine_components_factory_impl.cc::BuildConnectionManager()` 把 `cache_guid` 作为 `client_id` 传给 `MakeConnectionURL()` +- `components/sync/engine/net/url_translator.cc::AppendSyncQueryString()` 把它拼成 `client_id` 参数 + +为什么不用 protobuf 里的字段: + +| 字段 | 问题 | +|---|---| +| `ClientToServerMessage.invalidator_client_id` | M117 起废弃,不再填 | +| `CommitMessage.cache_guid` | 只在 COMMIT 里有;新设备的第一个请求是 GET_UPDATES,拿不到 | +| `ClientToServerMessage.share` | Chromium 填的是邮箱;Edge 填的是 cache_guid,但两者语义不一致 | + +所以设备 ID 统一从 URL 的 `client_id` 参数里取。现有的 `log_request` 中间件已经在日志里打出了 `query`,验证时直接看日志就行。 + +**待验证**:Edge 和 Brave 是否同样带 `client_id`。它们都基于 Chromium,大概率一样,但需要实测。 + +### cache_guid 的特性 + +- **会变**:在浏览器里退出账号、关闭再打开同步、或者换一个新的浏览器配置文件,都会生成新的 cache_guid,需要重新审批一次。界面上应该把这一点写清楚,并显示设备的首次出现时间,方便管理员认出"这是我刚重置过的那台"。 +- **是客户端自己报的**:服务器没法验证它的真假。它的安全性来自"不可猜"(随机生成的长字符串),本质上相当于一个口令。如果反向代理把完整 URL 写进了访问日志,日志泄露就等于口令泄露。 + +## 数据模型 + +新增 `devices` 表(新迁移文件,不改已有的迁移): + +| 列 | 类型 | 说明 | +|---|---|---| +| `id` | INTEGER PK | | +| `cache_guid` | TEXT | URL 里的 `client_id` | +| `email` | TEXT | 请求里解析出来的邮箱(Edge 为 `anonymous@localhost`) | +| `browser_kind` | TEXT | `chromium` / `edge` | +| `status` | TEXT | `approved` / `pending` / `rejected` | +| `user_agent` | TEXT | 方便管理员辨认设备 | +| `remote_addr` | TEXT | 同上;在反向代理后面时取 `X-Forwarded-For` || `first_seen` | INTEGER | 毫秒时间戳 | +| `last_seen` | INTEGER | 毫秒时间戳 | + +唯一键:`(cache_guid, email, browser_kind)`。 + +界面上显示的「Chrome 141 · Windows」不单独存,渲染时从 `user_agent` 解析。 + +### 能知道设备的哪些信息 + +同步请求的 User-Agent 不是网页那种,而是 Chromium 专门拼的(`components/sync/base/sync_util.cc::FormatUserAgentForSync()`): + +``` +Chrome WIN 141.0.7390.54 (-refs/branch-heads/) channel(stable) +``` + +- 系统只有大类:`WIN` / `MAC` / `LINUX` / `CROS` / `ANDROID-PHONE` / `ANDROID-TABLET` / `IOS-PHONE` / `IOS-TABLET`。**没有系统版本**,分不出 Windows 10 和 11。 +- 浏览器版本是完整的;渠道(stable / beta / dev)也有。 +- 产品名由编译参数决定,Brave、Vivaldi 这类大概率显示成 Chrome 或 Chromium,**分不出具体是哪个浏览器**(待实测)。Edge 的格式待实测。 + +已批准的设备还能拿到更多:设备同步上来的 `DeviceInfoSpecifics` 里有设备名(`client_name`,例如「Alice 的 MacBook Pro」)、厂商、型号、系统类型。但它要等设备开始正常同步后才会提交,**待审核的设备拿不到**,只能靠 User-Agent 和来源 IP 辨认。 + +开关状态存进新的 `settings` 表(`key TEXT PK, value TEXT`),键名 `device_approval`。不用环境变量,因为要能在界面上随时切换,不用重启。 + +## 请求处理流程 + +在 `handler/sync.rs::handle_command` 里,解析完身份之后、调用 `find_or_create_user` **之前**加一步设备判定: + +``` +解析 ClientIdentity + client_id + │ + ▼ +查 devices 表 + │ + ├─ 已批准 ────────────────► 更新 last_seen,照常处理 + │ + ├─ 已拒绝 ────────────────► 返回拒绝响应 + │ + └─ 没见过 / 待审核 + │ + ├─ 开关关闭 ──────► 记为 approved,照常处理 + └─ 开关打开 ──────► 记为 pending,返回拒绝响应 +``` + +几个关键点: + +1. **判定必须放在 `find_or_create_user` 之前**。否则待审核设备虽然被拒绝了,用户行和初始化数据(Nigori、书签根目录)已经建好了,#13 关心的"陌生人占用服务器资源"问题并没有解决。 +2. **开关关闭时也要记录设备**,并自动标成 approved。这样升级后再打开开关,已经在用的设备不会被误拦。 +3. **请求里没有 `client_id`**(老版本或非标准客户端):开关打开时直接拒绝,开关关闭时照常处理但不记录。 +4. 待审核的设备每次重试都会更新 `last_seen`,界面上能看到它还在尝试连接。 + +## 拒绝时返回什么 + +在 `ClientToServerResponse.error_code` 里返回 `TRANSIENT_ERROR`,HTTP 状态码仍然是 200。 + +各选项在客户端的效果(依据 Chromium `components/sync/engine/syncer_proto_util.cc` 和 `components/sync/service/sync_service_impl.cc`): + +| error_code | 客户端行为 | 结论 | +|---|---|---| +| `TRANSIENT_ERROR` | 按指数退避重试 | ✅ 采用。批准之后,下一次重试就能自动恢复同步,用户什么都不用做 | +| `THROTTLED` | 按服务器给的间隔暂停 | 可以用,但语义是"限流",不贴切 | +| `DISABLED_BY_ADMIN` | 停止同步,**直到浏览器重启** | ❌ 批准后还得重启浏览器 | +| `NOT_MY_BIRTHDAY` | 在客户端关闭同步,并清掉本地的加密口令 | ❌ 破坏性太大 | +| HTTP 401 / 403 | 被当成认证错误,会触发重新拿 token | ❌ 行为不可控 | + +**待验证**:退避的上限是多久。如果最长要等好几分钟,界面上要提示"批准后最多 N 分钟内生效"。 + +## 管理界面 + +### 🔴 管理界面必须和同步接口分开 + +现在的 `GET /` 只是一个只读的用户列表,和 `/command` 在同一个端口上,同样没有任何认证。 +如果直接在这个页面上加"开关"和"批准"按钮,那么任何能访问同步端口的人都能自己给自己批准,整个设计就失效了。 + +方案:新增一个独立的监听地址,专门跑管理界面。 + +- 新增环境变量 `SELFSYNC_ADMIN_ADDR`,默认 `127.0.0.1:8081`,只在本机可访问。 +- `SELFSYNC_ADDR` 上只保留同步接口和 `/healthz`。 +- 管理端口需要远程访问时,由用户自己在反向代理上加认证(OIDC、basic auth 都行,因为访问者是人,不是同步客户端)。#13 的提出者已经这么做了。 + +这是一个**行为变更**:升级后 `GET /` 不再出现在同步端口上,需要写进 CHANGELOG 和 README。 + +### 页面内容 + +可交互原型:https://claude.ai/artifact/FHkgyb2hjTQc1c4VRTCh97 + +单页,从上到下: + +1. **顶栏**:`selfsync 管理`,右侧标明管理端口和「仅本机可访问」,提醒管理员这个页面不该暴露出去;最右边是「中文 / English」语言切换。 +2. **审核开关卡片**:标题「新设备需要审核」,一句话说明开和关分别意味着什么,下方显示当前状态(「已开启 · 新设备批准后才能同步」/「已关闭 · 新设备自动批准」)。开关是 `role="switch"` 的按钮。 +3. **操作反馈**:开关切换、批准、拒绝之后,在开关下方显示一行结果(`aria-live`),例如「已批准:Chrome 141 · Windows(alice@example.com)」。 +4. **待审核**:标题带数量。每台设备一行: + - 主信息:浏览器和系统(从 User-Agent 解析,如 `Chrome 141 · Windows`)、账号邮箱 + - 次要信息:来源 IP、首次出现、最近一次尝试、设备 ID(只显示首尾几位) + - 操作:「拒绝」「批准」 + - 区块标题旁写明「批准后,浏览器下次重试时自动恢复同步,一般几分钟内,不用重启浏览器」 + - 开关关闭但仍有待审核设备时,顶部加一条提示:这些设备下次连接时会被自动批准 + - 没有待审核设备时显示空状态 +5. **已批准设备**:表格,列为设备、账号、来源 IP、首次出现、最近同步;每行一个「撤销」按钮,撤销即改成 rejected。刚批准的设备「最近同步」先显示「等待浏览器重试」。 + - 「设备」列两行:上面是设备名(如「Alice 的 MacBook Pro」「Google Pixel 8」),下面是浏览器和系统。 + - 设备名取自这台设备同步上来的 `DeviceInfoSpecifics.client_name`:在 `sync_entities` 里找 DEVICE_INFO 类型、`cache_guid` 等于该设备的那一条。 + - 还没同步过 DeviceInfo 的设备(刚批准的),上面一行显示浏览器和系统,下面一行写「设备名在首次同步后显示」。 +6. **已拒绝**:有数据时才显示。说明「这些设备会一直被拒绝,开关关闭也不会放行」;每行一个「改为批准」按钮。 +7. **账号**:原有用户列表,保留账号、浏览器,新增「已批准设备」数量,`next_version` 改叫「数据版本」;去掉对管理员没意义的 Store Birthday 列。 +8. **页脚说明**:设备 ID 什么情况下会变,以及变了之后同一台电脑会以新设备身份重新出现在待审核里。 + +视觉上沿用现有页面的朴素风格:白底卡片、无图标装饰,设备 ID、IP、版本号用等宽字体。状态靠文字和数量徽标区分,不只靠颜色。窄屏下按钮换行,表格在卡片内横向滚动。 + +### 原型里没覆盖、实现时要定的 + +- 撤销已批准设备要不要二次确认。原型里是直接撤销,因为可以从「已拒绝」里一键改回。 +- 设备多了以后要不要分页或搜索。个人自用场景下预计不超过几十台,第一版不做。 +- 页面是否自动刷新。待审核设备是浏览器在后台重试时才出现的,第一版可以只靠手动刷新,或者加一个每 10 秒的 ``。 + +### 管理接口 + +| 方法 | 路径 | 作用 | +|---|---|---| +| GET | `/` | 管理页面 | +| POST | `/settings/device-approval` | 切换开关 | +| POST | `/devices/{id}/approve` | 批准 | +| POST | `/devices/{id}/reject` | 拒绝 / 撤销 | + +表单提交用 POST,提交后重定向回 `/`(303)。 + +管理端口只监听本机,也挡不住你自己浏览器里打开的恶意网页向 `127.0.0.1:8081` 提交表单,所以加了两道检查: + +- **跨站 POST**:浏览器带 `Sec-Fetch-Site` 时只接受 `same-origin` / `none`;不带时比较 `Origin` 和 `Host`。两个头都没有(curl、脚本)放行。 +- **DNS rebinding**:管理端口绑在回环地址上时,`Host` 必须是 `localhost` / `127.0.0.1` / `[::1]`。绑在其他地址(Docker、反向代理后)时不检查,由反向代理负责。 + +### 前端实现方式 + +**不用前端框架,服务端直接渲染 HTML。** 页面就是几个列表加几个按钮,用不着 React / Vue,也不值得为它在 Dockerfile 里加一套 Node 构建。 + +- **模板**:用 [askama](https://crates.io/crates/askama)。模板是普通的 `.html` 文件,编译期生成 Rust 代码、自动转义,模板写错了编译不过,运行时没有额外开销。替代现在 `handler/users.rs` 里手拼字符串 + `html_escape` 的写法。 +- **交互**:每个按钮都是一个 `
`,提交后服务器重定向回 `/`(Post/Redirect/Get)。不写 JavaScript 也能用全部功能。 +- **样式**:一段内联 `