@nightpainters/dsh-ling 1.5.1

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 (51) hide show
  1. package/ACCESS-DESIGN.md +119 -0
  2. package/CHANGELOG.md +627 -0
  3. package/DESIGN.md +461 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +7 -0
  6. package/README.md +236 -0
  7. package/cordis.patch.yml +16 -0
  8. package/lib/audit/access-log.js +533 -0
  9. package/lib/audit/index.js +92 -0
  10. package/lib/audit/patch-spec.js +195 -0
  11. package/lib/client.js +5069 -0
  12. package/lib/host/api.js +2584 -0
  13. package/lib/host/archive.js +194 -0
  14. package/lib/host/autotree.js +131 -0
  15. package/lib/host/backfill.js +267 -0
  16. package/lib/host/classify.js +10 -0
  17. package/lib/host/clock-skill.js +141 -0
  18. package/lib/host/clock.js +132 -0
  19. package/lib/host/corpus-suggest.js +139 -0
  20. package/lib/host/deepsummary.js +338 -0
  21. package/lib/host/dsweb-summary.js +225 -0
  22. package/lib/host/feedback.js +119 -0
  23. package/lib/host/freeze.js +144 -0
  24. package/lib/host/genesis.js +176 -0
  25. package/lib/host/guard.js +184 -0
  26. package/lib/host/habit-gen.js +273 -0
  27. package/lib/host/import-file.js +291 -0
  28. package/lib/host/import-log.js +129 -0
  29. package/lib/host/inject-common.js +25 -0
  30. package/lib/host/inject.js +285 -0
  31. package/lib/host/l1.js +282 -0
  32. package/lib/host/lifecycle.js +367 -0
  33. package/lib/host/memory.js +2389 -0
  34. package/lib/host/mode.js +101 -0
  35. package/lib/host/persona.js +214 -0
  36. package/lib/host/retitle.js +214 -0
  37. package/lib/host/rules.js +195 -0
  38. package/lib/host/scan-dsweb.js +111 -0
  39. package/lib/host/seal.js +17 -0
  40. package/lib/host/settings-file.js +49 -0
  41. package/lib/host/snapshot.js +168 -0
  42. package/lib/host/suggestions.js +47 -0
  43. package/lib/host/summarizer.js +103 -0
  44. package/lib/host/tone-advice.js +112 -0
  45. package/lib/host/tools.js +1067 -0
  46. package/lib/host/tree-ops.js +330 -0
  47. package/lib/host/util.js +165 -0
  48. package/lib/host/zstd.js +63 -0
  49. package/lib/index.js +263 -0
  50. package/package.json +69 -0
  51. package/tools/apply-access-log-patch.mjs +295 -0
