dsh-any-agent 0.6.0 → 0.6.2

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 只读 dashboard:本地 HTTP + SSE,把 live 事件流渲染成调用链页面。
3
+ *
4
+ * 安全边界(页面能看到命令预览、路径等,所以按"本机私有页面"对待):
5
+ * - 只监听 127.0.0.1;端口默认随机。
6
+ * - 每次启动生成一次性 token,所有请求都要带 `?t=<token>`(EventSource 不能设请求头)。
7
+ * - 校验 Host 头只能是 127.0.0.1:<port> / localhost:<port>,挡 DNS rebinding。
8
+ * - 只有 GET;不提供任何会改变状态的接口(没有控制面,见设计稿 §9)。
9
+ * - CSP 禁止外部资源;Referrer-Policy: no-referrer,避免 token 随 Referer 外泄。
10
+ */
11
+
12
+ import { createServer } from 'node:http';
13
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ const HTML_FILE = join(HERE, 'live-dashboard.html');
20
+ /** 没有新事件时也定期推一次(刷新"写者是否还活着"与相对时间)。 */
21
+ const IDLE_PUSH_MS = 5000;
22
+ const KEEPALIVE_MS = 15000;
23
+
24
+ const SECURITY_HEADERS = {
25
+ 'Cache-Control': 'no-store',
26
+ 'X-Content-Type-Options': 'nosniff',
27
+ 'Referrer-Policy': 'no-referrer',
28
+ 'X-Frame-Options': 'DENY',
29
+ 'Content-Security-Policy':
30
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
31
+ };
32
+
33
+ function tokenOk(given, token) {
34
+ if (typeof given !== 'string') return false;
35
+ const a = Buffer.from(given);
36
+ const b = Buffer.from(token);
37
+ return a.length === b.length && timingSafeEqual(a, b);
38
+ }
39
+
40
+ /**
41
+ * 起 dashboard。返回 {url, port, token, server, close()}。
42
+ * @param {object} opts
43
+ * @param {import('./live-view.mjs').LiveTail} opts.tail
44
+ * @param {number} [opts.port] 0 = 随机
45
+ * @param {number} [opts.pollMs]
46
+ * @param {string} [opts.token] 测试用;缺省随机
47
+ * @param {string} [opts.html] 测试用;缺省读 live-dashboard.html
48
+ */
49
+ export async function startDashboard({ tail, port = 0, pollMs = 500, token = randomBytes(18).toString('base64url'), html } = {}) {
50
+ const page = html ?? readFileSync(HTML_FILE, 'utf8');
51
+ const clients = new Set();
52
+ let snapshot = '';
53
+
54
+ const refresh = () => {
55
+ snapshot = JSON.stringify(tail.view());
56
+ return snapshot;
57
+ };
58
+ tail.poll();
59
+ refresh();
60
+
61
+ const send = (res, data) => {
62
+ try {
63
+ res.write(`data: ${data}\n\n`);
64
+ } catch {
65
+ clients.delete(res);
66
+ }
67
+ };
68
+
69
+ let lastPush = Date.now();
70
+ const pollTimer = setInterval(() => {
71
+ let changed = false;
72
+ try {
73
+ changed = tail.poll();
74
+ } catch {
75
+ changed = false;
76
+ }
77
+ if (changed || Date.now() - lastPush > IDLE_PUSH_MS) {
78
+ const data = refresh();
79
+ for (const res of clients) send(res, data);
80
+ lastPush = Date.now();
81
+ }
82
+ }, pollMs);
83
+ const keepTimer = setInterval(() => {
84
+ for (const res of clients) {
85
+ try {
86
+ res.write(': keepalive\n\n');
87
+ } catch {
88
+ clients.delete(res);
89
+ }
90
+ }
91
+ }, KEEPALIVE_MS);
92
+ pollTimer.unref?.();
93
+ keepTimer.unref?.();
94
+
95
+ const server = createServer((req, res) => {
96
+ const actualPort = server.address()?.port;
97
+ const host = String(req.headers.host ?? '').toLowerCase();
98
+ if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
99
+ res.writeHead(421, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
100
+ res.end('misdirected request');
101
+ return;
102
+ }
103
+ if (req.method !== 'GET') {
104
+ res.writeHead(405, { Allow: 'GET', 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
105
+ res.end('read-only');
106
+ return;
107
+ }
108
+ let url;
109
+ try {
110
+ url = new URL(req.url ?? '/', `http://${host}`);
111
+ } catch {
112
+ res.writeHead(400, SECURITY_HEADERS);
113
+ res.end();
114
+ return;
115
+ }
116
+ if (!tokenOk(url.searchParams.get('t'), token)) {
117
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
118
+ res.end('forbidden: open the URL printed by `dsh-any-agent dashboard`');
119
+ return;
120
+ }
121
+ if (url.pathname === '/') {
122
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
123
+ res.end(page);
124
+ return;
125
+ }
126
+ if (url.pathname === '/api/view') {
127
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY_HEADERS });
128
+ res.end(snapshot);
129
+ return;
130
+ }
131
+ if (url.pathname === '/events') {
132
+ res.writeHead(200, {
133
+ 'Content-Type': 'text/event-stream; charset=utf-8',
134
+ Connection: 'keep-alive',
135
+ ...SECURITY_HEADERS,
136
+ });
137
+ res.write('retry: 2000\n\n');
138
+ clients.add(res);
139
+ send(res, snapshot);
140
+ req.on('close', () => clients.delete(res));
141
+ return;
142
+ }
143
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
144
+ res.end('not found');
145
+ });
146
+
147
+ await new Promise((resolve, reject) => {
148
+ server.once('error', reject);
149
+ server.listen(port, '127.0.0.1', () => {
150
+ server.off('error', reject);
151
+ resolve();
152
+ });
153
+ });
154
+ const actual = server.address().port;
155
+ const url = `http://127.0.0.1:${actual}/?t=${encodeURIComponent(token)}`;
156
+
157
+ return {
158
+ url,
159
+ port: actual,
160
+ token,
161
+ server,
162
+ async close() {
163
+ clearInterval(pollTimer);
164
+ clearInterval(keepTimer);
165
+ for (const res of clients) {
166
+ try {
167
+ res.end();
168
+ } catch {}
169
+ }
170
+ clients.clear();
171
+ const closed = new Promise((r) => server.close(() => r()));
172
+ server.closeAllConnections?.();
173
+ await closed;
174
+ },
175
+ };
176
+ }