From 7e03c96f5b24fc789229bc5ea5d585ffc1fdcfeb Mon Sep 17 00:00:00 2001 From: lee Date: Fri, 16 Oct 2026 15:57:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=BE=85=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=8B=92=E7=BB=9D=E3=80=81=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E4=B8=8A=E9=99=90=E4=B8=8E=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 待审核和黑名单设备的请求直接拒绝,不写库,关开关也不放行 - 新增设备数量上限(默认 20),超出后新设备拒绝且不记录 - 校验 client_id 与邮箱的长度和字符 - 管理页:上限设置、移出黑名单、清空待审核、批准后提示恢复方法 --- CLAUDE.md | 6 +- README.md | 2 +- README.zh-CN.md | 2 +- crates/sync-server/src/admin/i18n.rs | 73 ++++-- crates/sync-server/src/admin/mod.rs | 86 ++++++- crates/sync-server/src/device.rs | 299 +++++++++++++++++++----- crates/sync-server/templates/admin.html | 36 ++- docs/device-approval.md | 43 ++-- docs/faq.md | 4 +- 9 files changed, 442 insertions(+), 109 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb022c..a1c6543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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) +│ ├── device.rs # Per-device admission (client_id query param, approval switch, device limit) │ ├── 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) @@ -60,8 +60,8 @@ selfsync/ - `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 /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`. +- **Admin page** (separate listener `SELFSYNC_ADMIN_ADDR`, default `127.0.0.1:8081`): `GET /`, `POST /settings/device-approval|device-limit`, `POST /devices/{id}/approve|reject|delete`, `POST /devices/clear-pending`. 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. Pending / blocklisted (`rejected`) devices and unknown devices past the device limit are refused without any DB write. 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. diff --git a/README.md b/README.md index c0d2b19..fb99881 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ In the Docker image the defaults are overridden to `0.0.0.0:8080`, `0.0.0.0:8081 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). +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. Pending and blocked devices are refused without the server writing anything, and once approved plus pending devices reach the **Device limit** (default 20), new devices are refused without being recorded. 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1671388..141caaa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,7 +97,7 @@ Docker 镜像里默认值被覆盖为 `0.0.0.0:8080`、`0.0.0.0:8081` 和 `/data 管理页在单独的端口上,`SELFSYNC_ADMIN_ADDR`(默认 `127.0.0.1:8081`,只有本机能访问),列出账号和设备,支持中文和英文。 -打开「新设备需要审核」后,没批准过的浏览器同步会被拒绝,直到你点「批准」;批准后浏览器下次重试就恢复,不用重启。打开开关之前已经在用的设备不受影响。设计见 [docs/device-approval.md](docs/device-approval.md)。 +打开「新设备需要审核」后,没批准过的浏览器同步会被拒绝,直到你点「批准」;批准后浏览器下次重试就恢复,不用重启。打开开关之前已经在用的设备不受影响。待审核和黑名单里的设备请求直接拒绝,服务器不写库;已批准和待审核的设备加起来达到「设备数量上限」(默认 20)后,新设备直接拒绝、不记录。设计见 [docs/device-approval.md](docs/device-approval.md)。 同步端口没有任何认证,管理端口千万别照着同步端口那样开放出去。要远程访问,请放在带认证的反向代理后面。 diff --git a/crates/sync-server/src/admin/i18n.rs b/crates/sync-server/src/admin/i18n.rs index 1bad2dc..9f06172 100644 --- a/crates/sync-server/src/admin/i18n.rs +++ b/crates/sync-server/src/admin/i18n.rs @@ -84,13 +84,18 @@ pub struct Texts { pub status_on: &'static str, pub status_off: &'static str, + pub limit_title: &'static str, + pub limit_desc: &'static str, + pub limit_usage: &'static str, + pub save: &'static str, + pub limit_reached: &'static str, + pub pending_title: &'static str, pub pending_hint: &'static str, - pub off_note: &'static str, pub no_pending: &'static str, + pub clear_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, @@ -104,8 +109,8 @@ pub struct Texts { pub name_after_sync: &'static str, pub approve: &'static str, - pub reject: &'static str, - pub revoke: &'static str, + pub block: &'static str, + pub unblock: &'static str, pub re_approve: &'static str, pub users_title: &'static str, @@ -120,6 +125,9 @@ pub struct Texts { pub done_rejected: &'static str, pub done_on: &'static str, pub done_off: &'static str, + pub done_limit: &'static str, + pub done_deleted: &'static str, + pub resume_hint: &'static str, } impl Texts { @@ -140,6 +148,15 @@ impl Texts { } } + /// Flash after clearing pending devices. + pub fn cleared(&self, n: u64) -> String { + match (self.html_lang, n) { + ("zh-CN", _) => format!("已清空 {n} 台待审核设备"), + (_, 1) => "Cleared 1 pending device".to_string(), + _ => format!("Cleared {n} pending devices"), + } + } + fn just_now(&self) -> &'static str { if self.html_lang == "zh-CN" { "刚刚" @@ -159,22 +176,27 @@ pub const ZH: Texts = Texts { language: "界面语言", approval_title: "新设备需要审核", - approval_desc: "打开后,没批准过的浏览器无法同步,会出现在下方「待审核」里,等你批准。关闭时,新设备连上来会自动批准。", + approval_desc: "打开后,没批准过的浏览器无法同步,会出现在下方「待审核」里,等你批准。关闭时,新设备连上来会自动批准。已经在待审核里的设备不受开关影响,仍要你来处理。", status_on: "已开启 · 新设备批准后才能同步", status_off: "已关闭 · 新设备自动批准", + limit_title: "设备数量上限", + limit_desc: "已批准和待审核的设备加起来达到上限后,新设备直接拒绝,不记录、不出现在待审核里。黑名单不占名额。", + limit_usage: "当前", + save: "保存", + limit_reached: "已达上限:新设备会被直接拒绝。要加设备,先调高上限,或把不用的设备加入黑名单。", + pending_title: "待审核", pending_hint: "批准后,浏览器下次重试时自动恢复同步,一般几分钟内,不用重启浏览器。", - off_note: "审核已关闭:下面这些设备下次连接时会被自动批准。", no_pending: "没有待审核的设备。", + clear_pending: "全部清空", ip: "来源 IP", first_seen: "首次出现", - last_attempt: "最近一次尝试", device_id: "设备 ID", approved_title: "已批准设备", - rejected_title: "已拒绝", - rejected_hint: "这些设备会一直被拒绝,开关关闭也不会放行。", + rejected_title: "黑名单", + rejected_hint: "这些设备的请求一律拒绝。「改为批准」直接放行;「移出黑名单」删除记录,下次连上来按新设备处理。", no_approved: "还没有已批准的设备。", col_device: "设备", col_account: "账号", @@ -183,8 +205,8 @@ pub const ZH: Texts = Texts { name_after_sync: "设备名在首次同步后显示", approve: "批准", - reject: "拒绝", - revoke: "撤销", + block: "加入黑名单", + unblock: "移出黑名单", re_approve: "改为批准", users_title: "账号", @@ -196,9 +218,12 @@ pub const ZH: Texts = Texts { footer: "设备 ID 由浏览器自己生成。在浏览器里退出账号、关掉再打开同步、或换一个浏览器配置文件后,设备 ID 会变,同一台电脑会以新设备的身份重新出现在「待审核」里。", done_approved: "已批准", - done_rejected: "已拒绝", + done_rejected: "已加入黑名单", done_on: "审核已打开,新设备需要批准", done_off: "审核已关闭,新设备会自动批准", + done_limit: "设备数量上限已保存", + done_deleted: "已移出黑名单", + resume_hint: "浏览器会在下次重试时自动恢复同步,一般几分钟内。想马上恢复:重启浏览器,或打开 chrome://sync-internals(Edge 是 edge://sync-internals)点「Trigger GetUpdates」。不要关掉再打开同步,那样设备 ID 会变,要重新审批。", }; pub const EN: Texts = Texts { @@ -211,22 +236,27 @@ pub const EN: Texts = Texts { 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.", + 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. Devices already pending are not affected by this switch; they wait for you either way.", status_on: "On · new devices sync only after approval", status_off: "Off · new devices are approved automatically", + limit_title: "Device limit", + limit_desc: "Once approved and pending devices reach the limit, new devices are refused outright: not recorded, not listed as pending. Blocked devices don't count.", + limit_usage: "In use", + save: "Save", + limit_reached: "Limit reached: new devices are refused. To add one, raise the limit or block a device you no longer use.", + 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.", + clear_pending: "Clear all", 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.", + rejected_title: "Blocked", + rejected_hint: "Every request from these devices is refused. Approve lets one back in; Unblock forgets it, so its next request is handled as a new device.", no_approved: "No approved devices yet.", col_device: "Device", col_account: "Account", @@ -235,8 +265,8 @@ pub const EN: Texts = Texts { name_after_sync: "Device name appears after its first sync", approve: "Approve", - reject: "Reject", - revoke: "Revoke", + block: "Block", + unblock: "Unblock", re_approve: "Approve", users_title: "Accounts", @@ -248,9 +278,12 @@ pub const EN: Texts = Texts { 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_rejected: "Blocked", done_on: "Approval is on; new devices need approval", done_off: "Approval is off; new devices are approved automatically", + done_limit: "Device limit saved", + done_deleted: "Unblocked", + resume_hint: "The browser resumes on its next retry, usually within a few minutes. To resume now, restart the browser, or open chrome://sync-internals (edge://sync-internals in Edge) and click Trigger GetUpdates. Don't turn sync off and on: that changes the device ID and it needs approval again.", }; #[cfg(test)] diff --git a/crates/sync-server/src/admin/mod.rs b/crates/sync-server/src/admin/mod.rs index 056957a..4501465 100644 --- a/crates/sync-server/src/admin/mod.rs +++ b/crates/sync-server/src/admin/mod.rs @@ -24,7 +24,10 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Order, QueryFilter, use serde::Deserialize; use crate::db::entity::{device, sync_entity, user}; -use crate::device::{approval_required, set_approval_required, set_status}; +use crate::device::{ + DEVICE_LIMIT_RANGE, approval_required, clear_pending, delete, device_limit, + set_approval_required, set_device_limit, set_status, +}; use crate::proto::sync_pb; use crate::util::now_millis; use i18n::{LANG_COOKIE, Lang, Texts}; @@ -45,8 +48,11 @@ pub fn router(db: DatabaseConnection, config: AdminConfig) -> Router { Router::new() .route("/", get(index)) .route("/settings/device-approval", post(toggle_approval)) + .route("/settings/device-limit", post(update_limit)) + .route("/devices/clear-pending", post(clear_pending_devices)) .route("/devices/{id}/approve", post(approve)) .route("/devices/{id}/reject", post(reject)) + .route("/devices/{id}/delete", post(delete_device)) .layer(middleware::from_fn(check_request)) .layer(Extension(db)) .layer(Extension(config)) @@ -112,6 +118,7 @@ struct IndexParams { lang: Option, done: Option, device: Option, + n: Option, } struct DeviceRow { @@ -138,9 +145,16 @@ struct AdminPage { t: &'static Texts, lang: &'static str, approval_on: bool, + device_limit: u64, + /// Approved plus pending: what the limit counts. + active_devices: usize, + limit_min: u64, + limit_max: u64, admin_addr: String, loopback: bool, flash: Option, + /// Shown under the flash after an approval. + resume_hint: bool, pending: Vec, approved: Vec, rejected: Vec, @@ -186,6 +200,7 @@ async fn render_index( let t = lang.texts(); let now = now_millis(); let approval_on = approval_required(db).await?; + let limit = device_limit(db).await?; let devices = device::Entity::find() .order_by(device::Column::LastSeen, Order::Desc) .all(db) @@ -241,14 +256,20 @@ async fn render_index( .collect(); let flash = flash_message(t, params, &devices, &names); + let resume_hint = flash.is_some() && params.done.as_deref() == Some("approved"); let page = AdminPage { t, lang: lang.code(), approval_on, + device_limit: limit, + active_devices: pending.len() + approved.len(), + limit_min: *DEVICE_LIMIT_RANGE.start(), + limit_max: *DEVICE_LIMIT_RANGE.end(), admin_addr: config.addr.clone(), loopback: config.loopback, flash, + resume_hint, pending, approved, rejected, @@ -277,6 +298,9 @@ fn flash_message( "rejected" => described(t.done_rejected), "on" => Some(t.done_on.to_string()), "off" => Some(t.done_off.to_string()), + "limit" => Some(t.done_limit.to_string()), + "deleted" => Some(t.done_deleted.to_string()), + "cleared" => Some(t.cleared(params.n.unwrap_or(0))), _ => None, } } @@ -329,6 +353,66 @@ async fn toggle_approval( } } +#[derive(Deserialize)] +struct LimitForm { + limit: String, +} + +async fn update_limit( + Extension(db): Extension, + Form(form): Form, +) -> Response { + let Some(limit) = form + .limit + .trim() + .parse::() + .ok() + .filter(|n| DEVICE_LIMIT_RANGE.contains(n)) + else { + return (StatusCode::BAD_REQUEST, "device limit out of range").into_response(); + }; + match set_device_limit(&db, limit).await { + Ok(()) => { + tracing::info!(limit, "device limit changed"); + Redirect::to("/?done=limit").into_response() + } + Err(e) => { + tracing::error!("set device limit failed: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +async fn delete_device( + Extension(db): Extension, + Path(id): Path, +) -> Response { + match delete(&db, id).await { + Ok(Some(d)) => { + tracing::info!(id, email = %d.email, cache_guid = %d.cache_guid, "device deleted"); + Redirect::to("/?done=deleted").into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "no such device").into_response(), + Err(e) => { + tracing::error!(id, "device delete failed: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +async fn clear_pending_devices(Extension(db): Extension) -> Response { + match clear_pending(&db).await { + Ok(n) => { + tracing::info!(n, "pending devices cleared"); + Redirect::to(&format!("/?done=cleared&n={n}")).into_response() + } + Err(e) => { + tracing::error!("clear pending devices 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 } diff --git a/crates/sync-server/src/device.rs b/crates/sync-server/src/device.rs index 8b5bff7..29b2f1a 100644 --- a/crates/sync-server/src/device.rs +++ b/crates/sync-server/src/device.rs @@ -8,14 +8,19 @@ //! switch off they are recorded and approved automatically, so turning it on //! later does not lock out devices already in use. //! +//! Pending and blocklisted devices are refused without touching the database, +//! and once approved plus pending devices reach the admin-set limit, unknown +//! installs are refused without being recorded. Either way an unapproved +//! client can't make the server write. +//! //! 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, + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + Set, sea_query::OnConflict, }; use crate::auth::ClientIdentity; @@ -23,7 +28,19 @@ use crate::db::entity::{device, setting}; use crate::util::now_millis; const SETTING_DEVICE_APPROVAL: &str = "device_approval"; +const SETTING_DEVICE_LIMIT: &str = "device_limit"; const MAX_USER_AGENT_LEN: usize = 256; +/// Chromium's cache GUID is 16 random bytes in base64 (24 chars); leave room +/// for other Chromium-based clients without letting a request store an +/// arbitrarily long id. +const MAX_CACHE_GUID_LEN: usize = 64; +/// RFC 5321 limit on a mailbox. +const MAX_EMAIL_LEN: usize = 254; + +/// Default cap on approved plus pending devices. +pub const DEFAULT_DEVICE_LIMIT: u64 = 20; +/// Range the admin page accepts for the cap. +pub const DEVICE_LIMIT_RANGE: std::ops::RangeInclusive = 1..=1000; /// Device metadata taken from the HTTP layer of a sync request. #[derive(Debug, Clone, Default)] @@ -80,39 +97,43 @@ pub enum Admission { 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). +/// What to do with a request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Decision { - admission: Admission, - store: Option, +enum Action { + /// Known approved device: let it through and refresh its row. + Allow, + /// Refuse without writing anything. + Deny, + /// New device: insert a row with this status. `Approved` lets the + /// request through, `Pending` refuses it. + Record(device::Status), } -fn decide(existing: Option, approval_required: bool) -> Decision { +/// `at_limit`: approved plus pending devices have reached the cap. Only +/// consulted for devices without a row. +fn decide(existing: Option, approval_required: bool, at_limit: bool) -> Action { 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), - }, + match existing { + Some(Approved) => Action::Allow, + // Pending stays pending until the admin acts, even if the switch is + // turned off meanwhile. + Some(Pending | Rejected) => Action::Deny, + None if at_limit => Action::Deny, + None if approval_required => Action::Record(Pending), + None => Action::Record(Approved), } } +/// A client id or email no Chromium-based browser would send. Refused +/// before anything is written. +fn malformed(identity: &ClientIdentity, cache_guid: &str) -> bool { + let guid_ok = cache_guid.len() <= MAX_CACHE_GUID_LEN + && cache_guid + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"+/=-_".contains(&b)); + !guid_ok || identity.email.len() > MAX_EMAIL_LEN +} + /// 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. @@ -131,27 +152,29 @@ pub async fn admit( Admission::Allow }); }; + if malformed(identity, cache_guid) { + return Ok(Admission::Deny); + } let now = now_millis(); let existing = find(db, cache_guid, identity).await?; - let decision = decide( + let at_limit = existing.is_none() && active_count(db).await? >= device_limit(db).await?; + let action = decide( existing.as_ref().map(device::Model::status), approval_required, + at_limit, ); - match existing { - Some(row) => { + match (action, existing) { + (Action::Allow, 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?; + Ok(Admission::Allow) } - None => { - let status = decision.store.unwrap_or(device::Status::Pending); + (Action::Record(status), None) => { let row = device::ActiveModel { cache_guid: Set(cache_guid.to_string()), email: Set(identity.email.clone()), @@ -177,18 +200,29 @@ pub async fn admit( ) .exec_without_returning(db) .await?; - if status == device::Status::Pending { - tracing::info!( + if status == device::Status::Approved { + return Ok(Admission::Allow); + } + tracing::info!( + email = %identity.email, + browser = %identity.browser, + cache_guid, + "new device waiting for approval" + ); + Ok(Admission::Deny) + } + // Deny, or a stale pairing from a concurrent admin change: refuse. + _ => { + if at_limit { + tracing::warn!( email = %identity.email, - browser = %identity.browser, cache_guid, - "new device waiting for approval" + "device limit reached, new device refused" ); } + Ok(Admission::Deny) } } - - Ok(decision.admission) } async fn find( @@ -243,6 +277,61 @@ pub async fn set_status( Ok(Some(active.update(db).await?)) } +/// Cap on approved plus pending devices. Unknown devices beyond it are +/// refused without being recorded. +pub async fn device_limit(db: &DatabaseConnection) -> anyhow::Result { + let row = setting::Entity::find_by_id(SETTING_DEVICE_LIMIT) + .one(db) + .await?; + Ok(row + .and_then(|r| r.value.parse().ok()) + .unwrap_or(DEFAULT_DEVICE_LIMIT)) +} + +pub async fn set_device_limit(db: &DatabaseConnection, limit: u64) -> anyhow::Result<()> { + let row = setting::ActiveModel { + key: Set(SETTING_DEVICE_LIMIT.to_string()), + value: Set(limit.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(()) +} + +/// Approved plus pending devices; what [`device_limit`] caps. Blocklisted +/// rows don't count, so blocking junk frees room. +async fn active_count(db: &DatabaseConnection) -> anyhow::Result { + Ok(device::Entity::find() + .filter(device::Column::Status.ne(device::Status::Rejected.as_db_str())) + .count(db) + .await?) +} + +/// Forget a device; its next request is handled as a new device. Returns +/// the deleted row, or `None` when the id does not exist. +pub async fn delete(db: &DatabaseConnection, id: i32) -> anyhow::Result> { + let Some(row) = device::Entity::find_by_id(id).one(db).await? else { + return Ok(None); + }; + device::Entity::delete_by_id(id).exec(db).await?; + Ok(Some(row)) +} + +/// Forget every pending device. Returns how many were removed. +pub async fn clear_pending(db: &DatabaseConnection) -> anyhow::Result { + let res = device::Entity::delete_many() + .filter(device::Column::Status.eq(device::Status::Pending.as_db_str())) + .exec(db) + .await?; + Ok(res.rows_affected) +} + #[cfg(test)] mod tests { use super::*; @@ -251,22 +340,32 @@ mod tests { #[test] fn decide_matrix() { - let allow = |store| Decision { - admission: Admission::Allow, - store, - }; - let deny = |store| Decision { - admission: Admission::Deny, - store, + use Action::*; + for switch in [false, true] { + for limit in [false, true] { + assert_eq!(decide(Some(Approved), switch, limit), Allow); + assert_eq!(decide(Some(Pending), switch, limit), Deny); + assert_eq!(decide(Some(Rejected), switch, limit), Deny); + assert_eq!(decide(None, switch, true), Deny); + } + } + assert_eq!(decide(None, false, false), Record(Approved)); + assert_eq!(decide(None, true, false), Record(Pending)); + } + + #[test] + fn malformed_ids_and_emails() { + let me = alice(); + assert!(!malformed(&me, "Qm9yQ2FjaGVHdWlkMTIzNA==")); + assert!(!malformed(&me, "dev-a_b")); + assert!(malformed(&me, &"a".repeat(MAX_CACHE_GUID_LEN + 1))); + assert!(malformed(&me, "has space")); + assert!(malformed(&me, "