@@ -0,0 +1,533 @@
1
+ // E4 · 可取证访问日志(E4 任务书)
2
+ //
3
+ // 目标:让「谁 · 何时 · 从哪台设备 · 调用了哪个端点 · 结果如何」事后可回答。
4
+ // 起因:2026-09-24 19:49:51 的那次调度重启,四条排查路全走死,最后只剩
5
+ // 「一次真实点击或一次合成点击」——**因为整个系统没有一行访问日志**。
6
+ //
7
+ // 边界(任务书 §3.3):只记「发生了什么事」,不记「内容是什么」
8
+ // · 不落请求体 / 响应体 · 不落 cookie 值(只落验签结果与设备**摘要**)
9
+ // **C-01(2026-09-26)**:设备摘要 = `sha256(配对凭据原文)[:16]`(见 `deviceIdDigest()`)。
10
+ // 旧实现把 cookie 值"抽 hex"后原样落盘 —— 而权威形态下 cookie 值**就是**设备键**就是**凭据,
11
+ // 等于把 bearer 凭据写进日志。现在日志仍能回答"是不是同一台设备",但不再持有可用凭据。
12
+ // 形态判别:1.5.0 及更早的行**没有** `deviceIdHash` 字段,其 `deviceId` 是 32-hex 凭据原文;
13
+ // 新行带 `deviceIdHash:"sha256-16"`,其 `deviceId` 是 16-hex 摘要 —— 消费者按此字段分辨时代。
14
+ // · 查询串一律丢弃(/?token=… 这类凭据不得进日志)
15
+ //
16
+ // 三条工程约束(都来自现场教训,不是风格偏好):
17
+ // 1) **同步落盘**:悬案现场是「调度重启后 1200ms 进程退出」——异步写缓冲会把最后
18
+ // 一行丢掉,而最后一行恰恰是最重要的那行。用 appendFileSync(开→写→关)。
19
+ // 为什么不是「常开 fd + writeSync」:句柄一直握着时目录项会停在旧值 —— 实测
20
+ // 文件里已有 77 行,`Get-Item` 仍报 0 字节、mtime 停在创建那一刻;这正好是未来
21
+ // 取证时最容易误判成「什么都没记」的假象。且 Windows 上被别的句柄占用的文件
22
+ // 删不掉,会挡住按天轮转。
23
+ // 2) **绝不抛进请求路径**:任何异常都被吞掉。**但降级不是沉默**(C-07,2026-09-26):
24
+ // 写失败 ⇒ 自我禁用 + 首次立刻告警(带 errno)⇒ 之后按节流**继续**告警并累计"丢了多少行",
25
+ // 同时把可机读的健康面常驻在 `info().health`(`/health` 读它即可)。旧实现只喊一行就永久静默,
26
+ // 而"日志悄悄不记了"恰恰是本模块最该被发现的故障。
27
+ // 3) **只观察、不判定**:本模块不调用鉴权、不改响应。状态码与拒绝原因全部来自
28
+ // 「已经发生的响应」,判据与 guard 保持单一来源(任务书 §3.5)。
29
+
30
+ import { existsSync, mkdirSync, appendFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
32
+ import { join } from 'node:path';
33
+
34
+ const FILE_PREFIX = 'access-';
35
+ const FILE_SUFFIX = '.jsonl';
36
+ /** 配对设备 id 是 32 位 hex(devices.json 的键就是它);放宽到 16~64 位以兼容形态变化。 */
37
+ const HEX_RUN = /[0-9a-f]{16,64}/i;
38
+ /** `deviceId` 字段的形态标记(C-01):值 = 摘要算法 + 十六进制位数。老日志(≤1.5.0)没有这个字段。 */
39
+ export const DEVICE_ID_HASH = 'sha256-16';
40
+ /** core 的鉴权 cookie 前缀(dsh-client-connection:`dsh-auth-<base64url(sha256(authority))>`)。 */
41
+ const AUTH_COOKIE_PREFIX = 'dsh-auth-';
42
+ /**
43
+ * 部署姿态自检探针的路径(远端 UI 的部署姿态检查实现):
44
+ * 它伪造 Host 打本机 loopback,专门验证 `/api` 栅栏 —— **403 才是它期望的健康结果**。
45
+ * 命中签名时多打 `probe:"posture"`,免得把自家自检误判成入侵(任务书 §7.5)。
46
+ */
47
+ export const POSTURE_PROBE_PATH = '/api/session.list';
48
+ /**
49
+ * 「无 UA 行」的通道标记(任务书 §7.5):请求连 `user-agent` 头都没有 ⇒ **不是浏览器直连**。
50
+ * 但这一类**混装两种调用者** —— 进程内客户端(boot 批量的内部 RPC)与经远端通道代理进来的
51
+ * 设备(手机/平板/物理机,代理按设计不转发 UA)。故本字段只陈述"非浏览器通道"这一**事实**,
52
+ * **不指认是哪一台设备**;要分辨两者得看端点语义(判读口径见任务书 §7.5)。
53
+ */
54
+ export const CHANNEL_NO_UA = 'no-ua';
55
+
56
+ export const ACCESS_LOG_DEFAULTS = {
57
+ enabled: true,
58
+ /** 目录:默认 $DSH_HOME/logs(调用方补)。 */
59
+ dir: '',
60
+ /** 总大小上限(字节):超限删最旧,当天文件永不删。0 = 不限制。 */
61
+ maxTotalBytes: 32 * 1024 * 1024,
62
+ /** UA 截断长度:UA 能区分浏览器/脚本/哪台设备,但整条太长。 */
63
+ uaMax: 200,
64
+ /** 携带已配对设备标识的 cookie 名(= settings.yaml 的 remote-web-ui.cookieName)。 */
65
+ deviceCookie: 'dsh_pair-W',
66
+ /** 高危端点:命中则多打一个 risk 字段,便于 grep。 */
67
+ riskPatterns: ['settingRestart/restart', '/dsh-ling/'],
68
+ /** C-07:降级(写失败)后**重复告警**的最小间隔(毫秒);0 = 每次丢行都告警(测试用)。 */
69
+ degradeWarnMs: 60_000,
70
+ };
71
+
72
+ /** 本地时间 ISO(带毫秒与偏移):既能排序、又能直接肉眼对齐现场时刻。 */
73
+ export function localIso(date = new Date()) {
74
+ const p = (n, w = 2) => String(n).padStart(w, '0');
75
+ const off = -date.getTimezoneOffset();
76
+ const sign = off >= 0 ? '+' : '-';
77
+ const abs = Math.abs(off);
78
+ return (
79
+ `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}` +
80
+ `T${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}.${p(date.getMilliseconds(), 3)}` +
81
+ `${sign}${p(Math.floor(abs / 60))}:${p(abs % 60)}`
82
+ );
83
+ }
84
+
85
+ /** 本地日期 `YYYY-MM-DD`:日志按天切的文件名口径。 */
86
+ export function localDay(date = new Date()) {
87
+ const p = (n) => String(n).padStart(2, '0');
88
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
89
+ }
90
+
91
+ function header(headers, name) {
92
+ if (!headers || typeof headers !== 'object') return undefined;
93
+ const v = headers[name];
94
+ if (Array.isArray(v)) return v.length ? String(v[0]) : undefined;
95
+ return v === undefined || v === null ? undefined : String(v);
96
+ }
97
+
98
+ function truncate(text, max) {
99
+ if (text === undefined) return null;
100
+ const s = String(text);
101
+ if (!Number.isFinite(max) || max <= 0 || s.length <= max) return s;
102
+ return `${s.slice(0, max)}…`;
103
+ }
104
+
105
+ /** 只留路径:查询串与 hash 一律丢弃(可能含凭据)。 */
106
+ function pathOf(req) {
107
+ let raw = '';
108
+ try {
109
+ raw = String(req?.url ?? '');
110
+ } catch {
111
+ raw = '';
112
+ }
113
+ const cut = Math.min(
114
+ ...['?', '#'].map((c) => {
115
+ const i = raw.indexOf(c);
116
+ return i < 0 ? raw.length : i;
117
+ }),
118
+ );
119
+ return truncate(raw.slice(0, cut), 300) || null;
120
+ }
121
+
122
+ /** 不做通用 Cookie 解码:只按名字找那一个(与 core 的做法一致)。 */
123
+ export function cookieValueOf(headerValue, name) {
124
+ if (typeof headerValue !== 'string' || !headerValue || !name) return undefined;
125
+ for (const part of headerValue.split(';')) {
126
+ const eq = part.indexOf('=');
127
+ if (eq < 0) continue;
128
+ if (part.slice(0, eq).trim() !== name) continue;
129
+ return part.slice(eq + 1).trim();
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ /**
135
+ * 从配对 cookie 值里取设备 hash(多策略,取不到就返回 null 并如实记 present)。
136
+ * **权威形态(2026-09-25 定点核实)**:远端 UI 的配对实现
137
+ * 用它的随机令牌生成器产出 **32 位十六进制明文**,经它的路由层原样写进 cookie
138
+ * (`$DSH_HOME/remote-web-ui-devices.json` 的键实测同为 32-hex);cookie 名默认 `dsh_pair`,
139
+ * 本机配置为 `dsh_pair-W`。多策略保留只为「不赌它改版」:先找 hex run,再试点分段。
140
+ *
141
+ * ⚠️ **返回值是凭据原文**(权威形态下即设备键本身,见 C-01):**只能喂给 `deviceIdDigest()`**,
142
+ * 绝不直接落盘、回显或进错误信息。
143
+ */
144
+ export function deviceIdFromCookieValue(value) {
145
+ if (value === undefined || value === null) return null;
146
+ const s = String(value);
147
+ if (!s) return null;
148
+ const direct = s.match(HEX_RUN);
149
+ if (direct) return direct[0].toLowerCase();
150
+ const first = s.split(/[.~|:,]/)[0];
151
+ const seg = first && first.match(HEX_RUN);
152
+ return seg ? seg[0].toLowerCase() : null;
153
+ }
154
+
155
+ /**
156
+ * 设备标识**摘要**(C-01,2026-09-26):把凭据原文换成不可逆摘要后再落盘。
157
+ *
158
+ * 为什么必须做:权威形态下 cookie 值 = `remote-web-ui-devices.json` 的键 = **凭据本身**
159
+ * (`gate.ts` 出示它即通过配对校验)⇒ 旧实现把 bearer 凭据原样写进日志。而事故现场最常见的
160
+ * 动作恰恰是"把日志拷走 / 贴进工单 / 交给别人看"—— 那等于交出配对凭据(审计 C-01,高)。
161
+ *
162
+ * 口径:`sha256(原文 utf8)` 十六进制前 **16** 位(64 bit)。理由逐条:
163
+ * · **同设备 ⇒ 同摘要**:日志仍能回答"这两行是不是同一台设备"(取证的全部诉求);
164
+ * · **可反查**:要知道是哪一台,对 `$DSH_HOME/remote-web-ui-devices.json` 的键逐个做同法摘要
165
+ * 比对即可 —— 反查在本机做,凭据不出机器;
166
+ * · **不用慢哈希**:原文是 128 bit 随机量(`clock.randomToken()`),无字典可猜,"慢"只会在
167
+ * 每行日志上白烧 CPU;
168
+ * · **不加盐**:盐必须与日志同处存放才可用(等于没加保护),不同处存放就丢掉"反查 + 同设备
169
+ * 比对"这两个唯一用途;对"日志泄露 ⇒ 凭据可用"这一威胁,**不可逆 + 高熵**已经足够;
170
+ * · **截 16 位**:日志里同时存在的设备是"个位/十位"量级,碰撞上界 ~n²/2^65,可忽略。
171
+ */
172
+ export function deviceIdDigest(raw) {
173
+ if (raw === undefined || raw === null) return null;
174
+ const s = String(raw);
175
+ if (!s) return null;
176
+ return createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);
177
+ }
178
+
179
+ function firstForwarded(headers) {
180
+ return (
181
+ header(headers, 'cf-connecting-ip') ||
182
+ (header(headers, 'x-forwarded-for') || '').split(',')[0].trim() ||
183
+ header(headers, 'x-real-ip') ||
184
+ undefined
185
+ );
186
+ }
187
+
188
+ /**
189
+ * 拒绝原因:**只从已经发生的响应反推**,不重跑鉴权。
190
+ *
191
+ * 1) **优先取判定点写下的真因**(C-14,2026-09-25):器灵自己的 guard 判定完成后把
192
+ * `verdict.reason` 挂到请求对象上(`req.__dshLingRejection`,见 `lib/host/api.js` 的
193
+ * guard;405 分支挂 `'method-not-allowed'`)。为什么必须这样:器灵的记录点在 guard
194
+ * **顶部**(api.js:225-230 调 observe),判定点在其后(api.js:238)——只按状态码反推时,
195
+ * 器灵 guard 拒的 403 会被一律误标成 untrusted-host(`cross-site` / `origin-mismatch` /
196
+ * `no-cookie` / `empty-cookie` 四种全中招,实测)。该字段**只在拒绝分支上挂**,成功分支
197
+ * 永远没有它;本函数是在日志行写出那一刻被调用的(`observe()` 的 `finish()`,同步路径),
198
+ * 所以读到的一定是本次请求的真因。
199
+ *
200
+ * 2) 读不到标记就**退回按状态码反推** —— core 栅栏的流量走的就是这条路(它不经过器灵 guard,
201
+ * 也就没有上面那个字段),契约不变:
202
+ * 403 = Host/Origin 栅栏拦下(core 里 403 的唯一来源:`isTrustedApiRequest` 不过 ⇒ 403)
203
+ * → untrusted-host
204
+ * 401 = 栅栏过了但 cookie 没验过:带没带 cookie 区分 no-cookie / bad-cookie
205
+ *
206
+ * @param {number} status 已经发生的状态码
207
+ * @param {object} [headers] 请求头(退回判据用)
208
+ * @param {object} [req] 请求对象;给了就读它的 `__dshLingRejection`
209
+ */
210
+ export function rejectionReason(status, headers, req) {
211
+ const marked = req && typeof req === 'object' ? req.__dshLingRejection : undefined;
212
+ if (typeof marked === 'string' && marked) return marked;
213
+ if (status === 403) return 'untrusted-host';
214
+ if (status === 401) {
215
+ const raw = header(headers, 'cookie') || '';
216
+ return raw.includes(AUTH_COOKIE_PREFIX) ? 'bad-cookie' : 'no-cookie';
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /**
222
+ * 建一个访问日志器。所有方法都不抛(内部全兜)。
223
+ * @param {object} options 见 {@link ACCESS_LOG_DEFAULTS}(dir 必填)
224
+ */
225
+ export function createAccessLog(options = {}) {
226
+ const dir = String(options.dir || ACCESS_LOG_DEFAULTS.dir || '');
227
+ const maxTotalBytes = Number.isFinite(Number(options.maxTotalBytes))
228
+ ? Number(options.maxTotalBytes)
229
+ : ACCESS_LOG_DEFAULTS.maxTotalBytes;
230
+ const uaMax = Number.isFinite(Number(options.uaMax)) ? Number(options.uaMax) : ACCESS_LOG_DEFAULTS.uaMax;
231
+ const deviceCookie = String(options.deviceCookie || ACCESS_LOG_DEFAULTS.deviceCookie);
232
+ const riskPatterns = (Array.isArray(options.riskPatterns) ? options.riskPatterns : ACCESS_LOG_DEFAULTS.riskPatterns)
233
+ .filter((p) => typeof p === 'string' && p);
234
+ const degradeWarnMs = Number.isFinite(Number(options.degradeWarnMs))
235
+ ? Math.max(0, Number(options.degradeWarnMs))
236
+ : ACCESS_LOG_DEFAULTS.degradeWarnMs;
237
+
238
+ let day = null;
239
+ let writes = 0;
240
+ let disabled = options.enabled === false || !dir;
241
+ // C-07 的观测面(原实现只有一个 warned 布尔 ⇒ 喊一行之后永久静默):
242
+ // failures —— 写调用抛异常的次数(一次性的 vs 持续坏掉)
243
+ // lost —— 因此**丢掉的行数**:降级后唯一还能增长的量,告警节流就挂在它上面
244
+ // lastError—— 最后一次失败的 `{ code, message, at }`(errno 是排障的第一手信息)
245
+ // warnAt —— 上次告警时刻(重复告警的节流闸)
246
+ let failures = 0;
247
+ let lost = 0;
248
+ let lastError = null;
249
+ let warnAt = 0;
250
+
251
+ /**
252
+ * 该不该喊:首次必喊;之后**只按时间节流**(默认 60s 一次)。
253
+ * 为什么不用"丢行数是 2 的幂"这类计数节流:那会让告警里的损失量**滞后**(喊的时候是 8,其实已经丢了 9),
254
+ * 而"到底丢了多少"正是这条告警唯一的硬信息 —— 时间节流既不刷屏、计数又永远是最新的。
255
+ */
256
+ const shouldWarn = () => {
257
+ if (lastError === null) return false;
258
+ if (warnAt === 0) return true;
259
+ return Date.now() - warnAt >= degradeWarnMs;
260
+ };
261
+
262
+ const warn = () => {
263
+ if (!shouldWarn()) return;
264
+ warnAt = Date.now();
265
+ const e = lastError || {};
266
+ try {
267
+ // 自己拼成一整行(不用 printf 占位符):告警文本必须**所见即所得**,
268
+ // 否则任何拦下 console 的地方(测试、日志转发)看到的都是没插值的 `%d`。
269
+ const detail = `${e.code ? ` code=${e.code}` : ''}${e.message ? ` err=${e.message}` : ''}`;
270
+ console.error(
271
+ `[dsh-ling] access log disabled: write failed ${failures} time(s), ${lost} line(s) dropped; dir=${dir}${detail}`,
272
+ );
273
+ } catch {}
274
+ };
275
+
276
+ /**
277
+ * 记一次"丢了行"。**降级(lastError 存在)时**才计数并考虑告警 ——
278
+ * `enabled:false` 的"配置关闭"是有意为之,不是故障,既不计丢失也不喊。
279
+ */
280
+ function noteLost() {
281
+ if (!lastError) return;
282
+ lost += 1;
283
+ warn();
284
+ }
285
+
286
+ /** 写失败 ⇒ 降级:自我禁用 + 记下证据(stderr、info().health 两处同时可见)。 */
287
+ function degrade(err) {
288
+ failures += 1;
289
+ lost += 1; // 触发失败的那一行本身也丢了
290
+ lastError = {
291
+ code: err && err.code ? String(err.code) : null,
292
+ message: (err && err.message) || String(err),
293
+ at: localIso(),
294
+ };
295
+ disabled = true;
296
+ warn();
297
+ }
298
+
299
+ const fileNameOf = (d) => `${FILE_PREFIX}${d}${FILE_SUFFIX}`;
300
+
301
+ /** 超限删最旧(按文件名=时间序),当天文件永不删。 */
302
+ function enforceCap() {
303
+ if (!(maxTotalBytes > 0) || !dir) return;
304
+ let names;
305
+ try {
306
+ names = readdirSync(dir)
307
+ .filter((n) => n.startsWith(FILE_PREFIX) && n.endsWith(FILE_SUFFIX))
308
+ .sort();
309
+ } catch {
310
+ return;
311
+ }
312
+ const current = day ? fileNameOf(day) : '';
313
+ const sized = names.map((n) => {
314
+ try {
315
+ return { n, size: statSync(join(dir, n)).size };
316
+ } catch {
317
+ return { n, size: 0 };
318
+ }
319
+ });
320
+ let total = sized.reduce((a, b) => a + b.size, 0);
321
+ for (const f of sized) {
322
+ if (total <= maxTotalBytes) break;
323
+ if (f.n === current) continue;
324
+ try {
325
+ unlinkSync(join(dir, f.n));
326
+ total -= f.size;
327
+ } catch {}
328
+ }
329
+ }
330
+
331
+ /** 确认当天文件所在目录存在;跨天时顺手做一次超限裁剪。返回当天文件名。 */
332
+ function ensureDay() {
333
+ const today = localDay();
334
+ if (day !== today) {
335
+ mkdirSync(dir, { recursive: true });
336
+ day = today;
337
+ enforceCap();
338
+ }
339
+ return fileNameOf(day);
340
+ }
341
+
342
+ function append(entry) {
343
+ if (disabled) {
344
+ // C-07:降级后每来一行都如实计入"丢掉的行数",并按节流继续告警 —— 不再永久静默。
345
+ noteLost();
346
+ return;
347
+ }
348
+ let line;
349
+ try {
350
+ line = `${JSON.stringify(entry)}\n`;
351
+ } catch {
352
+ return;
353
+ }
354
+ try {
355
+ appendFileSync(join(dir, ensureDay()), line);
356
+ if (++writes % 256 === 0) enforceCap();
357
+ } catch (err) {
358
+ degrade(err);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * 设备字段:cookie 在不在(`deviceCookie`)+ **摘要**(`deviceId`,C-01)。
364
+ * 原文在这一步就被丢掉 —— 函数返回之后,本模块内再无凭据原文的任何副本。
365
+ */
366
+ function deviceFields(headers) {
367
+ const value = cookieValueOf(header(headers, 'cookie'), deviceCookie);
368
+ if (value === undefined) return { deviceCookie: false, deviceId: null, deviceIdHash: null };
369
+ return { deviceCookie: true, deviceId: deviceIdDigest(deviceIdFromCookieValue(value)), deviceIdHash: DEVICE_ID_HASH };
370
+ }
371
+
372
+ function baseFields(req) {
373
+ const headers = (req && req.headers) || {};
374
+ const sock = (req && (req.socket || req.connection)) || {};
375
+ return {
376
+ ip: sock.remoteAddress ? String(sock.remoteAddress) : null,
377
+ fwd: truncate(firstForwarded(headers), 60) || null,
378
+ host: truncate(header(headers, 'host'), 120) || null,
379
+ ua: truncate(header(headers, 'user-agent'), uaMax),
380
+ bytes: Number.isFinite(Number(header(headers, 'content-length')))
381
+ ? Number(header(headers, 'content-length'))
382
+ : null,
383
+ ...deviceFields(headers),
384
+ };
385
+ }
386
+
387
+ /** loopback 的三种写法(v4 / v6 / v4-mapped):姿态探针只打本机,来源必是其中之一。 */
388
+ function isLoopback(ip) {
389
+ return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
390
+ }
391
+
392
+ /**
393
+ * 姿态自检探针的**五要素签名**(任务书 §7.5):POST + 路径 + loopback + 无 UA + 被 403 拦下。
394
+ * 五条同时命中才标注 —— 放宽任何一条都会把真攻击错标成"自检"(内部踩坑记录 B3:判据过宽)。
395
+ * 标注**不代表放行**:被标注的行仍然是"被栅栏拦下"的记录,status 依旧是 403。
396
+ */
397
+ function isPostureProbe(entry) {
398
+ return (
399
+ entry.method === 'POST' &&
400
+ entry.path === POSTURE_PROBE_PATH &&
401
+ entry.status === 403 &&
402
+ entry.rejection === 'untrusted-host' &&
403
+ entry.ua === null &&
404
+ isLoopback(entry.ip)
405
+ );
406
+ }
407
+
408
+ function entryOf(req, base, status, extra) {
409
+ const path = pathOf(req);
410
+ const entry = {
411
+ at: localIso(),
412
+ method: (req && req.method) || null,
413
+ path,
414
+ status: Number.isFinite(status) && status > 0 ? status : null,
415
+ // C-14:把请求对象一起传下去 —— 器灵 guard 的真因在 `req.__dshLingRejection` 上,
416
+ // 读不到才退回按状态码反推(core 栅栏)。此处是 `rejectionReason` 的**唯一**消费点。
417
+ rejection: rejectionReason(status, (req && req.headers) || {}, req),
418
+ ip: base.ip,
419
+ fwd: base.fwd,
420
+ host: base.host,
421
+ ua: base.ua,
422
+ // 无 UA ⇒ 非浏览器通道(进程内客户端 / 经远端代理进来的设备);不指认是哪一台。
423
+ channel: base.ua === null ? CHANNEL_NO_UA : undefined,
424
+ deviceCookie: base.deviceCookie,
425
+ // C-01:这里是**摘要**(16-hex),不是凭据;`deviceIdHash` 标记形态,老日志没有这个字段。
426
+ deviceId: base.deviceId,
427
+ deviceIdHash: base.deviceIdHash,
428
+ bytes: base.bytes,
429
+ ...extra,
430
+ };
431
+ if (path) {
432
+ const hit = riskPatterns.find((p) => path.includes(p));
433
+ if (hit) entry.risk = hit;
434
+ }
435
+ if (isPostureProbe(entry)) entry.probe = 'posture';
436
+ return entry;
437
+ }
438
+
439
+ /**
440
+ * 观察一个 HTTP 请求:挂最小响应钩子,拿到真实状态码后同步落一行。
441
+ * @param {import('node:http').IncomingMessage} req
442
+ * @param {import('node:http').ServerResponse} res
443
+ */
444
+ function observe(req, res) {
445
+ if (!req || !res) return;
446
+ if (disabled) {
447
+ // C-07:降级后不再试着写盘,但请求还在来 —— 每来一次都是一次**可见的丢失**(计数 + 节流告警)。
448
+ noteLost();
449
+ return;
450
+ }
451
+ let base;
452
+ try {
453
+ base = baseFields(req);
454
+ } catch {
455
+ return;
456
+ }
457
+ const started = Date.now();
458
+ let finished = false;
459
+ let status = 0;
460
+ const finish = () => {
461
+ if (finished) return;
462
+ finished = true;
463
+ try {
464
+ if (status > 0 || Number.isFinite(res.statusCode)) {
465
+ status = status || res.statusCode;
466
+ }
467
+ append(entryOf(req, base, status, { ms: Date.now() - started }));
468
+ } catch {}
469
+ };
470
+ try {
471
+ const origWriteHead = res.writeHead;
472
+ if (typeof origWriteHead === 'function') {
473
+ res.writeHead = function patchedWriteHead(code, ...rest) {
474
+ if (typeof code === 'number' && code > 0) status = code;
475
+ return origWriteHead.call(this, code, ...rest);
476
+ };
477
+ }
478
+ const origEnd = res.end;
479
+ if (typeof origEnd === 'function') {
480
+ res.end = function patchedEnd(...args) {
481
+ try {
482
+ finish();
483
+ } catch {}
484
+ return origEnd.apply(this, args);
485
+ };
486
+ }
487
+ if (typeof res.once === 'function') res.once('close', finish);
488
+ } catch {}
489
+ }
490
+
491
+ /**
492
+ * 观察一次 WebSocket upgrade(/api/remote.mux):这里没有 statusCode 可读,
493
+ * 结局在调用点就已经确定 —— rejection 为空即放行(101),否则就是栅栏码。
494
+ */
495
+ function observeUpgrade(req, rejection) {
496
+ if (!req) return;
497
+ if (disabled) {
498
+ noteLost();
499
+ return;
500
+ }
501
+ try {
502
+ const status = Number.isFinite(rejection) && rejection > 0 ? rejection : 101;
503
+ append(entryOf(req, baseFields(req), status, { phase: 'upgrade' }));
504
+ } catch {}
505
+ }
506
+
507
+ /** 无句柄可关(每次写都开→写→关),保留此方法只为卸载链路的 API 稳定。 */
508
+ function close() {}
509
+
510
+ return {
511
+ observe,
512
+ observeUpgrade,
513
+ append,
514
+ close,
515
+ info: () => ({
516
+ dir,
517
+ file: day ? join(dir, fileNameOf(day)) : join(dir, fileNameOf(localDay())),
518
+ maxTotalBytes,
519
+ deviceCookie,
520
+ riskPatterns,
521
+ disabled,
522
+ // C-07:可机读的健康面 —— `/health`(或界面)读这一个字段即可,不必去猜 stderr 那一行。
523
+ health: {
524
+ state: lastError ? 'degraded' : disabled ? 'off' : 'ok',
525
+ writes,
526
+ failures,
527
+ lost,
528
+ lastError,
529
+ warnedAt: warnAt ? new Date(warnAt).toISOString() : null,
530
+ },
531
+ }),
532
+ };
533
+ }
@@ -0,0 +1,92 @@
1
+ // E4 访问日志 · 插件侧安装器
2
+ //
3
+ // 职责:把日志器挂到 core 补丁预留的两个全局钩子上,并在卸载时干净摘除。
4
+ // 不做的事:不碰鉴权判定、不碰记忆与人格数据(任务书 §3.5)。
5
+ //
6
+ // 一个重要性质:**没有补丁也安全**。钩子只是挂在 globalThis 上的可选调用,
7
+ // 补丁那一行写的是 `globalThis.__dshAccessLogObserve?.(…)` —— 插件在不在、
8
+ // 补丁打没打,DSH 都能照常启动与响应。
9
+
10
+ import { join } from 'node:path';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { dshHome } from '../host/util.js';
13
+ import { createAccessLog } from './access-log.js';
14
+ import { OBSERVE_GLOBAL, UPGRADE_GLOBAL, PATCH_STATE_FILE, PATCH_MARKER } from './patch-spec.js';
15
+
16
+ export { OBSERVE_GLOBAL, UPGRADE_GLOBAL, PATCH_MARKER };
17
+
18
+ /** 当前活着的访问日志器(同一进程重复安装时取最后一份)。 */
19
+ let active = null;
20
+
21
+ /**
22
+ * C-07:访问日志的**健康面**出口 —— `disabled` / `degraded` / 丢了多少行 / 最后一条 errno。
23
+ * `/health`(`lib/host/api.js`)或界面读这一个函数即可;日志器没装时返回 `null`。
24
+ * 为什么要有这个出口:写失败后旧实现只往 stderr 喊一行就永久静默,
25
+ * 而"日志悄悄不记了"是这套取证能力最该被发现的故障 —— 必须有个**常驻可查**的地方。
26
+ */
27
+ export function accessLogHealth() {
28
+ return active ? active.info() : null;
29
+ }
30
+
31
+ /** 读补丁状态文件(补丁脚本写的),拿不到就返回 undefined —— 只用于提示,不影响功能。 */
32
+ export function readPatchState(home = dshHome()) {
33
+ const file = join(home, 'logs', PATCH_STATE_FILE);
34
+ try {
35
+ if (!existsSync(file)) return undefined;
36
+ return JSON.parse(readFileSync(file, 'utf8'));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 安装访问日志。
44
+ * @param {import('@deepseek-ai/cordis').Context} ctx 宿主插件上下文(本实现不用它,保留签名一致)
45
+ * @param {object} cfg dsh-ling config 里的 `accessLog` 段
46
+ * @returns {() => void} disposer
47
+ */
48
+ export function installAccessLog(ctx, cfg = {}) {
49
+ const options = { ...(cfg || {}) };
50
+ if (!options.dir) options.dir = join(dshHome(), 'logs');
51
+ if (options.enabled === false) {
52
+ console.info('[dsh-ling] access log off (config.accessLog.enabled=false)');
53
+ return () => {};
54
+ }
55
+
56
+ const log = createAccessLog(options);
57
+ active = log;
58
+ const previous = { observe: globalThis[OBSERVE_GLOBAL], upgrade: globalThis[UPGRADE_GLOBAL] };
59
+ globalThis[OBSERVE_GLOBAL] = (req, res) => {
60
+ try {
61
+ log.observe(req, res);
62
+ } catch {}
63
+ };
64
+ globalThis[UPGRADE_GLOBAL] = (req, rejection) => {
65
+ try {
66
+ log.observeUpgrade(req, rejection);
67
+ } catch {}
68
+ };
69
+
70
+ const info = log.info();
71
+ console.info('[dsh-ling] access log on; dir=%s deviceCookie=%s', info.dir, info.deviceCookie);
72
+ // 补丁没打时钩子永远收不到调用 —— 这是"静默不记"的唯一形态,必须主动喊出来(任务书 §5.3)。
73
+ const state = readPatchState();
74
+ if (!state || state.marker !== PATCH_MARKER) {
75
+ console.warn('[dsh-ling] core patch missing → run: node tools/apply-access-log-patch.mjs');
76
+ } else if (Array.isArray(state.targets) && state.targets.some((t) => t.state !== 'applied')) {
77
+ const bad = state.targets.filter((t) => t.state !== 'applied').map((t) => t.id).join(', ');
78
+ console.warn('[dsh-ling] core patch incomplete (%s) → re-run tools/apply-access-log-patch.mjs', bad);
79
+ }
80
+
81
+ return () => {
82
+ if (active === log) active = null; // 卸载后不留悬空引用(accessLogHealth() 随之回 null)
83
+ // 必须先摘钩子再关 fd:core 的那一行随时可能还在调用它们。
84
+ if (previous.observe === undefined) delete globalThis[OBSERVE_GLOBAL];
85
+ else globalThis[OBSERVE_GLOBAL] = previous.observe;
86
+ if (previous.upgrade === undefined) delete globalThis[UPGRADE_GLOBAL];
87
+ else globalThis[UPGRADE_GLOBAL] = previous.upgrade;
88
+ try {
89
+ log.close();
90
+ } catch {}
91
+ };
92
+ }