dscs-server 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,38 +2,80 @@
2
2
 
3
3
  DSH Session CloudSync 的自部署服务端。
4
4
 
5
- 当前版本:**0.1.3,v2-only**(`PROTOCOL_VERSION = 3`)。设备 API 只有 `/v2/*`,
6
- 旧的 `/v1/*` 已移除;0.0.x 插件不兼容,需要 0.1.0 起的插件。
5
+ 当前发布版本:**0.1.5**(协议版本 3,设备 API 只有 `/v2/*`;数据库结构版本 6)。
7
6
 
8
- ## 能力
7
+ ## 能做什么
9
8
 
10
- - `/v2/sessions` 全量快照、`/v2/changes` 增量流;
11
- - 会话摘要、ETag、批次条件分页;
12
- - 带 `Idempotency-Key` 的 append;
13
- - `POST /v2/blobs/batch` / `POST /v2/blobs/batch/read` 批量 blob(8 MiB / 64 条);
14
- - zstd 封帧(加密前压缩)与服务端压缩状态聚合;
15
- - Vault 租约、append、cursor、stream。
9
+ - **多设备接续**:把 DSH 会话同步到自己的服务器,在另一台设备继续;
10
+ - **私有部署**:数据留在自己的机器或 NAS,不经过第三方;
11
+ - **端到端加密**:服务端只保存不透明密文和同步所需的基本信息,看不到会话内容;
12
+ - **弱网可用**:断点续传、批量传输、加密前压缩,网络恢复后继续;
13
+ - **附件通道**:加密附件(图片/文件)与按批次的附件索引有独立的 blob 类型,会话移除时一并释放;插件侧接入后生效;
14
+ - **不会互相覆盖**:同一时间只允许一台设备上传,避免多端同时写乱;
15
+ - **设备管理**:签发/改名/吊销设备,查看最后在线时间,为设备设置颜色;
16
+ - **会话管理**:存档、移除并释放空间、重置后重新上传、恢复存档;
17
+ - **审计**:记录登录、设备、配额、密码和会话管理动作,不记录内容或密钥;
18
+ - **历史与活动**:查看单个会话的变更时间线,以及全局的每日同步强度热力图。
16
19
 
17
20
  ## 安装
18
21
 
22
+ ### 一键安装(推荐)
23
+
24
+ Linux / macOS:
25
+
26
+ ```sh
27
+ curl -fsSL https://raw.githubusercontent.com/xfy2412/dsh-session-cloudsync/main/scripts/install.sh -o install.sh
28
+ less install.sh # 建议先看一眼脚本
29
+ sh install.sh
30
+ ```
31
+
32
+ Windows PowerShell:
33
+
34
+ ```powershell
35
+ irm https://raw.githubusercontent.com/xfy2412/dsh-session-cloudsync/main/scripts/install.ps1 -OutFile install.ps1
36
+ notepad install.ps1 # 建议先看一眼脚本
37
+ .\install.ps1
38
+ ```
39
+
40
+ 安装脚本会把版本装到独立目录,并可选注册 systemd 或计划任务;支持
41
+ `--version`、`--upgrade`、`--uninstall`、`--purge` 等参数。
42
+
43
+ ### 手动安装
44
+
19
45
  ```sh
20
46
  npm i -g dscs-server
21
47
  dscs-server serve --data-dir /var/lib/dscs --host 127.0.0.1 --port 8787
22
48
  ```
23
49
 
