device-center 0.2.0-beta.4 → 0.2.0-beta.6

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.
Files changed (50) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +25 -8
  3. package/SECURITY.md +14 -2
  4. package/bin/device-center.mjs +29 -5
  5. package/docs/agent-install.md +5 -5
  6. package/docs/cli.md +97 -0
  7. package/docs/driver-binding.md +5 -5
  8. package/docs/friend-trial.md +5 -5
  9. package/docs/hosted-access.md +1 -1
  10. package/docs/npm.md +12 -10
  11. package/docs/onboarding.md +4 -4
  12. package/docs/remote-access.md +2 -2
  13. package/docs/roadmap.md +13 -1
  14. package/docs/security-and-sync.md +2 -0
  15. package/docs/troubleshooting.md +11 -0
  16. package/docs/windows-acceptance.md +57 -0
  17. package/login.html +1 -1
  18. package/npm-shrinkwrap.json +2 -2
  19. package/package.json +4 -2
  20. package/scripts/access.mjs +0 -1
  21. package/scripts/driver.mjs +2 -3
  22. package/scripts/service.mjs +42 -16
  23. package/server/cli/commands.mjs +181 -0
  24. package/server/cloudAccess.mjs +1 -1
  25. package/server/drivers/access.mjs +15 -14
  26. package/server/drivers/browserPairing.mjs +9 -4
  27. package/server/drivers/cliClient.mjs +87 -0
  28. package/server/drivers/cliManagement.mjs +119 -0
  29. package/server/drivers/cloudLink.mjs +19 -20
  30. package/server/drivers/cloudRegistry.mjs +6 -0
  31. package/server/drivers/privateFiles.mjs +57 -0
  32. package/server/drivers/windowsSecurity.mjs +119 -0
  33. package/server/drivers/windowsService.mjs +77 -0
  34. package/server/index.mjs +36 -8
  35. package/server/unifiedAccess.mjs +24 -4
  36. package/src/api.js +2 -0
  37. package/src/cli-approval.js +49 -0
  38. package/src/cli-handoff.js +19 -0
  39. package/src/install-prompt.js +2 -2
  40. package/src/install-walkthrough.js +6 -6
  41. package/src/login-flow.js +37 -0
  42. package/src/login.css +3 -1
  43. package/src/login.js +15 -10
  44. package/src/main.js +7 -6
  45. package/src/messages.js +359 -117
  46. package/src/onboarding.js +35 -15
  47. package/src/pairing-handoff.js +6 -2
  48. package/src/start.js +10 -2
  49. package/src/styles.css +1 -0
  50. package/start.html +12 -4
