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.
- package/CONTRIBUTING.md +1 -1
- package/README.md +25 -8
- package/SECURITY.md +14 -2
- package/bin/device-center.mjs +29 -5
- package/docs/agent-install.md +5 -5
- package/docs/cli.md +97 -0
- package/docs/driver-binding.md +5 -5
- package/docs/friend-trial.md +5 -5
- package/docs/hosted-access.md +1 -1
- package/docs/npm.md +12 -10
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +13 -1
- package/docs/security-and-sync.md +2 -0
- package/docs/troubleshooting.md +11 -0
- package/docs/windows-acceptance.md +57 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/access.mjs +0 -1
- package/scripts/driver.mjs +2 -3
- package/scripts/service.mjs +42 -16
- package/server/cli/commands.mjs +181 -0
- package/server/cloudAccess.mjs +1 -1
- package/server/drivers/access.mjs +15 -14
- package/server/drivers/browserPairing.mjs +9 -4
- package/server/drivers/cliClient.mjs +87 -0
- package/server/drivers/cliManagement.mjs +119 -0
- package/server/drivers/cloudLink.mjs +19 -20
- package/server/drivers/cloudRegistry.mjs +6 -0
- package/server/drivers/privateFiles.mjs +57 -0
- package/server/drivers/windowsSecurity.mjs +119 -0
- package/server/drivers/windowsService.mjs +77 -0
- package/server/index.mjs +36 -8
- package/server/unifiedAccess.mjs +24 -4
- package/src/api.js +2 -0
- package/src/cli-approval.js +49 -0
- package/src/cli-handoff.js +19 -0
- package/src/install-prompt.js +2 -2
- package/src/install-walkthrough.js +6 -6
- package/src/login-flow.js +37 -0
- package/src/login.css +3 -1
- package/src/login.js +15 -10
- package/src/main.js +7 -6
- package/src/messages.js +359 -117
- package/src/onboarding.js +35 -15
- package/src/pairing-handoff.js +6 -2
- package/src/start.js +10 -2
- package/src/styles.css +1 -0
- 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 {
|
|
4
|
-
import { createHash, createPrivateKey, createPublicKey
|
|
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(
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
+
}
|