24
- 首次启动会在控制台打印一次性初始化密钥,同时写入数据目录的 `onboarding-code.json`。打开 `http://127.0.0.1:8787/admin` 完成管理密码、配额和设备初始化。
50
+ 首次启动会在控制台打印一次性初始化密钥,同时写入数据目录的 `onboarding-code.json`。
51
+ 打开 `http://127.0.0.1:8787/admin` 完成管理密码、配额和设备初始化。
52
+
53
+ ## CLI
54
+
55
+ ```sh
56
+ dscs-server serve --data-dir /var/lib/dscs --host 127.0.0.1 --port 8787
57
+ dscs-server token add laptop
58
+ dscs-server token list
59
+ dscs-server token revoke <deviceId>
60
+ dscs-server admin status
61
+ dscs-server admin reset-password --data-dir /var/lib/dscs
62
+ printf '%s' "$NEW_PASSWORD" | dscs-server admin reset-password --data-dir /var/lib/dscs --stdin
63
+ ```
25
64
 
26
- Linux/macOS 也可以使用仓库的 `scripts/install.sh`;Windows 使用 `scripts/install.ps1`。安装脚本会把版本装到独立目录,并可选注册 systemd 或计划任务。
65
+ `admin reset-password` 默认生成并打印一次随机密码;`--stdin` 读取自定义密码。
66
+ 重置后需重启 server 让旧管理会话失效;在管理台修改密码会立即失效其它会话。
27
67
 
28
- ## 要求
68
+ ## 要求与迁移
29
69
 
30
70
  - Node.js >= 24;
31
71
  - 默认只监听 `127.0.0.1`,公网部署请放在 TLS 反代后;
32
- - 数据目录包含 SQLite 与 blob 文件,升级前请备份;
33
- - 服务端不持有明文会话内容,只存不透明密文;
34
- - 0.0.1 数据目录升级到 0.1.0 会迁移到 schema 4,但旧插件仍会 404;
35
- - 已有未压缩会话不会自动压缩;需要清空数据目录后由 0.1.0 插件全量重传;
36
- - 协议说明见仓库 `docs/architecture/PROTOCOL.md`。
72
+ - 数据目录包含 SQLite、blob 文件与迁移备份,升级前请先备份;
73
+ - 数据库结构版本 6,支持从旧版本自动迁移,迁移前会写本地备份;
74
+ - 0.1.5 不迁移数据库,可直接回滚;旧插件不受影响(附件功能按 `/health` 的 `attachment-index` capability 门控);
75
+ - 旧的变更流接口已移除;0.1.0 插件不兼容,需要插件 >= 0.1.1;
76
+ - 服务端不持有明文会话内容,只存不透明密文和同步元数据;
77
+ - 已有未压缩会话不会自动压缩;需要清空数据目录后由插件全量重传;
78
+ - 协议说明见 `docs/architecture/PROTOCOL.md`,错误分类见 `docs/architecture/ERRORS.md`。
37
79
 
38
80
  ## 许可证
39
81
 