@@ -0,0 +1,87 @@
1
+ import { defaultLinkFile, readLink } from './cloudLink.mjs';
2
+ import { machinePost } from './remote.mjs';
3
+
4
+ export function cliError(code, message, statusCode = 400) {
5
+ return Object.assign(new Error(message), { cliCode: code, statusCode });
6
+ }
7
+ export function localClient({ port = 5174, fetchImpl = fetch } = {}) {
8
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw cliError('invalid_arguments', '端口须为 1024–65535。');
9
+ const origin = `http://127.0.0.1:${port}`;
10
+ async function request(path, input) {
11
+ let response;
12
+ try { response = await fetchImpl(origin + path, { method: input === undefined ? 'GET' : 'POST',
13
+ headers: { origin, ...(input === undefined ? {} : { 'content-type': 'application/json' }) },
14
+ ...(input === undefined ? {} : { body: JSON.stringify(input) }), redirect: 'error', signal: AbortSignal.timeout(30_000) }); }
15
+ catch { throw cliError('local_driver_unavailable', '本机 Driver 未响应。先检查 device-center service status 或启动 device-center start。', 503); }
16
+ if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw cliError('invalid_driver_response', '本机端口未返回 Device Center JSON。', 502);
17
+ const chunks = []; let size = 0;
18
+ try { for await (const chunk of response.body) { size += chunk.length; if (size > 2_097_152) throw cliError('response_too_large', '本机响应过大。', 502); chunks.push(chunk); } }
19
+ catch (error) { if (error.cliCode) throw error; throw cliError('local_driver_unavailable', '本机响应中断。', 503); }
20
+ let data;
21
+ try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw cliError('invalid_driver_response', '本机响应格式无效。', 502); }
22
+ if (!response.ok) throw cliError(/^[a-z_]+$/.test(data?.error ?? '') ? data.error : 'local_request_failed', '本机请求失败。', response.status);
23
+ return data;
24
+ }
25
+ let ready = false;
26
+ async function local() {
27
+ if (ready) return;
28
+ const health = await request('/api/health');
29
+ if (health?.ok !== true || health.mode !== 'local') throw cliError('local_driver_required', '这个端口不是本机 Driver。', 409);
30
+ ready = true;
31
+ }
32
+ const get = path => async () => { await local(); return request(path); };
33
+ const post = path => async input => { await local(); return request(path, input); };
34
+ return { origin, health: get('/api/health'), overview: get('/api/overview'), link: get('/api/driver/link'),
35
+ setup: get('/api/connector/setup'), connector: get('/api/connector/status'),
36
+ candidates: get('/api/ssh/initialization'), collect: post('/api/devices/collect'), profile: post('/api/devices/profile'),
37
+ importSsh: post('/api/ssh/initialize'), checkSsh: post('/api/devices/ssh/check'), removeSsh: post('/api/devices/ssh/remove'),
38
+ async mcp(method, params) { await local();
39
+ // MCP has its own Accept contract. No arbitrary URL or JSON-RPC method is exposed.
40
+ if (!['tools/list', 'tools/call'].includes(method)) throw cliError('invalid_arguments', '不支持的 MCP 方法。');
41
+ let response;
42
+ try { response = await fetchImpl(origin + '/mcp', { method: 'POST', headers: { origin, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
43
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), redirect: 'error', signal: AbortSignal.timeout(30_000) }); }
44
+ catch { throw cliError('mcp_unavailable', 'MCP 未响应。', 503); }
45
+ if (!response.ok) throw cliError('mcp_unavailable', 'MCP 未响应。', response.status);
46
+ if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw cliError('invalid_driver_response', 'MCP 响应格式无效。', 502);
47
+ const chunks = []; let size = 0;
48
+ try { for await (const chunk of response.body) { size += chunk.length; if (size > 2_097_152) throw cliError('response_too_large', 'MCP 响应过大。', 502); chunks.push(chunk); } }
49
+ catch (error) { if (error.cliCode) throw error; throw cliError('mcp_unavailable', 'MCP 响应中断。', 503); }
50
+ let value;
51
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw cliError('invalid_driver_response', 'MCP 响应格式无效。', 502); }
52
+ if (value.error || value.result?.isError) throw cliError('mcp_tool_failed', '工具未完成;检查参数、本机信任与目标日志授权。', 409);
53
+ return value.result;
54
+ },
55
+ };
56
+ }
57
+
58
+ export function cloudClient({ file = defaultLinkFile(), fetchImpl, read = readLink } = {}) {
59
+ const state = read(file);
60
+ if (!state?.driverId || state.disabled) throw cliError('device_binding_required', '先运行 connect 绑定这台管理电脑,再 auth login 单独授权 CLI。', 403);
61
+ const call = async (action, input = {}) => {
62
+ try { return await machinePost(state, `cli/${action}`, input, fetchImpl); }
63
+ catch (error) {
64
+ if (Number.isInteger(error.statusCode)) throw error;
65
+ throw cliError('cloud_driver_unavailable', '已绑定的控制面未响应有效数据;检查网络和控制面版本。', 503);
66
+ }
67
+ };
68
+ return { origin: state.origin, fingerprint: state.fingerprint, call,
69
+ async devices() {
70
+ const devices = []; let offset = 0, total;
71
+ for (let page = 0; page < 26; page++) {
72
+ const result = await call('list', { offset, limit: 10 });
73
+ if (!Array.isArray(result.devices) || result.devices.length > 10 || !Number.isInteger(result.total) || result.total > 256 ||
74
+ (result.nextOffset !== null && result.nextOffset !== offset + 10)) throw cliError('invalid_cloud_response', '云端分页响应无效。', 502);
75
+ total ??= result.total;
76
+ if (total !== result.total) throw cliError('inventory_changed', '设备清单在分页期间变化,请重新 list。', 409);
77
+ devices.push(...result.devices);
78
+ if (result.nextOffset === null) {
79
+ if (devices.length !== total || new Set(devices.map(d => d.id)).size !== total) throw cliError('inventory_changed', '设备清单在分页期间变化,请重新 list。', 409);
80
+ return { devices, total };
81
+ }
82
+ offset = result.nextOffset;
83
+ }
84
+ throw cliError('invalid_cloud_response', '云端分页超出上限。', 502);
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,119 @@
1
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
+ import { fail, object } from './driverProtocol.mjs';
3
+
4
+ export const CLI_SCOPES = Object.freeze(['devices:read', 'devices:profile', 'devices:collect']);
5
+ const hash = value => createHash('sha256').update(value).digest('hex');
6
+ const AGE = 300_000;
7
+ // A bound telemetry identity is not an account management credential. A human owner
8
+ // grants this separate, short-lived capability; every call still proves the device key.
9
+ export function createCliManagement({ db, now = Date.now, account }) {
10
+ db.exec(`CREATE TABLE IF NOT EXISTS cli_requests (
11
+ id TEXT PRIMARY KEY, driver_id TEXT NOT NULL, ticket_hash TEXT UNIQUE NOT NULL,
12
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, minutes INTEGER NOT NULL,
13
+ state TEXT NOT NULL DEFAULT 'pending');
14
+ CREATE TABLE IF NOT EXISTS cli_grants (
15
+ driver_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
16
+ revoked_at INTEGER, last_used_at INTEGER)`);
17
+ const descriptor = row => ({ driverId: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint });
18
+ const status = row => {
19
+ const grant = db.prepare('SELECT * FROM cli_grants WHERE driver_id=?').get(row.id);
20
+ return { authorized: Boolean(grant && grant.revoked_at == null && grant.expires_at > now()),
21
+ scopes: [...CLI_SCOPES], expiresAt: grant?.expires_at ?? null, revokedAt: grant?.revoked_at ?? null,
22
+ lastUsedAt: grant?.last_used_at ?? null, ...descriptor(row) };
23
+ };
24
+ function pending(input, tenantId) {
25
+ if (typeof input.ticket !== 'string' || !/^[a-f0-9]{64}$/.test(input.ticket)) fail('cli_request_unavailable', 404);
26
+ const request = db.prepare(`SELECT r.*, d.tenant_id, d.name, d.platform, d.fingerprint, d.revoked_at
27
+ FROM cli_requests r JOIN cloud_drivers d ON d.id=r.driver_id WHERE r.ticket_hash=?`).get(hash(input.ticket));
28
+ if (!request || request.tenant_id !== tenantId || request.revoked_at != null || request.expires_at <= now() || request.state !== 'pending') fail('cli_request_unavailable', 404);
29
+ return request;
30
+ }
31
+ return {
32
+ owner(tenantId) {
33
+ return {
34
+ lookup(input) {
35
+ object(input, ['ticket']); const row = pending(input, tenantId);
36
+ return { id: row.id, driverId: row.driver_id, name: row.name, platform: row.platform, fingerprint: row.fingerprint,
37
+ scopes: [...CLI_SCOPES], minutes: row.minutes, expiresAt: row.expires_at };
38
+ },
39
+ approve(input) {
40
+ object(input, ['ticket', 'fingerprint']); const row = pending(input, tenantId);
41
+ if (input.fingerprint !== row.fingerprint) fail('cli_confirmation_required', 400);
42
+ const expiresAt = now() + row.minutes * 60_000;
43
+ db.exec('BEGIN IMMEDIATE');
44
+ try {
45
+ db.prepare("UPDATE cli_requests SET state='approved' WHERE id=? AND state='pending'").run(row.id);
46
+ db.prepare(`INSERT INTO cli_grants VALUES (?,?,?,NULL,NULL) ON CONFLICT(driver_id)
47
+ DO UPDATE SET created_at=excluded.created_at, expires_at=excluded.expires_at, revoked_at=NULL, last_used_at=NULL`).run(row.driver_id, now(), expiresAt);
48
+ db.exec('COMMIT');
49
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
50
+ return { authorized: true, scopes: [...CLI_SCOPES], expiresAt };
51
+ },
52
+ deny(input) {
53
+ object(input, ['ticket']); const row = pending(input, tenantId);
54
+ db.prepare("UPDATE cli_requests SET state='denied' WHERE id=?").run(row.id);
55
+ return { ok: true };
56
+ },
57
+ grants() {
58
+ return { grants: db.prepare(`SELECT d.* FROM cloud_drivers d JOIN cli_grants g ON g.driver_id=d.id
59
+ WHERE d.tenant_id=? AND d.revoked_at IS NULL ORDER BY g.created_at DESC`).all(tenantId).map(status) };
60
+ },
61
+ revoke(input) {
62
+ object(input, ['driverId']);
63
+ if (typeof input.driverId !== 'string' || !db.prepare('SELECT 1 FROM cloud_drivers WHERE id=? AND tenant_id=?').get(input.driverId, tenantId)) fail('driver_unknown', 404);
64
+ db.prepare('UPDATE cli_grants SET revoked_at=COALESCE(revoked_at,?) WHERE driver_id=?').run(now(), input.driverId);
65
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(input.driverId);
66
+ return { ok: true };
67
+ },
68
+ };
69
+ },
70
+ machine(action, row, input) {
71
+ if (action === 'status') { object(input, []); return status(row); }
72
+ if (action === 'start') {
73
+ object(input, ['minutes']); const minutes = input.minutes ?? 480;
74
+ if (!Number.isInteger(minutes) || minutes < 5 || minutes > 480) fail('invalid_cli_duration');
75
+ // Only expired protocol handoffs are discarded, never account/device data.
76
+ db.prepare('DELETE FROM cli_requests WHERE expires_at<=?').run(now());
77
+ if (db.prepare('SELECT count(*) AS n FROM cli_requests').get().n >= 128 ||
78
+ db.prepare('SELECT count(*) AS n FROM cli_requests WHERE driver_id=? AND created_at>?').get(row.id, now() - 60_000).n >= 3) fail('cli_rate_limited', 429);
79
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(row.id);
80
+ const id = randomUUID(), ticket = randomBytes(32).toString('hex');
81
+ db.prepare('INSERT INTO cli_requests (id,driver_id,ticket_hash,created_at,expires_at,minutes) VALUES (?,?,?,?,?,?)').run(id, row.id, hash(ticket), now(), now() + AGE, minutes);
82
+ return { id, ticket, expiresAt: now() + AGE, fingerprint: row.fingerprint, scopes: [...CLI_SCOPES], minutes };
83
+ }
84
+ if (action === 'poll' || action === 'cancel') {
85
+ object(input, ['id']);
86
+ if (typeof input.id !== 'string') fail('cli_request_unavailable', 404);
87
+ const request = db.prepare('SELECT * FROM cli_requests WHERE id=? AND driver_id=?').get(input.id, row.id);
88
+ if (!request || request.expires_at <= now()) fail('cli_request_expired', 410);
89
+ if (action === 'cancel') db.prepare("UPDATE cli_requests SET state='cancelled' WHERE id=? AND state='pending'").run(request.id);
90
+ return { state: action === 'cancel' && request.state === 'pending' ? 'cancelled' : request.state, ...status(row) };
91
+ }
92
+ if (action === 'logout') {
93
+ object(input, []);
94
+ db.prepare('UPDATE cli_grants SET revoked_at=COALESCE(revoked_at,?) WHERE driver_id=?').run(now(), row.id);
95
+ db.prepare("UPDATE cli_requests SET state='cancelled' WHERE driver_id=? AND state='pending'").run(row.id);
96
+ return { authorized: false, deviceBindingRetained: true };
97
+ }
98
+ if (!status(row).authorized) fail('cli_authorization_required', 403);
99
+ const owner = account(row.tenant_id);
100
+ let result;
101
+ if (action === 'list') {
102
+ object(input, ['offset', 'limit']); const offset = input.offset ?? 0, limit = input.limit ?? 10;
103
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 256 || !Number.isInteger(limit) || limit < 1 || limit > 10) fail('invalid_cli_page');
104
+ const devices = owner.devices();
105
+ result = { devices: devices.slice(offset, offset + limit), total: devices.length, nextOffset: offset + limit < devices.length ? offset + limit : null };
106
+ } else if (action === 'show') {
107
+ object(input, ['deviceId']);
108
+ if (typeof input.deviceId !== 'string') fail('invalid_device_id');
109
+ const device = owner.devices().find(d => d.id === input.deviceId);
110
+ if (!device) fail('device_not_found', 404);
111
+ result = { device };
112
+ } else if (action === 'profile') result = owner.saveProfile(input);
113
+ else if (action === 'collect') result = owner.collect(input);
114
+ else fail('cli_action_unavailable', 404);
115
+ db.prepare('UPDATE cli_grants SET last_used_at=? WHERE driver_id=?').run(now(), row.id);
116
+ return result;
117
+ },
118
+ };
119
+ }
@@ -1,38 +1,37 @@
1
- import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
2
1
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
- import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto';
5
4
  import { cloudOrigin, signedRequest, publicIdentity } from './driverProtocol.mjs';
6
5
  import { observeLocalDriver } from './local.mjs';
7
6
  import { validateCertificate } from './certificates.mjs';
7
+ import { readPrivateJson, writePrivateJson, defaultDataDirectory } from './privateFiles.mjs';
8
+ import { protectWindowsIdentity, unprotectWindowsIdentity } from './windowsSecurity.mjs';
9
+ export { ensurePrivateDirectory, defaultDataDirectory } from './privateFiles.mjs';
8
10
 
9
- export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : join(home, '.local', 'share', 'device-center'), 'driver-link.json');
11
+ export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(defaultDataDirectory(home, platform), 'driver-link.json');
12
+ const decrypted = new Map();
10
13
  export function readLink(file) {
11
- let fd;
12
14
  try {
13
- fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
14
- const info = fstatSync(fd);
15
- if (!info.isFile() || info.nlink !== 1 || info.size > 8192 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_file_required');
16
- const state = JSON.parse(readFileSync(fd, 'utf8'));
15
+ const stored = readPrivateJson(file, null, process.platform === 'win32' ? 32768 : 8192);
16
+ if (stored === null) return null;
17
+ let state = stored;
18
+ if (process.platform === 'win32') {
19
+ const cached = decrypted.get(file);
20
+ state = cached?.data === stored.data ? structuredClone(cached.state) : unprotectWindowsIdentity(stored);
21
+ if (stored.format !== 'device-center-dpapi-v1') throw new Error('windows_encrypted_identity_required');
22
+ if (decrypted.size > 8) decrypted.clear();
23
+ decrypted.set(file, { data: stored.data, state: structuredClone(state) });
24
+ }
17
25
  cloudOrigin(state.origin);
18
26
  const key = createPrivateKey(state.privateKey), identity = publicIdentity(state.publicKey);
19
27
  if (key.asymmetricKeyType !== 'ed25519' || createPublicKey(key).export({ type: 'spki', format: 'pem' }).toString() !== identity.publicKey || identity.fingerprint !== state.fingerprint || typeof state.shareSsh !== 'boolean' || typeof state.disabled !== 'boolean' || (state.driverId != null && !/^[a-f0-9-]{36}$/.test(state.driverId))) throw new Error('driver_private_file_invalid');
20
28
  if (state.certificate) validateCertificate(state.certificate, state.fingerprint, Date.now(), true);
21
29
  return state;
22
30
  } catch (error) { if (error.code === 'ENOENT') return null; throw new Error('driver_private_file_invalid'); }
23
- finally { if (fd !== undefined) closeSync(fd); }
24
- }
25
- export function ensurePrivateDirectory(directory) {
26
- mkdirSync(directory, { recursive: true, mode: 0o700 });
27
- const info = lstatSync(directory);
28
- if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
29
31
  }
30
32
  export function writeLink(file, state, { create = false } = {}) {
31
- ensurePrivateDirectory(dirname(file));
32
- if (create) { writeFileSync(file, JSON.stringify(state), { mode: 0o600, flag: 'wx' }); return; }
33
- const temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
34
- writeFileSync(temp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
35
- renameSync(temp, file);
33
+ writePrivateJson(file, process.platform === 'win32' ? protectWindowsIdentity(state) : state, { create });
34
+ decrypted.delete(file);
36
35
  }
37
36
  export function linkSummary(state) {
38
37
  return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, secureTransportReady: Boolean(state?.certificate), shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
@@ -3,6 +3,7 @@ import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } fr
3
3
  import { validateCertificate } from './certificates.mjs';
4
4
  import { createRelay } from './relay.mjs';
5
5
  import { createDeviceProfiles } from './deviceProfiles.mjs';
6
+ import { createCliManagement } from './cliManagement.mjs';
6
7
 
7
8
  const PAIR_AGE = 5 * 60_000;
8
9
  export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
@@ -197,10 +198,14 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
197
198
  });
198
199
  },
199
200
  };
