local-agent-dashboard 0.3.3 → 0.3.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 +8 -8
- package/package.json +1 -1
- package/src/application/pairing.js +7 -3
- package/src/composition/main.js +23 -8
- package/src/infrastructure/pairing-store.js +116 -0
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ brew install openssl@3
|
|
|
21
21
|
在终端运行:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
npx local-agent-dashboard@0.3.
|
|
24
|
+
npx local-agent-dashboard@0.3.5
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
第一次运行时,npx 会询问是否下载,输入 `y` 确认即可。启动后会自动打开浏览器,显示这台电脑最近的任务。
|
|
@@ -30,7 +30,7 @@ npx local-agent-dashboard@0.3.3
|
|
|
30
30
|
|
|
31
31
|
## 连接 Android 手机
|
|
32
32
|
|
|
33
|
-
1. 从 [GitHub 下载 0.3.
|
|
33
|
+
1. 从 [GitHub 下载 0.3.5 的 Android 安装包](https://github.com/jiahim/local-agent-dashboard/releases/tag/v0.3.5),在手机上安装。请选择 `.apk` 文件;无需安装 Expo Go。
|
|
34
34
|
2. 让手机和电脑连接同一个 Wi-Fi,或处于可以互相访问的局域网。
|
|
35
35
|
3. 在电脑页面点击 **连接手机**,确认网络地址,再点击 **应用所选地址**。默认会选中所有可用地址,也可以用全选、反选来调整。
|
|
36
36
|
4. 点击 **生成配对邀请**。打开手机 App,在 **设置 → 电脑连接** 中扫码;也可以把配对链接发送到手机,点击链接打开 App。
|
|
@@ -44,15 +44,15 @@ npx local-agent-dashboard@0.3.3
|
|
|
44
44
|
|
|
45
45
|
| 用途 | 命令 |
|
|
46
46
|
| --- | --- |
|
|
47
|
-
| 检查运行环境,排查启动问题 | `npx local-agent-dashboard@0.3.
|
|
48
|
-
| 用示例任务体验界面 | `npx local-agent-dashboard@0.3.
|
|
49
|
-
| 启动时不自动打开浏览器 | `npx local-agent-dashboard@0.3.
|
|
50
|
-
| 查看全部参数 | `npx local-agent-dashboard@0.3.
|
|
47
|
+
| 检查运行环境,排查启动问题 | `npx local-agent-dashboard@0.3.5 --doctor` |
|
|
48
|
+
| 用示例任务体验界面 | `npx local-agent-dashboard@0.3.5 --demo` |
|
|
49
|
+
| 启动时不自动打开浏览器 | `npx local-agent-dashboard@0.3.5 --no-open` |
|
|
50
|
+
| 查看全部参数 | `npx local-agent-dashboard@0.3.5 --help` |
|
|
51
51
|
|
|
52
52
|
如果下载的是 Release 中的 `.tgz` 文件,在文件所在目录运行:
|
|
53
53
|
|
|
54
54
|
```sh
|
|
55
|
-
npx --package ./local-agent-dashboard-0.3.
|
|
55
|
+
npx --package ./local-agent-dashboard-0.3.5.tgz local-agent-dashboard
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
## 数据保存在哪里?
|
|
@@ -67,7 +67,7 @@ npx --package ./local-agent-dashboard-0.3.3.tgz local-agent-dashboard
|
|
|
67
67
|
|
|
68
68
|
也可以用 `--state-dir <目录>` 指定已有数据目录。同一个目录不要同时运行两个服务。通过源码启动时使用的 `.runtime` 目录不会自动迁移。
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
已批准手机的授权和已启用的网络地址保存在同一用户数据目录。服务重启或更新 npm 版本后会恢复;撤销授权也会保存。旧版本已经丢失的授权需重新配对一次,之后可保留。
|
|
71
71
|
|
|
72
72
|
## 隐私与使用范围
|
|
73
73
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,9 @@ export class Pairing {
|
|
|
14
14
|
revokeListeners = new Set();
|
|
15
15
|
now;
|
|
16
16
|
onRevoke;
|
|
17
|
-
|
|
17
|
+
store;
|
|
18
|
+
constructor(now = Date.now, onRevoke = () => { }, store) { this.now = now; this.onRevoke = onRevoke; this.store = store; for (const d of store?.load() ?? [])
|
|
19
|
+
this.devices.set(d.id, d); }
|
|
18
20
|
createInvite() {
|
|
19
21
|
this.requests.clear();
|
|
20
22
|
return this.invite = { secret: secret(), expiresAt: this.now() + 120_000, consumed: false };
|
|
@@ -57,7 +59,9 @@ export class Pairing {
|
|
|
57
59
|
if (this.devices.size >= 64)
|
|
58
60
|
throw new ProtocolError(429, 'device_limit');
|
|
59
61
|
const token = secret(), deviceId = randomUUID();
|
|
60
|
-
|
|
62
|
+
const device = { id: deviceId, name: r.name.trim(), tokenHash: hash(token), createdAt: this.now(), revoked: false };
|
|
63
|
+
this.store?.save([...this.devices.values(), device]);
|
|
64
|
+
this.devices.set(deviceId, device);
|
|
61
65
|
r.state = 'claimed';
|
|
62
66
|
this.invite.consumed = true;
|
|
63
67
|
return { state: 'paired', deviceId, token };
|
|
@@ -70,7 +74,7 @@ export class Pairing {
|
|
|
70
74
|
}
|
|
71
75
|
subscribeRevoke(listener) { this.revokeListeners.add(listener); return () => this.revokeListeners.delete(listener); }
|
|
72
76
|
revoke(id) { const d = this.devices.get(id); if (!d)
|
|
73
|
-
throw new ProtocolError(404, 'unknown_device'); d.revoked = true; this.onRevoke(id); for (const listener of this.revokeListeners)
|
|
77
|
+
throw new ProtocolError(404, 'unknown_device'); this.store?.save([...this.devices.values()].map(item => item.id === id ? { ...item, revoked: true } : item)); d.revoked = true; this.onRevoke(id); for (const listener of this.revokeListeners)
|
|
74
78
|
listener(id); }
|
|
75
79
|
ack(d, instanceId, revision) {
|
|
76
80
|
if (d.revoked)
|
package/src/composition/main.js
CHANGED
|
@@ -2,10 +2,11 @@ import { networkInterfaces, hostname } from 'node:os';
|
|
|
2
2
|
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
-
import { X509Certificate } from 'node:crypto';
|
|
5
|
+
import { X509Certificate, createHash } from 'node:crypto';
|
|
6
6
|
import { Collector, demoSnapshot } from "../adapters/agents/log-collector.js";
|
|
7
7
|
import { Snapshots } from "../application/snapshots.js";
|
|
8
8
|
import { Pairing } from "../application/pairing.js";
|
|
9
|
+
import { PairingStore } from "../infrastructure/pairing-store.js";
|
|
9
10
|
import { certificates } from "../infrastructure/certificates.js";
|
|
10
11
|
import { pairingLink } from "../infrastructure/pairing-link.js";
|
|
11
12
|
import { spawn } from 'node:child_process';
|
|
@@ -18,10 +19,21 @@ export async function main(options = {}) {
|
|
|
18
19
|
const port = Number(value('--port') ?? 0), lanPort = Number(value('--lan-port') ?? 63982), hours = Number(value('--hours') ?? 24);
|
|
19
20
|
if (![port, lanPort].every(p => Number.isInteger(p) && p >= 0 && p <= 65535) || !Number.isFinite(hours) || hours < 1 || hours > 168)
|
|
20
21
|
throw new Error('端口或 hours 参数无效');
|
|
21
|
-
const
|
|
22
|
+
const explicitLan = args.includes('--lan');
|
|
22
23
|
const runtime = options.runtimeRoot ?? (value('--state-dir') ? resolve(value('--state-dir')) : fileURLToPath(new URL('../../.runtime/', import.meta.url)));
|
|
23
|
-
let interfacesConfigured = args.includes('--lan');
|
|
24
24
|
await mkdir(runtime, { recursive: true, mode: 0o700 });
|
|
25
|
+
const authorizations = new PairingStore(runtime);
|
|
26
|
+
let savedIdentity;
|
|
27
|
+
try {
|
|
28
|
+
savedIdentity = createHash('sha256').update(new X509Certificate(await readFile(join(runtime, 'identity', 'ca.pem'))).raw).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (e.code !== 'ENOENT')
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
authorizations.verifyIdentity(savedIdentity);
|
|
35
|
+
let interfacesConfigured = explicitLan || authorizations.addresses !== null;
|
|
36
|
+
const initial = explicitLan ? (value('--lan')?.split(',').filter(Boolean) ?? []) : (authorizations.addresses ?? []).filter(a => interfaces().some(i => i.address === a));
|
|
25
37
|
const lock = join(runtime, 'host.lock');
|
|
26
38
|
try {
|
|
27
39
|
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
@@ -43,14 +55,14 @@ export async function main(options = {}) {
|
|
|
43
55
|
}
|
|
44
56
|
const collector = new Collector({ hours });
|
|
45
57
|
const snapshots = new Snapshots(() => args.includes('--demo') ? demoSnapshot() : collector.snapshot());
|
|
46
|
-
const pairing = new Pairing();
|
|
58
|
+
const pairing = new Pairing(Date.now, () => { }, authorizations);
|
|
47
59
|
const lanServers = new Map();
|
|
48
60
|
let localServer;
|
|
49
61
|
let identity;
|
|
50
62
|
let addresses = [], endpoints = [], servicePort = lanPort, changing = false, stopping = false;
|
|
51
63
|
let certificateWarning = '';
|
|
52
64
|
const close = (s) => { s.closeAllConnections(); s.close(); };
|
|
53
|
-
async function chooseInterfaces(next) {
|
|
65
|
+
async function chooseInterfaces(next, persist = true) {
|
|
54
66
|
const available = interfaces();
|
|
55
67
|
if (changing || stopping || next.length > 8 || new Set(next).size !== next.length || next.some(a => typeof a !== 'string' || !available.some(i => i.address === a)))
|
|
56
68
|
throw new Error('请选择本机现有的明确 IPv4 地址');
|
|
@@ -59,6 +71,7 @@ export async function main(options = {}) {
|
|
|
59
71
|
try {
|
|
60
72
|
if (next.length) {
|
|
61
73
|
const renewed = await certificates(join(runtime, 'identity'), next);
|
|
74
|
+
authorizations.verifyIdentity(renewed.computerId);
|
|
62
75
|
if (identity && renewed.computerId !== identity.computerId)
|
|
63
76
|
throw new Error('电脑根已改变,需要明确重新配对');
|
|
64
77
|
for (const address of next)
|
|
@@ -74,6 +87,8 @@ export async function main(options = {}) {
|
|
|
74
87
|
identity = renewed;
|
|
75
88
|
certificateWarning = '';
|
|
76
89
|
}
|
|
90
|
+
if (persist)
|
|
91
|
+
authorizations.configure(identity?.computerId ?? savedIdentity, next);
|
|
77
92
|
for (const [address, server] of lanServers)
|
|
78
93
|
if (!next.includes(address)) {
|
|
79
94
|
close(server);
|
|
@@ -106,10 +121,10 @@ export async function main(options = {}) {
|
|
|
106
121
|
}, 3600000);
|
|
107
122
|
renewal.unref();
|
|
108
123
|
try {
|
|
109
|
-
await chooseInterfaces(initial);
|
|
124
|
+
await chooseInterfaces(initial, explicitLan);
|
|
110
125
|
const local = createDashboard(() => snapshots.get(), async (action, input) => {
|
|
111
126
|
if (action === 'status')
|
|
112
|
-
return { ...pairing.status(), available: interfaces(), selectedAddresses: addresses, interfacesConfigured, endpoints, computerId: identity?.computerId, name: hostname(), restartRequiresApproval:
|
|
127
|
+
return { ...pairing.status(), available: interfaces(), selectedAddresses: addresses, interfacesConfigured, endpoints, computerId: identity?.computerId, name: hostname(), restartRequiresApproval: false, certificateWarning };
|
|
113
128
|
if (action === 'interfaces') {
|
|
114
129
|
if (!Array.isArray(input.addresses))
|
|
115
130
|
throw new Error('invalid addresses');
|
|
@@ -145,7 +160,7 @@ export async function main(options = {}) {
|
|
|
145
160
|
browser.on('error', () => { });
|
|
146
161
|
browser.unref();
|
|
147
162
|
}
|
|
148
|
-
console.log(`Local Agent Dashboard(${args.includes('--demo') ? '示例任务' : '读取本机任务状态'})\n电脑管理页面:${url}\n${endpoints.length ? `手机连接地址:${endpoints.join(', ')}\n
|
|
163
|
+
console.log(`Local Agent Dashboard(${args.includes('--demo') ? '示例任务' : '读取本机任务状态'})\n电脑管理页面:${url}\n${endpoints.length ? `手机连接地址:${endpoints.join(', ')}\n已批准手机的授权已保存,服务重启后可继续连接。` : '尚未开启手机连接。请在电脑页面点击「连接手机」,选择网络地址后启用。'}`);
|
|
149
164
|
}
|
|
150
165
|
catch (e) {
|
|
151
166
|
clearInterval(renewal);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, lstatSync, fstatSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
const fingerprint = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
6
|
+
function decode(value) {
|
|
7
|
+
const s = value;
|
|
8
|
+
const addressesValid = s && (s.addresses === null || Array.isArray(s.addresses)
|
|
9
|
+
&& s.addresses.length <= 8 && new Set(s.addresses).size === s.addresses.length
|
|
10
|
+
&& s.addresses.every(a => typeof a === 'string' && isIP(a) === 4 && a !== '0.0.0.0'));
|
|
11
|
+
if (!s || s.version !== 1 || !(s.computerId === null || fingerprint(s.computerId))
|
|
12
|
+
|| !addressesValid || !Array.isArray(s.devices) || s.devices.length > 64) {
|
|
13
|
+
throw Error('设备授权文件无效;请恢复备份,不会自动清空授权。');
|
|
14
|
+
}
|
|
15
|
+
const ids = new Set(), tokens = new Set();
|
|
16
|
+
for (const d of s.devices) {
|
|
17
|
+
if (!d || typeof d.id !== 'string' || !/^[a-f0-9-]{36}$/.test(d.id) || ids.has(d.id)
|
|
18
|
+
|| !fingerprint(d.tokenHash) || tokens.has(d.tokenHash) || typeof d.name !== 'string'
|
|
19
|
+
|| !d.name.trim() || d.name.length > 60 || /[\x00-\x1f]/.test(d.name)
|
|
20
|
+
|| !Number.isSafeInteger(d.createdAt) || d.createdAt < 0 || typeof d.revoked !== 'boolean') {
|
|
21
|
+
throw Error('设备授权记录无效;不会自动清空授权。');
|
|
22
|
+
}
|
|
23
|
+
ids.add(d.id);
|
|
24
|
+
tokens.add(d.tokenHash);
|
|
25
|
+
}
|
|
26
|
+
if (s.devices.length && !s.computerId)
|
|
27
|
+
throw Error('设备授权缺少电脑身份。');
|
|
28
|
+
return {
|
|
29
|
+
version: 1, computerId: s.computerId, addresses: s.addresses,
|
|
30
|
+
devices: s.devices.map(({ id, name, tokenHash, createdAt, revoked }) => ({ id, name, tokenHash, createdAt, revoked })),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Only approved token hashes are durable. Invitations and delivery ACKs are process-local.
|
|
34
|
+
export class PairingStore {
|
|
35
|
+
file;
|
|
36
|
+
state;
|
|
37
|
+
constructor(privateRoot) {
|
|
38
|
+
const root = lstatSync(privateRoot);
|
|
39
|
+
if (!root.isDirectory() || root.isSymbolicLink() || (root.mode & 0o077) !== 0
|
|
40
|
+
|| process.getuid && root.uid !== process.getuid()) {
|
|
41
|
+
throw Error('用户数据目录必须由当前用户独占(权限 0700)。');
|
|
42
|
+
}
|
|
43
|
+
this.file = join(privateRoot, 'authorized-devices.json');
|
|
44
|
+
this.state = { version: 1, computerId: null, addresses: null, devices: [] };
|
|
45
|
+
let fd;
|
|
46
|
+
try {
|
|
47
|
+
fd = openSync(this.file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
if (e.code === 'ENOENT') {
|
|
51
|
+
// A dangling link must not masquerade as a missing file.
|
|
52
|
+
try {
|
|
53
|
+
lstatSync(this.file);
|
|
54
|
+
throw Error('拒绝链接形式的设备授权文件。');
|
|
55
|
+
}
|
|
56
|
+
catch (check) {
|
|
57
|
+
if (check.code === 'ENOENT')
|
|
58
|
+
return;
|
|
59
|
+
throw check;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw Error('无法安全读取设备授权文件。');
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const stat = fstatSync(fd);
|
|
66
|
+
if (!stat.isFile() || stat.size > 65536 || (stat.mode & 0o077) !== 0
|
|
67
|
+
|| process.getuid && stat.uid !== process.getuid())
|
|
68
|
+
throw Error('设备授权文件权限或大小无效。');
|
|
69
|
+
let value;
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(readFileSync(fd, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw Error('设备授权文件无法读取或已损坏;请恢复备份,不会自动清空授权。');
|
|
75
|
+
}
|
|
76
|
+
this.state = decode(value);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
get addresses() { return this.state.addresses === null ? null : [...this.state.addresses]; }
|
|
83
|
+
verifyIdentity(computerId) {
|
|
84
|
+
if (this.state.computerId && this.state.computerId !== computerId) {
|
|
85
|
+
throw Error('电脑身份与已保存的设备授权不一致;请恢复原身份,或明确重新配对。');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
load() { return this.state.devices.map(d => ({ ...d })); }
|
|
89
|
+
save(devices) { this.persist({ ...this.state, devices }); }
|
|
90
|
+
configure(computerId, addresses) {
|
|
91
|
+
this.verifyIdentity(computerId);
|
|
92
|
+
this.persist({ ...this.state, computerId: computerId ?? null, addresses });
|
|
93
|
+
}
|
|
94
|
+
persist(value) {
|
|
95
|
+
const next = decode(value), temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
96
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(fd, JSON.stringify(next) + '\n');
|
|
99
|
+
fsyncSync(fd);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
unlinkSync(temporary);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
closeSync(fd);
|
|
107
|
+
try {
|
|
108
|
+
renameSync(temporary, this.file);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
unlinkSync(temporary);
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
this.state = next;
|
|
115
|
+
}
|
|
116
|
+
}
|