@@ -0,0 +1,27 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { deriveMasterKey, newSalt } from './shared/crypto.js';
3
+ import { recordAudit } from './audit.js';
4
+ import { isAdminInitialized, setAdminPassword } from './db.js';
5
+ import { validateAdminPassword } from './http.js';
6
+ export function generateAdminPassword() {
7
+ for(let attempt = 0; attempt < 10; attempt += 1){
8
+ const candidate = randomBytes(18).toString('base64url');
9
+ if (validateAdminPassword(candidate) === undefined) return candidate;
10
+ }
11
+ return randomBytes(18).toString('base64url') + '!aA1';
12
+ }
13
+ export function resetAdminPassword(db, password) {
14
+ if (!isAdminInitialized(db)) throw new Error('尚未初始化,请先通过 /admin 完成初始化');
15
+ const problem = validateAdminPassword(password);
16
+ if (problem !== undefined) throw new Error(problem);
17
+ const salt = newSalt();
18
+ setAdminPassword(db, deriveMasterKey(password, salt).toString('hex'), salt.toString('hex'));
19
+ recordAudit(db, {
20
+ actorKind: 'system',
21
+ actorId: 'cli',
22
+ action: 'password.reset_cli'
23
+ });
24
+ }
25
+
26
+
27
+ //# sourceURL=admin-password.ts
package/dist/audit.js ADDED
@@ -0,0 +1,73 @@
1
+ import { DEFAULT_DATASET } from './db.js';
2
+ function requestMeta(req) {
3
+ if (req === undefined) return {
4
+ ip: null,
5
+ userAgent: null
6
+ };
7
+ const forwarded = req.headers['x-forwarded-for'];
8
+ const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
9
+ const ip = first !== undefined && first.trim() !== '' ? first.split(',')[0].trim() : req.socket.remoteAddress ?? null;
10
+ const agent = req.headers['user-agent'];
11
+ const userAgent = typeof agent === 'string' && agent !== '' ? agent.slice(0, 300) : null;
12
+ return {
13
+ ip,
14
+ userAgent
15
+ };
16
+ }
17
+ export function recordAudit(db, input, req) {
18
+ try {
19
+ const meta = requestMeta(req);
20
+ db.handle.prepare(`INSERT INTO audit_events (dataset, at, actor_kind, actor_id, action, target, detail, ip, user_agent)
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, Date.now(), input.actorKind, input.actorId, input.action, input.target ?? null, input.detail === undefined ? null : JSON.stringify(input.detail), meta.ip, meta.userAgent);
22
+ } catch {}
23
+ }
24
+ export function listAudit(db, query = {}) {
25
+ const limit = Math.min(200, Math.max(1, Math.trunc(query.limit ?? 50)));
26
+ const where = [
27
+ 'e.dataset = ?'
28
+ ];
29
+ const params = [
30
+ DEFAULT_DATASET
31
+ ];
32
+ if (query.before !== undefined && Number.isSafeInteger(query.before)) {
33
+ where.push('e.id < ?');
34
+ params.push(query.before);
35
+ }
36
+ if (query.action !== undefined && query.action !== '') {
37
+ where.push('e.action = ?');
38
+ params.push(query.action);
39
+ }
40
+ params.push(limit);
41
+ const rows = db.handle.prepare(`SELECT e.id, e.at, e.actor_kind, e.actor_id, e.action, e.target, e.detail, e.ip, e.user_agent,
42
+ d.label AS actor_label
43
+ FROM audit_events e
44
+ LEFT JOIN devices d ON d.dataset = e.dataset AND d.device_id = e.actor_id
45
+ WHERE ${where.join(' AND ')}
46
+ ORDER BY e.id DESC LIMIT ?`).all(...params);
47
+ const events = rows.map((row)=>({
48
+ id: row.id,
49
+ at: row.at,
50
+ actorKind: row.actor_kind,
51
+ actorId: row.actor_id,
52
+ actorLabel: row.actor_kind === 'admin' ? '管理台' : row.actor_kind === 'system' ? '系统' : row.actor_label ?? row.actor_id,
53
+ action: row.action,
54
+ target: row.target,
55
+ detail: row.detail === null ? null : parseDetail(row.detail),
56
+ ip: row.ip,
57
+ userAgent: row.user_agent
58
+ }));
59
+ return {
60
+ events,
61
+ nextBefore: rows.length === limit ? rows[rows.length - 1].id : null
62
+ };
63
+ }
64
+ function parseDetail(raw) {
65
+ try {
66
+ return JSON.parse(raw);
67
+ } catch {
68
+ return raw;
69
+ }
70
+ }
71
+
72
+
73
+ //# sourceURL=audit.ts
package/dist/db.js CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  export const DEFAULT_DATASET = 'default';
6
- export const SERVER_SCHEMA_VERSION = 4;
6
+ export const SERVER_SCHEMA_VERSION = 6;
7
7
  const SCHEMA_VERSION = SERVER_SCHEMA_VERSION;
8
8
  const SCHEMA = `
9
9
  CREATE TABLE IF NOT EXISTS meta (
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS devices (
19
19
  created_at INTEGER NOT NULL,
20
20
  last_seen_at INTEGER,
21
21
  revoked_at INTEGER,
22
+ color TEXT,
22
23
  PRIMARY KEY (dataset, device_id)
23
24
  );
24
25
 
@@ -35,6 +36,7 @@ CREATE TABLE IF NOT EXISTS sessions (
35
36
  revision INTEGER NOT NULL,
36
37
  updated_at INTEGER NOT NULL,
37
38
  deleted_at INTEGER,
39
+ purged_at INTEGER,
38
40
  compression TEXT NOT NULL DEFAULT 'none',
39
41
  stored_bytes INTEGER NOT NULL DEFAULT 0,
40
42
  PRIMARY KEY (dataset, session_id)
@@ -91,6 +93,55 @@ CREATE TABLE IF NOT EXISTS tombstones (
91
93
  deleted_by TEXT NOT NULL,
92
94
  PRIMARY KEY (dataset, session_id, created_at)
93
95
  );
96
+
97
+ CREATE TABLE IF NOT EXISTS audit_events (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ dataset TEXT NOT NULL,
100
+ at INTEGER NOT NULL,
101
+ actor_kind TEXT NOT NULL,
102
+ actor_id TEXT NOT NULL,
103
+ action TEXT NOT NULL,
104
+ target TEXT,
105
+ detail TEXT,
106
+ ip TEXT,
107
+ user_agent TEXT
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS audit_events_by_time ON audit_events (dataset, at DESC);
111
+ CREATE INDEX IF NOT EXISTS audit_events_by_action ON audit_events (dataset, action, at DESC);
112
+
113
+ CREATE TABLE IF NOT EXISTS session_resets (
114
+ dataset TEXT NOT NULL,
115
+ session_id TEXT NOT NULL,
116
+ created_at INTEGER NOT NULL,
117
+ reset_at INTEGER NOT NULL,
118
+ expires_at INTEGER NOT NULL,
119
+ device_id TEXT,
120
+ workspace_key TEXT,
121
+ PRIMARY KEY (dataset, session_id)
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS session_events (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ dataset TEXT NOT NULL,
127
+ session_id TEXT NOT NULL,
128
+ at INTEGER NOT NULL,
129
+ kind TEXT NOT NULL,
130
+ device_id TEXT,
131
+ actor_kind TEXT NOT NULL,
132
+ actor_id TEXT NOT NULL DEFAULT '',
133
+ events INTEGER NOT NULL DEFAULT 0,
134
+ bytes INTEGER NOT NULL DEFAULT 0,
135
+ seq_from INTEGER,
136
+ seq_to INTEGER,
137
+ batch_count INTEGER NOT NULL DEFAULT 0,
138
+ last_seq INTEGER,
139
+ stored_bytes INTEGER,
140
+ detail TEXT
141
+ );
142
+
143
+ CREATE INDEX IF NOT EXISTS session_events_by_session ON session_events (dataset, session_id, at);
144
+ CREATE INDEX IF NOT EXISTS session_events_by_time ON session_events (dataset, at);
94
145
  `;
95
146
  function tableExists(handle, table) {
96
147
  const row = handle.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
@@ -161,18 +212,133 @@ function ensureSessionStatsColumns(handle) {
161
212
  if (added) backfillSessionStats(handle);
162
213
  return added;
163
214
  }
215
+ export const DEVICE_COLORS = [
216
+ '#5b88ff',
217
+ '#5ed392',
218
+ '#f0b64a',
219
+ '#c084fc',
220
+ '#38bdf8',
221
+ '#f472b6',
222
+ '#a3e635',
223
+ '#fb923c',
224
+ '#94a3b8',
225
+ '#f87171'
226
+ ];
227
+ export function nextDeviceColor(db) {
228
+ const rows = db.handle.prepare('SELECT color FROM devices WHERE dataset = ? ORDER BY created_at').all(DEFAULT_DATASET);
229
+ const used = new Set(rows.map((row)=>row.color).filter((color)=>typeof color === 'string' && color !== ''));
230
+ const free = DEVICE_COLORS.find((color)=>!used.has(color));
231
+ return free ?? DEVICE_COLORS[rows.length % DEVICE_COLORS.length];
232
+ }
233
+ function backfillDeviceColors(handle) {
234
+ const rows = handle.prepare('SELECT device_id, color FROM devices ORDER BY created_at, device_id').all();
235
+ const used = new Set(rows.map((row)=>row.color).filter((color)=>typeof color === 'string' && color !== ''));
236
+ const update = handle.prepare('UPDATE devices SET color = ? WHERE device_id = ?');
237
+ let next = 0;
238
+ for (const row of rows){
239
+ if (typeof row.color === 'string' && row.color !== '') continue;
240
+ let color;
241
+ while(next < DEVICE_COLORS.length){
242
+ const candidate = DEVICE_COLORS[next];
243
+ next += 1;
244
+ if (candidate !== undefined && !used.has(candidate)) {
245
+ color = candidate;
246
+ break;
247
+ }
248
+ }
249
+ color ??= DEVICE_COLORS[next % DEVICE_COLORS.length] ?? DEVICE_COLORS[0];
250
+ used.add(color);
251
+ update.run(color, row.device_id);
252
+ }
253
+ }
254
+ function ensureDeviceColorColumn(handle) {
255
+ const columns = tableColumns(handle, 'devices');
256
+ let added = false;
257
+ if (!columns.has('color')) {
258
+ handle.exec('ALTER TABLE devices ADD COLUMN color TEXT');
259
+ added = true;
260
+ }
261
+ backfillDeviceColors(handle);
262
+ return added;
263
+ }
264
+ function ensureSessionLifecycleColumns(handle) {
265
+ const columns = tableColumns(handle, 'sessions');
266
+ if (columns.has('purged_at')) return false;
267
+ handle.exec('ALTER TABLE sessions ADD COLUMN purged_at INTEGER');
268
+ return true;
269
+ }
270
+ function ensureSessionResetColumns(handle) {
271
+ if (!tableExists(handle, 'session_resets')) return false;
272
+ const columns = tableColumns(handle, 'session_resets');
273
+ if (columns.has('workspace_key')) return false;
274
+ handle.exec('ALTER TABLE session_resets ADD COLUMN workspace_key TEXT');
275
+ return true;
276
+ }
277
+ function ensureSessionEventColumns(handle) {
278
+ if (!tableExists(handle, 'session_events')) return false;
279
+ const columns = tableColumns(handle, 'session_events');
280
+ if (columns.has('actor_id')) return false;
281
+ handle.exec("ALTER TABLE session_events ADD COLUMN actor_id TEXT NOT NULL DEFAULT ''");
282
+ return true;
283
+ }
284
+ function backfillSessionEvents(handle) {
285
+ const marker = handle.prepare("SELECT value FROM meta WHERE key = 'session_events_backfill'").get();
286
+ if (marker !== undefined) return;
287
+ const existing = handle.prepare('SELECT COUNT(*) AS n FROM session_events').get();
288
+ if (Number(existing?.n ?? 0) > 0) {
289
+ handle.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_events_backfill', '1')").run();
290
+ return;
291
+ }
292
+ if (!tableExists(handle, 'append_requests')) return;
293
+ const rows = handle.prepare(`SELECT session_id, device_id, created_at, result_json
294
+ FROM append_requests WHERE dataset = ?
295
+ ORDER BY session_id ASC, created_at ASC, rowid ASC`).all(DEFAULT_DATASET);
296
+ const state = new Map();
297
+ const insert = handle.prepare(`INSERT INTO session_events
298
+ (dataset, session_id, at, kind, device_id, actor_kind, actor_id, events, bytes, seq_from, seq_to, batch_count, last_seq, stored_bytes, detail)
299
+ VALUES (?, ?, ?, ?, ?, 'device', ?, ?, ?, NULL, NULL, 1, ?, ?, ?)`);
300
+ for (const row of rows){
301
+ let summary;
302
+ try {
303
+ summary = JSON.parse(row.result_json);
304
+ } catch {
305
+ continue;
306
+ }
307
+ if (typeof summary.lastSeq !== 'number' || !Number.isSafeInteger(summary.lastSeq)) continue;
308
+ const storedBytes = typeof summary.storedBytes === 'number' && Number.isSafeInteger(summary.storedBytes) ? summary.storedBytes : 0;
309
+ const previous = state.get(row.session_id);
310
+ const oldSeq = previous?.seen === true ? previous.lastSeq : 0;
311
+ const oldBytes = previous?.seen === true ? previous.storedBytes : 0;
312
+ const events = Math.max(0, summary.lastSeq - oldSeq);
313
+ const bytes = Math.max(0, storedBytes - oldBytes);
314
+ insert.run(DEFAULT_DATASET, row.session_id, row.created_at, previous?.seen === true ? 'append' : 'create', row.device_id, row.device_id, events, bytes, summary.lastSeq, storedBytes, JSON.stringify({
315
+ backfilled: true
316
+ }));
317
+ state.set(row.session_id, {
318
+ lastSeq: summary.lastSeq,
319
+ storedBytes,
320
+ seen: true
321
+ });
322
+ }
323
+ handle.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_events_backfill', '1')").run();
324
+ }
164
325
  function migrateDatabase(handle, dataDir, version) {
165
326
  if (version > SCHEMA_VERSION) {
166
327
  throw new Error(`数据库结构版本 ${version} 高于服务端支持的 ${SCHEMA_VERSION};请升级服务端,不要降级覆盖。`);
167
328
  }
168
329
  if (version === SCHEMA_VERSION) {
169
330
  handle.exec(SCHEMA);
331
+ ensureDeviceColorColumn(handle);
332
+ ensureSessionResetColumns(handle);
333
+ ensureSessionEventColumns(handle);
334
+ backfillSessionEvents(handle);
170
335
  const columns = tableColumns(handle, 'sessions');
171
- if (!columns.has('compression') || !columns.has('stored_bytes')) {
336
+ if (!columns.has('compression') || !columns.has('stored_bytes') || !columns.has('purged_at')) {
172
337
  backupBeforeMigration(handle, dataDir, version);
173
338
  handle.exec('BEGIN IMMEDIATE');
174
339
  try {
175
340
  ensureSessionStatsColumns(handle);
341
+ ensureSessionLifecycleColumns(handle);
176
342
  handle.exec('COMMIT');
177
343
  } catch (error) {
178
344
  handle.exec('ROLLBACK');
@@ -198,7 +364,7 @@ function migrateDatabase(handle, dataDir, version) {
198
364
  }
199
365
  sourceVersion = 2;
200
366
  }
201
- if (sourceVersion !== 2 && sourceVersion !== 3) {
367
+ if (sourceVersion !== 2 && sourceVersion !== 3 && sourceVersion !== 4 && sourceVersion !== 5) {
202
368
  throw new Error(`不支持的数据库结构版本 ${sourceVersion};请使用对应版本的服务端迁移。`);
203
369
  }
204
370
  assertV2Sessions(handle);
@@ -211,6 +377,11 @@ function migrateDatabase(handle, dataDir, version) {
211
377
  }
212
378
  handle.exec(SCHEMA);
213
379
  ensureSessionStatsColumns(handle);
380
+ ensureDeviceColorColumn(handle);
381
+ ensureSessionLifecycleColumns(handle);
382
+ ensureSessionResetColumns(handle);
383
+ ensureSessionEventColumns(handle);
384
+ backfillSessionEvents(handle);
214
385
  handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
215
386
  handle.exec('COMMIT');
216
387
  } catch (error) {