201
+ const management = createCliManagement({ db, now, account: tenantId => ({
202
+ devices: () => registry.devices(tenantId), saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
203
+ }) });
200
204
  return {
201
205
  start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
202
206
  machine(action, input) {
203
207
  const row = driver(input, `/api/driver/${action}`);
208
+ if (action.startsWith('cli/')) return management.machine(action.slice(4), row, input.payload);
204
209
  if (action === 'control') {
205
210
  object(input.payload, []);
206
211
  if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
@@ -220,6 +225,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
220
225
  lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
221
226
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
222
227
  saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
228
+ cli: management.owner(tenantId),
223
229
  };
224
230
  },
225
231
  };
@@ -0,0 +1,57 @@
1
+ import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { dirname, join, win32 } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { windowsStorage } from './windowsSecurity.mjs';
6
+
7
+ export const defaultDataDirectory = (home = homedir(), platform = process.platform) => platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : platform === 'win32' ? win32.join(home, 'AppData', 'Local', 'Device Center') : join(home, '.local', 'share', 'device-center');
8
+
9
+ // Recheck Windows ACLs at least every 30s, or immediately when file metadata changes.
10
+ // This avoids launching PowerShell on every 5s control poll. No private data is cached here.
11
+ const checked = new Map();
12
+ export function secureCreatedPrivateFile(file) {
13
+ if (process.platform === 'win32') windowsStorage('secure-new-file', { path: file });
14
+ }
15
+ export function ensurePrivateDirectory(directory) {
16
+ if (process.platform === 'win32') { windowsStorage('ensure-directory', { path: directory }); return; }
17
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
18
+ const info = lstatSync(directory);
19
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
20
+ }
21
+ export function assertPrivateFile(file, info, limit = 65536) {
22
+ const pathInfo = lstatSync(file);
23
+ if (!info.isFile() || info.nlink !== 1 || info.size > limit || pathInfo.isSymbolicLink() || info.dev !== pathInfo.dev || info.ino !== pathInfo.ino) throw new Error('driver_private_file_required');
24
+ if (process.platform !== 'win32') {
25
+ if ((info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_file_required');
26
+ return;
27
+ }
28
+ const signature = `${info.dev}:${info.ino}:${info.ctimeMs}:${info.mtimeMs}:${info.size}`;
29
+ const previous = checked.get(file);
30
+ if (!previous || previous.signature !== signature || Date.now() - previous.at >= 30_000) {
31
+ windowsStorage('assert-file', { path: file });
32
+ if (checked.size > 32) checked.clear();
33
+ checked.set(file, { signature, at: Date.now() });
34
+ }
35
+ }
36
+ export function readPrivateJson(file, fallback, limit = 65536) {
37
+ let fd;
38
+ try {
39
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
40
+ assertPrivateFile(file, fstatSync(fd), limit);
41
+ return JSON.parse(readFileSync(fd, 'utf8'));
42
+ } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
43
+ finally { if (fd !== undefined) closeSync(fd); }
44
+ }
45
+ export function writePrivateJson(file, value, { create = false } = {}) {
46
+ ensurePrivateDirectory(dirname(file));
47
+ const target = create ? file : `${file}.${randomBytes(6).toString('hex')}.tmp`;
48
+ writeFileSync(target, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
49
+ // Windows files inherit the protected directory ACL. Validate before activation.
50
+ if (process.platform === 'win32') {
51
+ secureCreatedPrivateFile(target);
52
+ const fd = openSync(target, constants.O_RDONLY);
53
+ try { assertPrivateFile(target, fstatSync(fd)); } finally { closeSync(fd); }
54
+ }
55
+ if (!create) renameSync(target, file);
56
+ checked.delete(file);
57
+ }
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ // Fixed PowerShell code; paths and secrets travel only on stdin, never in arguments.
5
+ // No execution-policy change, elevation, password, external download or shell expansion.
6
+ export const powershellPath = (environment = process.env) => {
7
+ const systemRoot = environment.SystemRoot;
8
+ if (!systemRoot || !win32.isAbsolute(systemRoot) || /[\r\n\0]/.test(systemRoot)) throw new Error('windows_system_root_required');
9
+ return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
10
+ };
11
+ export const powershellArguments = source => ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')];
12
+ export function runPowerShell(source, input, { spawnImpl = spawnSync, environment = process.env } = {}) {
13
+ const result = spawnImpl(powershellPath(environment), powershellArguments(source), {
14
+ input: JSON.stringify(input), encoding: 'utf8', windowsHide: true, shell: false, timeout: 15_000, maxBuffer: 512 * 1024,
15
+ });
16
+ // PowerShell diagnostics can contain input values. Never relay them to the caller.
17
+ if (result.error || result.status !== 0) throw new Error('windows_security_operation_failed');
18
+ try { return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); }
19
+ catch { throw new Error('windows_security_response_invalid'); }
20
+ }
21
+ export const powershellPreamble = `
22
+ $ErrorActionPreference = 'Stop'
23
+ # A Node child launched from PowerShell 7 inherits its module search path.
24
+ # Windows PowerShell 5.1 must load its own built-in modules (including Set-Acl),
25
+ # not incompatible PS7 or user-installed modules. This affects only this child.
26
+ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
27
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
28
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
29
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
30
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
31
+ `;
32
+ export const windowsStorageSource = powershellPreamble + `
33
+ function Assert-PlainPath([string] $path) {
34
+ $cursor = [IO.Path]::GetFullPath($path)
35
+ while ($cursor) {
36
+ if ([IO.File]::Exists($cursor) -or [IO.Directory]::Exists($cursor)) {
37
+ $item = Get-Item -LiteralPath $cursor -Force
38
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse_point' }
39
+ }
40
+ $parent = [IO.Path]::GetDirectoryName($cursor)
41
+ if ($parent -eq $cursor) { break }
42
+ $cursor = $parent
43
+ }
44
+ }
45
+ function Assert-Private([string] $path) {
46
+ Assert-PlainPath $path
47
+ $acl = Get-Acl -LiteralPath $path
48
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner_required' }
49
+ $currentAllowed = $false
50
+ foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
51
+ if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { throw 'deny_rule' }
52
+ if ($rule.IdentityReference.Value -notin @($sid.Value, 'S-1-5-18')) { throw 'private_acl_required' }
53
+ if ($rule.IdentityReference.Value -eq $sid.Value -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $currentAllowed = $true }
54
+ }
55
+ if (-not $currentAllowed) { throw 'current_user_access_required' }
56
+ }
57
+ switch ($request.operation) {
58
+ 'ensure-directory' {
59
+ Assert-PlainPath $request.path
60
+ if (-not [IO.Directory]::Exists($request.path)) {
61
+ $directory = [IO.Directory]::CreateDirectory($request.path)
62
+ $acl = New-Object System.Security.AccessControl.DirectorySecurity
63
+ $acl.SetOwner($sid)
64
+ $acl.SetAccessRuleProtection($true, $false)
65
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
66
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
67
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')
68
+ $acl.AddAccessRule($rule)
69
+ }
70
+ Set-Acl -LiteralPath $directory.FullName -AclObject $acl
71
+ }
72
+ Assert-Private $request.path
73
+ @{ ok = $true } | ConvertTo-Json -Compress
74
+ }
75
+ 'assert-file' {
76
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
77
+ Assert-Private $request.path
78
+ @{ ok = $true } | ConvertTo-Json -Compress
79
+ }
80
+ 'secure-new-file' {
81
+ # Called only after our exclusive wx creation in an already protected directory.
82
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
83
+ Assert-PlainPath $request.path
84
+ $acl = [System.Security.AccessControl.FileSecurity]::new()
85
+ $acl.SetOwner($sid)
86
+ $acl.SetAccessRuleProtection($true, $false)
87
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
88
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
89
+ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow'))
90
+ }
91
+ Set-Acl -LiteralPath $request.path -AclObject $acl
92
+ Assert-Private $request.path
93
+ @{ ok = $true } | ConvertTo-Json -Compress
94
+ }
95
+ 'protect' {
96
+ Add-Type -AssemblyName System.Security
97
+ $data = [Convert]::FromBase64String($request.data)
98
+ $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
99
+ @{ data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
100
+ [Array]::Clear($data, 0, $data.Length)
101
+ }
102
+ 'unprotect' {
103
+ Add-Type -AssemblyName System.Security
104
+ $data = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($request.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
105
+ @{ data = [Convert]::ToBase64String($data) } | ConvertTo-Json -Compress
106
+ [Array]::Clear($data, 0, $data.Length)
107
+ }
108
+ default { throw 'unknown_storage_operation' }
109
+ }
110
+ `;
111
+ export function windowsStorage(operation, values) {
112
+ if (process.platform !== 'win32') throw new Error('windows_only');
113
+ return runPowerShell(windowsStorageSource, { operation, ...values });
114
+ }
115
+ export const protectWindowsIdentity = value => ({ format: 'device-center-dpapi-v1', data: windowsStorage('protect', { data: Buffer.from(JSON.stringify(value)).toString('base64') }).data });
116
+ export function unprotectWindowsIdentity(value) {
117
+ if (value?.format !== 'device-center-dpapi-v1' || typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('windows_encrypted_identity_required');
118
+ return JSON.parse(Buffer.from(windowsStorage('unprotect', { data: value.data }).data, 'base64').toString('utf8'));
119
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { powershellArguments, powershellPath, powershellPreamble, runPowerShell } from './windowsSecurity.mjs';
3
+
4
+ const literal = value => `'${String(value).replaceAll("'", "''")}'`;
5
+ export function windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath, errorLogPath, environment = process.env }) {
6
+ const launcher = `$ErrorActionPreference = 'Stop'\n$env:PORT = '${port}'\n$env:DEVICE_CENTER_DB_PATH = ${literal(dbPath)}\n$env:DEVICE_CENTER_SERVICE_MANAGER = 'windows-task'\n$env:DEVICE_CENTER_RUNTIME = 'native'\n$env:DEVICE_CENTER_PUBLIC_ORIGIN = $null\n$env:DEVICE_CENTER_AUTH_FILE = $null\nSet-Location -LiteralPath ${literal(root)}\n# Windows PowerShell 5.1 treats redirected native stderr as ErrorRecord.\n# Keep Node warnings visible in the log; use its exit code for task failure.\n$ErrorActionPreference = 'Continue'\n$LASTEXITCODE = 1\n& ${literal(node)} ${literal(script)} >> ${literal(logPath)} 2>> ${literal(errorLogPath)}\n$exitCode = $LASTEXITCODE\n$ErrorActionPreference = 'Stop'\nexit $exitCode\n`;
7
+ return {
8
+ taskName: `DeviceCenter-${createHash('sha256').update(home.toLowerCase()).digest('hex').slice(0, 16)}`,
9
+ executable: powershellPath(environment),
10
+ arguments: ['-WindowStyle', 'Hidden', ...powershellArguments(launcher)].join(' '),
11
+ workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
12
+ };
13
+ }
14
+ // COM Task Scheduler is built into Windows. Only this user's exact task is touched.
15
+ export const windowsTaskSource = powershellPreamble + `
16
+ $scheduler = New-Object -ComObject 'Schedule.Service'
17
+ $scheduler.Connect()
18
+ $folder = $scheduler.GetFolder('\\')
19
+ $task = $null
20
+ try { $task = $folder.GetTask($request.task.taskName) } catch {
21
+ $exception = $_.Exception
22
+ while ($exception.InnerException) { $exception = $exception.InnerException }
23
+ if ($exception.HResult -ne -2147024894) { throw }
24
+ }
25
+ function Assert-OurTask {
26
+ if (-not $task) { throw 'task_missing' }
27
+ $definition = $task.Definition
28
+ $principal = $definition.Principal
29
+ $principalSid = $principal.UserId
30
+ if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
31
+ if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
32
+ if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
33
+ $action = $definition.Actions.Item(1)
34
+ $trigger = $definition.Triggers.Item(1)
35
+ if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
36
+ }
37
+ switch ($request.operation) {
38
+ 'install' {
39
+ if ($task) { throw 'task_already_exists' }
40
+ $definition = $scheduler.NewTask(0)
41
+ $definition.RegistrationInfo.Description = 'Device Center: current user local Driver; login startup only.'
42
+ $definition.Principal.UserId = $sid.Value
43
+ $definition.Principal.LogonType = 3
44
+ $definition.Principal.RunLevel = 0
45
+ $trigger = $definition.Triggers.Create(9)
46
+ $trigger.UserId = $sid.Value
47
+ $action = $definition.Actions.Create(0)
48
+ $action.Path = $request.task.executable
49
+ $action.Arguments = $request.task.arguments
50
+ $action.WorkingDirectory = $request.task.workingDirectory
51
+ $definition.Settings.Enabled = $true
52
+ $definition.Settings.AllowDemandStart = $true
53
+ $definition.Settings.StartWhenAvailable = $true
54
+ $definition.Settings.DisallowStartIfOnBatteries = $false
55
+ $definition.Settings.StopIfGoingOnBatteries = $false
56
+ $definition.Settings.ExecutionTimeLimit = 'PT0S'
57
+ $definition.Settings.MultipleInstances = 2
58
+ $definition.Settings.RestartInterval = 'PT1M'
59
+ $definition.Settings.RestartCount = 3
60
+ $task = $folder.RegisterTaskDefinition($request.task.taskName, $definition, 2, $sid.Value, $null, 3)
61
+ $null = $task.Run($null)
62
+ @{ registered = $true } | ConvertTo-Json -Compress
63
+ }
64
+ 'status' {
65
+ if ($task) { Assert-OurTask }
66
+ @{ exists = [bool]$task; state = $(if ($task) { [int]$task.State } else { $null }) } | ConvertTo-Json -Compress
67
+ }
68
+ 'stop' { Assert-OurTask; $task.Stop(0); @{ stopped = $true } | ConvertTo-Json -Compress }
69
+ 'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
70
+ default { throw 'unknown_task_operation' }
71
+ }
72
+ `;
73
+ export function windowsTask(operation, task) {
74
+ if (process.platform !== 'win32') throw new Error('windows_only');
75
+ try { return runPowerShell(windowsTaskSource, { operation, task }); }
76
+ catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
77
+ }