Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

同步端口没有任何认证,管理端口千万别照着同步端口那样开放出去。要远程访问,请放在带认证的反向代理后面。

Expand Down
73 changes: 53 additions & 20 deletions crates/sync-server/src/admin/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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" {
"刚刚"
Expand All @@ -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: "账号",
Expand All @@ -183,8 +205,8 @@ pub const ZH: Texts = Texts {
name_after_sync: "设备名在首次同步后显示",

approve: "批准",
reject: "拒绝",
revoke: "撤销",
block: "加入黑名单",
unblock: "移出黑名单",
re_approve: "改为批准",

users_title: "账号",
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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)]
Expand Down
86 changes: 85 additions & 1 deletion crates/sync-server/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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))
Expand Down Expand Up @@ -112,6 +118,7 @@ struct IndexParams {
lang: Option<String>,
done: Option<String>,
device: Option<i32>,
n: Option<u64>,
}

struct DeviceRow {
Expand All @@ -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<String>,
/// Shown under the flash after an approval.
resume_hint: bool,
pending: Vec<DeviceRow>,
approved: Vec<DeviceRow>,
rejected: Vec<DeviceRow>,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -329,6 +353,66 @@ async fn toggle_approval(
}
}

#[derive(Deserialize)]
struct LimitForm {
limit: String,
}

async fn update_limit(
Extension(db): Extension<DatabaseConnection>,
Form(form): Form<LimitForm>,
) -> Response {
let Some(limit) = form
.limit
.trim()
.parse::<u64>()
.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<DatabaseConnection>,
Path(id): Path<i32>,
) -> 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<DatabaseConnection>) -> 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<DatabaseConnection>, Path(id): Path<i32>) -> Response {
change_status(&db, id, device::Status::Approved, "approved").await
}
Expand Down
Loading
Loading