@linxin666/dsh-remote-web-ui 0.2.4 → 0.2.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.i18n.yaml +2 -2
- package/README.md +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +602 -67
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -32
- package/lib/mobile.js +3 -1
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +21 -0
- package/lib/types/client/FenceNotice.d.ts.map +1 -0
- package/lib/types/client/FenceNotice.js +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +18 -11
- package/lib/types/client/RemotePanel.d.ts +10 -4
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -6
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/device-name.d.ts +3 -0
- package/lib/types/client/device-name.d.ts.map +1 -0
- package/lib/types/client/device-name.js +30 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/locales.d.ts +51 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +79 -29
- package/lib/types/client/pair-api.d.ts +35 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +39 -0
- package/lib/types/client/remote-channel.d.ts +93 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -0
- package/lib/types/client/remote-channel.js +246 -0
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +5 -2
- package/lib/types/index.d.ts +19 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +73 -5
- package/lib/types/loopback-proxy.d.ts +28 -0
- package/lib/types/loopback-proxy.d.ts.map +1 -0
- package/lib/types/loopback-proxy.js +112 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +6 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +6 -2
- package/lib/types/mobile-routes.js +1 -1
- package/lib/types/pairing.d.ts +63 -11
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +137 -20
- package/lib/types/pairing.persist.test.js +60 -5
- package/lib/types/posture.d.ts +75 -0
- package/lib/types/posture.d.ts.map +1 -0
- package/lib/types/posture.js +116 -0
- package/lib/types/remote-api.d.ts +60 -0
- package/lib/types/remote-api.d.ts.map +1 -0
- package/lib/types/remote-api.js +181 -0
- package/lib/types/remote-methods.d.ts +33 -0
- package/lib/types/remote-methods.d.ts.map +1 -0
- package/lib/types/remote-methods.js +54 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +33 -4
- package/package.json +2 -2
- package/src/client/FenceNotice.tsx +43 -0
- package/src/client/RemoteEntry.tsx +19 -9
- package/src/client/RemotePanel.tsx +83 -10
- package/src/client/RemoteSettingsCard.tsx +16 -0
- package/src/client/device-name.ts +31 -0
- package/src/client/index.ts +46 -0
- package/src/client/locales.ts +79 -29
- package/src/client/pair-api.ts +60 -0
- package/src/client/remote-channel.ts +269 -0
- package/src/client/remote.module.css +240 -16
- package/src/dsh-home.ts +36 -0
- package/src/gate.ts +5 -2
- package/src/index.ts +89 -17
- package/src/loopback-proxy.ts +126 -0
- package/src/mobile/views/SessionListView.tsx +6 -1
- package/src/mobile-api.ts +6 -2
- package/src/mobile-routes.ts +1 -1
- package/src/pairing.persist.test.ts +64 -5
- package/src/pairing.ts +167 -20
- package/src/posture.ts +154 -0
- package/src/remote-api.ts +217 -0
- package/src/remote-methods.ts +62 -0
- package/src/routes.ts +33 -4
package/lib/types/pairing.js
CHANGED
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
* unknown one (no oracle for validity).
|
|
14
14
|
* - `stop()` revokes every device session and clears the token, so paired
|
|
15
15
|
* devices are cut off on their next gated request.
|
|
16
|
+
* - `revoke()` drops one device session; idle sessions older than
|
|
17
|
+
* `idleExpireMs` are deleted on sweep, load, and the next gated request.
|
|
16
18
|
*/
|
|
17
19
|
import { randomBytes } from 'node:crypto';
|
|
18
20
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
21
|
import { dirname } from 'node:path';
|
|
22
|
+
/** Default idle-expiry window: 7 days without heartbeat or a gated request. */
|
|
23
|
+
export const DEFAULT_IDLE_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
/** Cap on the persisted/displayed User-Agent string. */
|
|
25
|
+
const MAX_USER_AGENT_CHARS = 180;
|
|
20
26
|
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
21
27
|
export class UnknownLanAddressError extends Error {
|
|
22
28
|
/**
|
|
@@ -53,6 +59,9 @@ export class PairingService {
|
|
|
53
59
|
publicBase;
|
|
54
60
|
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
55
61
|
tunnelStatus;
|
|
62
|
+
posture;
|
|
63
|
+
/** True when lastSeenAt changed since the last persist (flushed on sweep). */
|
|
64
|
+
dirty = false;
|
|
56
65
|
/**
|
|
57
66
|
* @param config - tunables. The settings surface replaces the object (a
|
|
58
67
|
* fresh literal) when a committed section changes; every operation reads
|
|
@@ -82,12 +91,19 @@ export class PairingService {
|
|
|
82
91
|
continue;
|
|
83
92
|
if (typeof session !== 'object' || session === null)
|
|
84
93
|
continue;
|
|
85
|
-
const { createdAt, lastSeenAt } = session;
|
|
94
|
+
const { createdAt, lastSeenAt, userAgent } = session;
|
|
86
95
|
if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number')
|
|
87
96
|
continue;
|
|
88
|
-
|
|
97
|
+
const label = typeof userAgent === 'string' ? sanitizeUserAgent(userAgent) : undefined;
|
|
98
|
+
this.devices.set(deviceId, {
|
|
99
|
+
createdAt,
|
|
100
|
+
lastSeenAt,
|
|
101
|
+
...(label !== undefined ? { userAgent: label } : {}),
|
|
102
|
+
});
|
|
89
103
|
}
|
|
90
104
|
this.clampToMaxDevices();
|
|
105
|
+
if (this.evictIdle())
|
|
106
|
+
this.persist();
|
|
91
107
|
}
|
|
92
108
|
catch {
|
|
93
109
|
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
@@ -102,12 +118,24 @@ export class PairingService {
|
|
|
102
118
|
for (const [id] of ordered.slice(0, overflow))
|
|
103
119
|
this.devices.delete(id);
|
|
104
120
|
}
|
|
121
|
+
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
122
|
+
evictIdle() {
|
|
123
|
+
const now = this.clock.now();
|
|
124
|
+
const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
|
|
125
|
+
let removed = false;
|
|
126
|
+
for (const [id, session] of [...this.devices]) {
|
|
127
|
+
if (now - session.lastSeenAt > limit) {
|
|
128
|
+
this.devices.delete(id);
|
|
129
|
+
removed = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return removed;
|
|
133
|
+
}
|
|
105
134
|
/**
|
|
106
135
|
* Write the current device table to the configured file. Called on the
|
|
107
|
-
* mutation boundaries that change the set of live sessions (accept
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* heartbeat re-warms its own session.
|
|
136
|
+
* mutation boundaries that change the set of live sessions (accept, stop,
|
|
137
|
+
* revoke, idle eviction) and, throttled, from sweep() so lastSeenAt
|
|
138
|
+
* survives a restart without a write per request.
|
|
111
139
|
*
|
|
112
140
|
* Device ids are session credentials (the gate authorizes requests by the
|
|
113
141
|
* cookie's device id), so the file is written 0600 via a temp file and
|
|
@@ -120,8 +148,12 @@ export class PairingService {
|
|
|
120
148
|
try {
|
|
121
149
|
mkdirSync(dirname(file), { recursive: true });
|
|
122
150
|
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
123
|
-
|
|
151
|
+
const payload = {};
|
|
152
|
+
for (const [id, session] of this.devices)
|
|
153
|
+
payload[id] = session;
|
|
154
|
+
writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 });
|
|
124
155
|
renameSync(temp, file);
|
|
156
|
+
this.dirty = false;
|
|
125
157
|
}
|
|
126
158
|
catch (error) {
|
|
127
159
|
console.error('remote-web-ui: failed to persist paired devices', error);
|
|
@@ -158,6 +190,11 @@ export class PairingService {
|
|
|
158
190
|
this.tunnelStatus = status;
|
|
159
191
|
this.notify();
|
|
160
192
|
}
|
|
193
|
+
/** Set the latest /api posture probe result (see posture.ts). */
|
|
194
|
+
setPosture(snapshot) {
|
|
195
|
+
this.posture = snapshot;
|
|
196
|
+
this.notify();
|
|
197
|
+
}
|
|
161
198
|
/**
|
|
162
199
|
* Issue a fresh token, replacing (invalidating) any previous one. A
|
|
163
200
|
* stopped service re-arms through this call (the panel's refresh button).
|
|
@@ -198,9 +235,10 @@ export class PairingService {
|
|
|
198
235
|
* successful call for the same token is impossible because the first
|
|
199
236
|
* consumes it.
|
|
200
237
|
* @param token - the token secret from the QR link.
|
|
238
|
+
* @param userAgent - optional User-Agent header captured at accept.
|
|
201
239
|
* @returns the new device id, or a refusal code.
|
|
202
240
|
*/
|
|
203
|
-
accept(token) {
|
|
241
|
+
accept(token, userAgent) {
|
|
204
242
|
const record = this.tokens.get(token);
|
|
205
243
|
if (record === undefined || record.consumed || this.stopped || this.clock.now() > record.expiresAt) {
|
|
206
244
|
return { ok: false, code: record?.consumed === true ? 'used' : 'invalid' };
|
|
@@ -218,7 +256,12 @@ export class PairingService {
|
|
|
218
256
|
if (oldest !== undefined)
|
|
219
257
|
this.devices.delete(oldest.id);
|
|
220
258
|
}
|
|
221
|
-
|
|
259
|
+
const label = sanitizeUserAgent(userAgent);
|
|
260
|
+
this.devices.set(deviceId, {
|
|
261
|
+
createdAt: now,
|
|
262
|
+
lastSeenAt: now,
|
|
263
|
+
...(label !== undefined ? { userAgent: label } : {}),
|
|
264
|
+
});
|
|
222
265
|
this.persist();
|
|
223
266
|
this.notify();
|
|
224
267
|
return { ok: true, deviceId };
|
|
@@ -235,18 +278,34 @@ export class PairingService {
|
|
|
235
278
|
this.stopped = true;
|
|
236
279
|
this.notify();
|
|
237
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Revoke one paired device. The next gated request from that cookie is
|
|
283
|
+
* refused; other sessions stay live. Unknown ids are a no-op.
|
|
284
|
+
* @param deviceId - the cookie value of the device to drop.
|
|
285
|
+
* @returns true when a live session was removed.
|
|
286
|
+
*/
|
|
287
|
+
revoke(deviceId) {
|
|
288
|
+
if (this.stopped)
|
|
289
|
+
return false;
|
|
290
|
+
if (!this.devices.delete(deviceId))
|
|
291
|
+
return false;
|
|
292
|
+
this.persist();
|
|
293
|
+
this.notify();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
238
296
|
/**
|
|
239
297
|
* The api/gate path: record activity for a device id and report whether
|
|
240
298
|
* the request may proceed. Unknown or revoked ids (including any device
|
|
241
|
-
* after stop()) are refused.
|
|
299
|
+
* after stop() or idle expiry) are refused.
|
|
242
300
|
* @param deviceId - the cookie value of the requesting device.
|
|
243
301
|
* @returns true when the device session is live and was refreshed.
|
|
244
302
|
*/
|
|
245
303
|
touchDevice(deviceId) {
|
|
246
|
-
const session = this.
|
|
247
|
-
if (session === undefined
|
|
304
|
+
const session = this.liveSession(deviceId);
|
|
305
|
+
if (session === undefined)
|
|
248
306
|
return false;
|
|
249
307
|
session.lastSeenAt = this.clock.now();
|
|
308
|
+
this.dirty = true;
|
|
250
309
|
this.notify();
|
|
251
310
|
return true;
|
|
252
311
|
}
|
|
@@ -255,17 +314,24 @@ export class PairingService {
|
|
|
255
314
|
return this.touchDevice(deviceId);
|
|
256
315
|
}
|
|
257
316
|
/**
|
|
258
|
-
* Periodic sweep:
|
|
259
|
-
* the
|
|
260
|
-
* the
|
|
317
|
+
* Periodic sweep: drop idle sessions, flush a dirty lastSeenAt, and
|
|
318
|
+
* re-evaluate the derived snapshot (a device aging past the offline
|
|
319
|
+
* window flips the phase to disconnected). Emits only when the snapshot
|
|
320
|
+
* actually changed.
|
|
261
321
|
*/
|
|
262
322
|
sweep() {
|
|
323
|
+
const evicted = this.evictIdle();
|
|
324
|
+
if (evicted || this.dirty)
|
|
325
|
+
this.persist();
|
|
263
326
|
this.notify();
|
|
264
327
|
}
|
|
265
328
|
/** The current snapshot (fresh object per call — stable between emits). */
|
|
266
329
|
snapshot() {
|
|
267
330
|
const now = this.clock.now();
|
|
268
|
-
const
|
|
331
|
+
const devices = [...this.devices.entries()]
|
|
332
|
+
.sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
333
|
+
.map(([id, session]) => this.toDeviceSnapshot(id, session, now));
|
|
334
|
+
const onlineCount = devices.filter(device => device.online).length;
|
|
269
335
|
const token = this.activeToken();
|
|
270
336
|
return {
|
|
271
337
|
phase: this.derivePhase(onlineCount, token !== undefined),
|
|
@@ -273,15 +339,16 @@ export class PairingService {
|
|
|
273
339
|
lanAddresses: [...this.lanBases.keys()],
|
|
274
340
|
...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
|
|
275
341
|
...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
|
|
342
|
+
...(this.posture !== undefined ? { posture: this.posture } : {}),
|
|
276
343
|
...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
|
|
277
344
|
deviceCount: this.devices.size,
|
|
278
345
|
onlineCount,
|
|
346
|
+
devices,
|
|
279
347
|
};
|
|
280
348
|
}
|
|
281
|
-
/** Whether a cookie value names a currently live device session. */
|
|
349
|
+
/** Whether a cookie value names a currently live (non-idle) device session. */
|
|
282
350
|
hasDevice(deviceId) {
|
|
283
|
-
|
|
284
|
-
return session !== undefined && !this.stopped;
|
|
351
|
+
return this.liveSession(deviceId) !== undefined;
|
|
285
352
|
}
|
|
286
353
|
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
287
354
|
onState(listener) {
|
|
@@ -298,6 +365,34 @@ export class PairingService {
|
|
|
298
365
|
}
|
|
299
366
|
return undefined;
|
|
300
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Return a live session, deleting it first when idle-expired. Side-effecting
|
|
370
|
+
* so a stale cookie cannot pass the gate between sweeps.
|
|
371
|
+
*/
|
|
372
|
+
liveSession(deviceId) {
|
|
373
|
+
if (this.stopped)
|
|
374
|
+
return undefined;
|
|
375
|
+
const session = this.devices.get(deviceId);
|
|
376
|
+
if (session === undefined)
|
|
377
|
+
return undefined;
|
|
378
|
+
const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
|
|
379
|
+
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
380
|
+
this.devices.delete(deviceId);
|
|
381
|
+
this.persist();
|
|
382
|
+
this.notify();
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
return session;
|
|
386
|
+
}
|
|
387
|
+
toDeviceSnapshot(id, session, now) {
|
|
388
|
+
return {
|
|
389
|
+
id,
|
|
390
|
+
createdAt: session.createdAt,
|
|
391
|
+
lastSeenAt: session.lastSeenAt,
|
|
392
|
+
online: this.isOnlineAt(session, now),
|
|
393
|
+
...(session.userAgent !== undefined ? { userAgent: session.userAgent } : {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
301
396
|
derivePhase(onlineCount, hasToken) {
|
|
302
397
|
if (this.lanBases.size === 0 && this.publicBase === undefined)
|
|
303
398
|
return 'lan-required';
|
|
@@ -340,7 +435,20 @@ function snapshotsEqual(a, b) {
|
|
|
340
435
|
&& a.tokenId === b.tokenId
|
|
341
436
|
&& a.tokenExpiresAt === b.tokenExpiresAt
|
|
342
437
|
&& a.deviceCount === b.deviceCount
|
|
343
|
-
&& a.onlineCount === b.onlineCount
|
|
438
|
+
&& a.onlineCount === b.onlineCount
|
|
439
|
+
&& devicesEqual(a.devices, b.devices);
|
|
440
|
+
}
|
|
441
|
+
/** Per-device roster equality (order is pairing time). */
|
|
442
|
+
function devicesEqual(a, b) {
|
|
443
|
+
return a.length === b.length && a.every((device, index) => {
|
|
444
|
+
const other = b[index];
|
|
445
|
+
return other !== undefined
|
|
446
|
+
&& device.id === other.id
|
|
447
|
+
&& device.createdAt === other.createdAt
|
|
448
|
+
&& device.lastSeenAt === other.lastSeenAt
|
|
449
|
+
&& device.online === other.online
|
|
450
|
+
&& device.userAgent === other.userAgent;
|
|
451
|
+
});
|
|
344
452
|
}
|
|
345
453
|
/** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
|
|
346
454
|
function tunnelEqual(a, b) {
|
|
@@ -351,3 +459,12 @@ function tunnelEqual(a, b) {
|
|
|
351
459
|
function sameStrings(a, b) {
|
|
352
460
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
353
461
|
}
|
|
462
|
+
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
463
|
+
export function sanitizeUserAgent(raw) {
|
|
464
|
+
if (raw === undefined)
|
|
465
|
+
return undefined;
|
|
466
|
+
const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
467
|
+
if (cleaned === '')
|
|
468
|
+
return undefined;
|
|
469
|
+
return cleaned.length <= MAX_USER_AGENT_CHARS ? cleaned : cleaned.slice(0, MAX_USER_AGENT_CHARS);
|
|
470
|
+
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PairingService device-session persistence: with `devicesFile` set, sessions
|
|
3
|
-
* written on accept/stop survive a process restart (a new
|
|
4
|
-
* instance)
|
|
5
|
-
*
|
|
3
|
+
* written on accept/stop/revoke/sweep survive a process restart (a new
|
|
4
|
+
* PairingService instance). lastSeenAt is flushed on sweep, not on every
|
|
5
|
+
* touch; idle sessions older than idleExpireMs are dropped on load.
|
|
6
6
|
*/
|
|
7
7
|
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
11
11
|
import { pairingConfigOf } from './index.js';
|
|
12
|
-
import { PairingService } from './pairing.js';
|
|
12
|
+
import { DEFAULT_IDLE_EXPIRE_MS, PairingService } from './pairing.js';
|
|
13
13
|
const BASE_CONFIG = {
|
|
14
14
|
tokenTtlMs: 60_000,
|
|
15
15
|
offlineAfterMs: 25_000,
|
|
16
16
|
maxDevices: 4,
|
|
17
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
17
18
|
cookieName: 'dsh_pair',
|
|
18
19
|
};
|
|
19
20
|
/** Deterministic clock: tokens are issued in a fixed, readable sequence. */
|
|
@@ -47,7 +48,11 @@ describe('PairingService device persistence', () => {
|
|
|
47
48
|
const deviceId = pairDevice(first);
|
|
48
49
|
expect(readFileSync(file, 'utf8')).toContain(deviceId);
|
|
49
50
|
// Device ids are session credentials: the persisted file is 0600.
|
|
50
|
-
|
|
51
|
+
// win32: NTFS has no POSIX mode bits and Node ignores the writeFileSync
|
|
52
|
+
// mode option there, so the mode assertion only applies on POSIX (#772).
|
|
53
|
+
if (process.platform !== 'win32') {
|
|
54
|
+
expect(statSync(file).mode & 0o777).toBe(0o600);
|
|
55
|
+
}
|
|
51
56
|
// Simulated restart: a brand-new service instance reading the same file.
|
|
52
57
|
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
|
|
53
58
|
expect(second.hasDevice(deviceId)).toBe(true);
|
|
@@ -58,6 +63,7 @@ describe('PairingService device persistence', () => {
|
|
|
58
63
|
tokenTtlMs: 60_000,
|
|
59
64
|
offlineAfterMs: 25_000,
|
|
60
65
|
maxDevices: 4,
|
|
66
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
61
67
|
cookieName: 'dsh_pair',
|
|
62
68
|
devicesFile: file,
|
|
63
69
|
});
|
|
@@ -72,6 +78,7 @@ describe('PairingService device persistence', () => {
|
|
|
72
78
|
tokenTtlMs: 60_000,
|
|
73
79
|
offlineAfterMs: 25_000,
|
|
74
80
|
maxDevices: 4,
|
|
81
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
75
82
|
cookieName: 'dsh_pair',
|
|
76
83
|
devicesFile: file,
|
|
77
84
|
});
|
|
@@ -125,4 +132,52 @@ describe('PairingService device persistence', () => {
|
|
|
125
132
|
const ghost = join(dir, 'does-not-exist.json');
|
|
126
133
|
expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow();
|
|
127
134
|
});
|
|
135
|
+
it('does not persist lastSeenAt on touch; sweep flushes it', () => {
|
|
136
|
+
const file = join(dir, 'devices.json');
|
|
137
|
+
const now = { value: 1_000_000 };
|
|
138
|
+
let n = 0;
|
|
139
|
+
const clock = {
|
|
140
|
+
now: () => now.value,
|
|
141
|
+
randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
|
|
142
|
+
};
|
|
143
|
+
const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
|
|
144
|
+
const deviceId = pairDevice(first);
|
|
145
|
+
const afterAccept = JSON.parse(readFileSync(file, 'utf8'));
|
|
146
|
+
expect(afterAccept[deviceId]?.lastSeenAt).toBe(1_000_000);
|
|
147
|
+
now.value = 1_500_000;
|
|
148
|
+
expect(first.touchDevice(deviceId)).toBe(true);
|
|
149
|
+
const afterTouch = JSON.parse(readFileSync(file, 'utf8'));
|
|
150
|
+
expect(afterTouch[deviceId]?.lastSeenAt).toBe(1_000_000);
|
|
151
|
+
first.sweep();
|
|
152
|
+
const afterSweep = JSON.parse(readFileSync(file, 'utf8'));
|
|
153
|
+
expect(afterSweep[deviceId]?.lastSeenAt).toBe(1_500_000);
|
|
154
|
+
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
|
|
155
|
+
expect(second.hasDevice(deviceId)).toBe(true);
|
|
156
|
+
expect(second.snapshot().devices[0]?.lastSeenAt).toBe(1_500_000);
|
|
157
|
+
});
|
|
158
|
+
it('drops idle sessions on load instead of restoring them', () => {
|
|
159
|
+
const file = join(dir, 'devices.json');
|
|
160
|
+
writeFileSync(file, JSON.stringify({
|
|
161
|
+
stale: { createdAt: 1, lastSeenAt: 1 },
|
|
162
|
+
}));
|
|
163
|
+
const clock = {
|
|
164
|
+
now: () => 1 + DEFAULT_IDLE_EXPIRE_MS + 1,
|
|
165
|
+
randomToken: () => 'tok',
|
|
166
|
+
};
|
|
167
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock);
|
|
168
|
+
expect(service.hasDevice('stale')).toBe(false);
|
|
169
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
170
|
+
expect(saved).toEqual({});
|
|
171
|
+
});
|
|
172
|
+
it('persists a User-Agent captured at accept', () => {
|
|
173
|
+
const file = join(dir, 'devices.json');
|
|
174
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
|
|
175
|
+
service.setPublicBaseUrl('https://pairing.example.trycloudflare.com');
|
|
176
|
+
const { token } = service.issue();
|
|
177
|
+
const result = service.accept(token, 'Mozilla/5.0 TestPhone');
|
|
178
|
+
expect(result.ok).toBe(true);
|
|
179
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
180
|
+
const deviceId = result.ok ? result.deviceId : '';
|
|
181
|
+
expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone');
|
|
182
|
+
});
|
|
128
183
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment posture probe: verify that the connection plugin's `/api` fence
|
|
3
|
+
* actually refuses non-loopback requests for every origin this deployment
|
|
4
|
+
* advertises (the public tunnel host, the LAN bases). The fence is the SDK's
|
|
5
|
+
* own Host check — the one seam a plugin cannot mount a gate into — so this
|
|
6
|
+
* probe is the guardrail that makes a re-opened `/api` (for example a
|
|
7
|
+
* re-added `--trusted-host`, or the SDK's LAN-literal auto-trust under
|
|
8
|
+
* `--host 0.0.0.0`) visible instead of silent.
|
|
9
|
+
*
|
|
10
|
+
* The probe issues loopback requests with a forged Host header — the exact
|
|
11
|
+
* shape a tunnel or LAN client produces — and treats anything other than a
|
|
12
|
+
* 403 as exposed: the fence is documented to refuse with 403, so any other
|
|
13
|
+
* status means the request reached the RPC bridge.
|
|
14
|
+
*/
|
|
15
|
+
import type { ClientRequest, ClientRequestArgs } from 'node:http';
|
|
16
|
+
/** One probed origin. */
|
|
17
|
+
export interface PostureHost {
|
|
18
|
+
/** The forged Host header value that was probed. */
|
|
19
|
+
host: string;
|
|
20
|
+
/** True when the probe reached past the fence (anything but 403). */
|
|
21
|
+
exposed: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** One probe round. */
|
|
24
|
+
export interface PostureSnapshot {
|
|
25
|
+
/** When the round completed (epoch ms). */
|
|
26
|
+
checkedAt: number;
|
|
27
|
+
/** Every probed origin and its verdict. */
|
|
28
|
+
hosts: PostureHost[];
|
|
29
|
+
}
|
|
30
|
+
/** Whether any probed origin is currently exposed. */
|
|
31
|
+
export declare function anyExposed(snapshot: PostureSnapshot): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Build the forged Host values to probe: the public base authority (host or
|
|
34
|
+
* host:port as written in the URL) plus every LAN base literal.
|
|
35
|
+
* @param publicBaseUrl - the configured public base URL (or undefined).
|
|
36
|
+
* @param lanAddresses - the LAN interface addresses the QR advertises.
|
|
37
|
+
* @param port - the local webServer port (LAN hosts are probed as host:port).
|
|
38
|
+
* @returns Host header values, de-duplicated.
|
|
39
|
+
*/
|
|
40
|
+
export declare function postureTargets(publicBaseUrl: string | undefined, lanAddresses: string[], port: number): string[];
|
|
41
|
+
/** Injectable transport seam (defaults to node:http). */
|
|
42
|
+
export type ProbeRequest = (options: ClientRequestArgs, onStatus: (status: number) => void) => ClientRequest;
|
|
43
|
+
/** Probe options. */
|
|
44
|
+
export interface ProbePostureOptions {
|
|
45
|
+
/** The local webServer port. */
|
|
46
|
+
port: number;
|
|
47
|
+
/** Forged Host header values (see {@link postureTargets}). */
|
|
48
|
+
targets: string[];
|
|
49
|
+
/** Transport seam (defaults to node:http). */
|
|
50
|
+
request?: ProbeRequest;
|
|
51
|
+
/** Per-host timeout (default 3000 ms). */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** Clock seam (default Date.now). */
|
|
54
|
+
now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Run one posture probe round.
|
|
58
|
+
* @returns the snapshot for this round.
|
|
59
|
+
*/
|
|
60
|
+
export declare function probePosture(options: ProbePostureOptions): Promise<PostureSnapshot>;
|
|
61
|
+
/**
|
|
62
|
+
* Reserve an advertised-target key so a second trigger with the same set
|
|
63
|
+
* does not overlap an in-flight round. Pair with {@link releasePostureKey}
|
|
64
|
+
* on failure — otherwise that key never retries.
|
|
65
|
+
*/
|
|
66
|
+
export declare function claimPostureKey(current: string | undefined, key: string): {
|
|
67
|
+
run: boolean;
|
|
68
|
+
next: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Drop a failed in-flight key so the next trigger re-probes the same targets.
|
|
72
|
+
* A newer key that started meanwhile is left alone.
|
|
73
|
+
*/
|
|
74
|
+
export declare function releasePostureKey(current: string | undefined, attempted: string): string | undefined;
|
|
75
|
+
//# sourceMappingURL=posture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAGjE,yBAAyB;AACzB,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,uBAAuB;AACvB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,KAAK,EAAE,WAAW,EAAE,CAAA;CACrB;AAED,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAehH;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,aAAa,CAAA;AAyC5G,qBAAqB;AACrB,MAAM,WAAW,mBAAmB;IAClC,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAWzF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAGxG;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpG"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment posture probe: verify that the connection plugin's `/api` fence
|
|
3
|
+
* actually refuses non-loopback requests for every origin this deployment
|
|
4
|
+
* advertises (the public tunnel host, the LAN bases). The fence is the SDK's
|
|
5
|
+
* own Host check — the one seam a plugin cannot mount a gate into — so this
|
|
6
|
+
* probe is the guardrail that makes a re-opened `/api` (for example a
|
|
7
|
+
* re-added `--trusted-host`, or the SDK's LAN-literal auto-trust under
|
|
8
|
+
* `--host 0.0.0.0`) visible instead of silent.
|
|
9
|
+
*
|
|
10
|
+
* The probe issues loopback requests with a forged Host header — the exact
|
|
11
|
+
* shape a tunnel or LAN client produces — and treats anything other than a
|
|
12
|
+
* 403 as exposed: the fence is documented to refuse with 403, so any other
|
|
13
|
+
* status means the request reached the RPC bridge.
|
|
14
|
+
*/
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
/** Whether any probed origin is currently exposed. */
|
|
17
|
+
export function anyExposed(snapshot) {
|
|
18
|
+
return snapshot.hosts.some(host => host.exposed);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the forged Host values to probe: the public base authority (host or
|
|
22
|
+
* host:port as written in the URL) plus every LAN base literal.
|
|
23
|
+
* @param publicBaseUrl - the configured public base URL (or undefined).
|
|
24
|
+
* @param lanAddresses - the LAN interface addresses the QR advertises.
|
|
25
|
+
* @param port - the local webServer port (LAN hosts are probed as host:port).
|
|
26
|
+
* @returns Host header values, de-duplicated.
|
|
27
|
+
*/
|
|
28
|
+
export function postureTargets(publicBaseUrl, lanAddresses, port) {
|
|
29
|
+
const targets = [];
|
|
30
|
+
if (publicBaseUrl !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(publicBaseUrl);
|
|
33
|
+
const authority = url.port === '' ? url.hostname : `${url.hostname}:${url.port}`;
|
|
34
|
+
if (authority !== '')
|
|
35
|
+
targets.push(authority);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A malformed base is ignored elsewhere with a warning; nothing to probe.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const address of lanAddresses) {
|
|
42
|
+
targets.push(`${address}:${String(port)}`);
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(targets)];
|
|
45
|
+
}
|
|
46
|
+
const defaultRequest = (options, onStatus) => {
|
|
47
|
+
const request = http.request(options, response => { onStatus(response.statusCode ?? 0); });
|
|
48
|
+
request.on('error', () => { onStatus(0); });
|
|
49
|
+
return request;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Probe one forged Host against the local `/api` fence.
|
|
53
|
+
* @param port - the local webServer port.
|
|
54
|
+
* @param hostHeader - the Host header to forge.
|
|
55
|
+
* @param request - transport seam.
|
|
56
|
+
* @param timeoutMs - give up after this long (counts as not exposed; the
|
|
57
|
+
* fence being unreachable is not evidence it is open).
|
|
58
|
+
* @returns true when the probe got past the fence.
|
|
59
|
+
*/
|
|
60
|
+
async function probeHost(port, hostHeader, request, timeoutMs) {
|
|
61
|
+
return await new Promise(resolve => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (exposed) => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
handle.destroy();
|
|
69
|
+
resolve(exposed);
|
|
70
|
+
};
|
|
71
|
+
const handle = request({
|
|
72
|
+
host: '127.0.0.1',
|
|
73
|
+
port,
|
|
74
|
+
method: 'POST',
|
|
75
|
+
path: '/api/session.list',
|
|
76
|
+
headers: { host: hostHeader, 'content-type': 'application/json' },
|
|
77
|
+
timeout: timeoutMs,
|
|
78
|
+
}, status => { finish(status !== 403); });
|
|
79
|
+
const timer = setTimeout(() => { finish(false); }, timeoutMs + 1_000);
|
|
80
|
+
handle.on('error', () => { finish(false); });
|
|
81
|
+
handle.end('{}');
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Run one posture probe round.
|
|
86
|
+
* @returns the snapshot for this round.
|
|
87
|
+
*/
|
|
88
|
+
export async function probePosture(options) {
|
|
89
|
+
const { port, targets } = options;
|
|
90
|
+
const request = options.request ?? defaultRequest;
|
|
91
|
+
const timeoutMs = options.timeoutMs ?? 3_000;
|
|
92
|
+
const now = options.now ?? (() => Date.now());
|
|
93
|
+
const hosts = [];
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
// Sequential on purpose: one slow host must not multiply the round's latency.
|
|
96
|
+
hosts.push({ host: target, exposed: await probeHost(port, target, request, timeoutMs) });
|
|
97
|
+
}
|
|
98
|
+
return { checkedAt: now(), hosts };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reserve an advertised-target key so a second trigger with the same set
|
|
102
|
+
* does not overlap an in-flight round. Pair with {@link releasePostureKey}
|
|
103
|
+
* on failure — otherwise that key never retries.
|
|
104
|
+
*/
|
|
105
|
+
export function claimPostureKey(current, key) {
|
|
106
|
+
if (current === key)
|
|
107
|
+
return { run: false, next: current };
|
|
108
|
+
return { run: true, next: key };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Drop a failed in-flight key so the next trigger re-probes the same targets.
|
|
112
|
+
* A newer key that started meanwhile is left alone.
|
|
113
|
+
*/
|
|
114
|
+
export function releasePostureKey(current, attempted) {
|
|
115
|
+
return current === attempted ? undefined : current;
|
|
116
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote desktop data channel: `/remote` is this plugin's own prefix, so
|
|
3
|
+
* the paired-device cookie is the access control (exactly like `/m/api`).
|
|
4
|
+
* After that gate, every fenced same-origin path the browser rewrote here is
|
|
5
|
+
* re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
|
|
6
|
+
* fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
|
|
7
|
+
* and no per-plugin pairing consult.
|
|
8
|
+
*
|
|
9
|
+
* Security model:
|
|
10
|
+
* - Every request must carry a live paired-device cookie, enforced before
|
|
11
|
+
* any bytes are forwarded and before any host call.
|
|
12
|
+
* - The SDK's loopback-only privileged methods (native dialogs, the settings
|
|
13
|
+
* plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
|
|
14
|
+
* are denied here. The set is pinned by tests/remote-contract.spec.ts.
|
|
15
|
+
* - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
|
|
16
|
+
* `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
|
|
17
|
+
* - Everything else is HTTP- or WebSocket-proxied to the local port with
|
|
18
|
+
* Host rewritten, Origin and cookies dropped, and a synthetic same-origin
|
|
19
|
+
* browser marker added after authentication. Plugin loopback fences then
|
|
20
|
+
* pass. The pairing cookie never leaves this process.
|
|
21
|
+
*/
|
|
22
|
+
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
23
|
+
import type { PairingService } from './pairing.ts';
|
|
24
|
+
export { DESKTOP_LAUNCHER_PATH, LOOPBACK_ONLY_METHODS, PLUGIN_MANAGER_PATH, REMOTE_API_PATHS, REMOTE_PREFIX, REMOTE_UPGRADE_PATHS, WEB_UI_SETTINGS_BRIDGE_PATH, } from './remote-methods.ts';
|
|
25
|
+
export { REMOTE_API_PREFIX } from './remote-methods.ts';
|
|
26
|
+
/** Route-family dependencies. */
|
|
27
|
+
export interface RemoteApiDeps {
|
|
28
|
+
/** The pairing service (device gate + cookie name). */
|
|
29
|
+
service: PairingService;
|
|
30
|
+
/** The local webServer port the loopback proxy connects to. */
|
|
31
|
+
port: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Map `/remote/...` to the inner path, or undefined when the outer path is
|
|
35
|
+
* not a safe rewrite target.
|
|
36
|
+
*/
|
|
37
|
+
export declare function innerPathOf(pathname: string): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a paired inner path must stay physically local.
|
|
40
|
+
* @returns a denial message, or undefined when the path may be proxied.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loopbackOnlyDenial(innerPath: string): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Build the remote desktop channel HTTP routes.
|
|
45
|
+
* @param deps - pairing service + local port.
|
|
46
|
+
* @returns the routes to register on webServer.
|
|
47
|
+
*/
|
|
48
|
+
export declare function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[];
|
|
49
|
+
/**
|
|
50
|
+
* Map one outer upgrade URL onto the loopback path (query string included).
|
|
51
|
+
*/
|
|
52
|
+
export declare function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Build the WebSocket upgrade routes for the event streams and known plugin
|
|
55
|
+
* sockets. webServer matches upgrades by exact path.
|
|
56
|
+
* @param deps - pairing service + local port.
|
|
57
|
+
* @returns the upgrade routes to register on webServer.
|
|
58
|
+
*/
|
|
59
|
+
export declare function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[];
|
|
60
|
+
//# sourceMappingURL=remote-api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAclD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;CACb;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBxE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAsCnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAwBjF"}
|