@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0
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 +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time pairing grants for the /pair-app landing.
|
|
3
|
+
*
|
|
4
|
+
* The QR entry (`/pair-accept?pair=<token>`) used to hand the freshly minted
|
|
5
|
+
* device id to the app landing in its own URL (`/pair-app?device=<id>`). A
|
|
6
|
+
* device id is a live session credential: the channel gate authorizes every
|
|
7
|
+
* gated request by it and the cookie it mirrors lives a year, so a URL carrying
|
|
8
|
+
* it leaks that credential into browser history, the address bar, and every log
|
|
9
|
+
* on the path. The landing is reached with a one-time grant instead — 256 bits
|
|
10
|
+
* of entropy, a short TTL, and single-consume semantics — and the device id
|
|
11
|
+
* reaches the client only inside the server-rendered shell (the capture script
|
|
12
|
+
* the landing patches in).
|
|
13
|
+
*
|
|
14
|
+
* Consume semantics follow the reference implementation's one-time host
|
|
15
|
+
* capability (zcode `packages/server/src/hostCapability.ts`): the record is
|
|
16
|
+
* deleted on EVERY consume attempt — success, expiry, or replay — so a captured
|
|
17
|
+
* grant is worthless after its first use and a replayed one can never resolve,
|
|
18
|
+
* and only a first use inside the TTL yields a device.
|
|
19
|
+
*
|
|
20
|
+
* @module @linxin666/dsh-remote-web-ui/pair-grant
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
/**
|
|
24
|
+
* Default grant lifetime. The grant only has to survive one redirect chain
|
|
25
|
+
* from the accept page to the app landing, tunnel round trips included, so it
|
|
26
|
+
* is far shorter than the device session it stands in for.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_PAIR_GRANT_TTL_MS = 60_000;
|
|
29
|
+
/**
|
|
30
|
+
* Cap on live grants. The accept page is rate-limited per source, but the
|
|
31
|
+
* table is still caller-driven, so it stays bounded (oldest evicted first)
|
|
32
|
+
* exactly like the device table.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_LIVE_GRANTS = 256;
|
|
35
|
+
/**
|
|
36
|
+
* Build the grant table. Pure TypeScript with injected clock/randomness so the
|
|
37
|
+
* whole one-time semantics are unit-testable without a server.
|
|
38
|
+
*/
|
|
39
|
+
export function createPairGrantStore(options = {}) {
|
|
40
|
+
const ttlMs = options.ttlMs ?? DEFAULT_PAIR_GRANT_TTL_MS;
|
|
41
|
+
const now = options.now ?? (() => Date.now());
|
|
42
|
+
const createGrant = options.createGrant ?? (() => randomBytes(32).toString('base64url'));
|
|
43
|
+
const entries = new Map();
|
|
44
|
+
const purgeExpired = (at) => {
|
|
45
|
+
for (const [grant, entry] of entries) {
|
|
46
|
+
if (entry.expiresAt <= at)
|
|
47
|
+
entries.delete(grant);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
issue(deviceId) {
|
|
52
|
+
const issuedAt = now();
|
|
53
|
+
purgeExpired(issuedAt);
|
|
54
|
+
while (entries.size >= MAX_LIVE_GRANTS) {
|
|
55
|
+
const oldest = entries.keys().next().value;
|
|
56
|
+
if (oldest === undefined)
|
|
57
|
+
break;
|
|
58
|
+
entries.delete(oldest);
|
|
59
|
+
}
|
|
60
|
+
const grant = createGrant();
|
|
61
|
+
const expiresAt = issuedAt + ttlMs;
|
|
62
|
+
entries.set(grant, { deviceId, expiresAt });
|
|
63
|
+
return { grant, expiresAt };
|
|
64
|
+
},
|
|
65
|
+
consume(grant) {
|
|
66
|
+
if (grant === undefined || grant === '')
|
|
67
|
+
return undefined;
|
|
68
|
+
const consumedAt = now();
|
|
69
|
+
const entry = entries.get(grant);
|
|
70
|
+
// Delete before validating: a replayed grant must never resolve, and an
|
|
71
|
+
// expired one must not linger in the table.
|
|
72
|
+
entries.delete(grant);
|
|
73
|
+
purgeExpired(consumedAt);
|
|
74
|
+
if (entry === undefined || entry.expiresAt <= consumedAt)
|
|
75
|
+
return undefined;
|
|
76
|
+
return entry.deviceId;
|
|
77
|
+
},
|
|
78
|
+
peek(grant) {
|
|
79
|
+
if (grant === undefined || grant === '')
|
|
80
|
+
return undefined;
|
|
81
|
+
const at = now();
|
|
82
|
+
const entry = entries.get(grant);
|
|
83
|
+
if (entry === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (entry.expiresAt <= at) {
|
|
86
|
+
entries.delete(grant);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return entry.deviceId;
|
|
90
|
+
},
|
|
91
|
+
get size() {
|
|
92
|
+
return entries.size;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/types/pairing.d.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* Security invariants:
|
|
8
8
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
9
9
|
* immediately invalidates the previous link.
|
|
10
|
-
* - A token
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - A token is a bearer credential for its whole window: it is refused only
|
|
11
|
+
* when unknown, expired, or after `stop()`, and the same link may pair
|
|
12
|
+
* repeatedly within that window (each accept mints a fresh device session).
|
|
13
13
|
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
14
14
|
* in-app browser to the system browser), and the later context must be
|
|
15
15
|
* able to complete its own pairing from the same link.
|
|
@@ -39,8 +39,6 @@ export interface TokenRecord {
|
|
|
39
39
|
issuedAt: number;
|
|
40
40
|
/** Absolute expiry (ms epoch); accept() past this is refused. */
|
|
41
41
|
expiresAt: number;
|
|
42
|
-
/** Consumed by the first successful accept(). */
|
|
43
|
-
consumed: boolean;
|
|
44
42
|
/** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
|
|
45
43
|
id: string;
|
|
46
44
|
/** Workspace the QR link should land the phone in (optional). */
|
|
@@ -150,13 +148,17 @@ export interface PairingConfig {
|
|
|
150
148
|
*/
|
|
151
149
|
devicesFile?: string;
|
|
152
150
|
}
|
|
153
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Result of one accept() attempt. A token is a bearer credential for its whole
|
|
153
|
+
* window (see the module doc), so the only refusal is `invalid`: unknown,
|
|
154
|
+
* expired, or after stop(). There is no "already used" outcome.
|
|
155
|
+
*/
|
|
154
156
|
export type AcceptResult = {
|
|
155
157
|
ok: true;
|
|
156
158
|
deviceId: string;
|
|
157
159
|
} | {
|
|
158
160
|
ok: false;
|
|
159
|
-
code: 'invalid'
|
|
161
|
+
code: 'invalid';
|
|
160
162
|
};
|
|
161
163
|
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
162
164
|
export declare class UnknownLanAddressError extends Error {
|
|
@@ -170,6 +172,11 @@ export interface PairingClock {
|
|
|
170
172
|
now(): number;
|
|
171
173
|
randomToken(): string;
|
|
172
174
|
}
|
|
175
|
+
/** File-operation seam for the revocation-durability step (tests). */
|
|
176
|
+
export interface PairingFsSeams {
|
|
177
|
+
/** Remove the stale device store (defaults to fs.rmSync with force). */
|
|
178
|
+
removeFile?(path: string): void;
|
|
179
|
+
}
|
|
173
180
|
/** Real clock/entropy: 32 random hex chars per token. */
|
|
174
181
|
export declare const defaultClock: PairingClock;
|
|
175
182
|
/**
|
|
@@ -184,6 +191,7 @@ export declare const defaultClock: PairingClock;
|
|
|
184
191
|
export declare class PairingService {
|
|
185
192
|
config: PairingConfig;
|
|
186
193
|
private readonly clock;
|
|
194
|
+
private readonly fs;
|
|
187
195
|
private readonly tokens;
|
|
188
196
|
private readonly devices;
|
|
189
197
|
private readonly listeners;
|
|
@@ -206,7 +214,7 @@ export declare class PairingService {
|
|
|
206
214
|
* the current one.
|
|
207
215
|
* @param clock - clock/entropy source (injectable for tests).
|
|
208
216
|
*/
|
|
209
|
-
constructor(config: PairingConfig, clock?: PairingClock);
|
|
217
|
+
constructor(config: PairingConfig, clock?: PairingClock, fs?: PairingFsSeams);
|
|
210
218
|
/**
|
|
211
219
|
* Restore device sessions persisted by a previous process run. A corrupt
|
|
212
220
|
* or missing file is tolerated (an empty device table, never a throw) —
|
|
@@ -228,6 +236,16 @@ export declare class PairingService {
|
|
|
228
236
|
* atomic rename; a crash mid-write can never leave a half-written store.
|
|
229
237
|
*/
|
|
230
238
|
private persist;
|
|
239
|
+
/**
|
|
240
|
+
* Persist a table change that REVOKES access (stop, per-device revoke, idle
|
|
241
|
+
* eviction). A failed write would leave the revoked session on disk, and
|
|
242
|
+
* {@link loadPersisted} restores that file verbatim on the next start — the
|
|
243
|
+
* device's still-valid cookie would authorize again after an explicit
|
|
244
|
+
* revocation. When the write cannot be made durable, the stale store is
|
|
245
|
+
* removed instead (a missing file already loads as an empty table), so the
|
|
246
|
+
* failure costs a re-pair rather than silently undoing the revocation.
|
|
247
|
+
*/
|
|
248
|
+
private persistRevocation;
|
|
231
249
|
/** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
|
|
232
250
|
get lanBaseUrl(): string | undefined;
|
|
233
251
|
/** The LAN base URL for one specific literal (undefined when not constructible). */
|
|
@@ -241,7 +259,12 @@ export declare class PairingService {
|
|
|
241
259
|
}[]): void;
|
|
242
260
|
/** The configured public (tunneled) base URL, when present. */
|
|
243
261
|
get publicBaseUrl(): string | undefined;
|
|
244
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Set or clear the public base URL (a tunnel in front of this server). The
|
|
264
|
+
* value is canonicalized to its origin: a trailing slash (what a browser
|
|
265
|
+
* address-bar copy produces) or a path would mint a dead `//pair-accept`
|
|
266
|
+
* link, which WHATWG resolves as an authority rather than a path.
|
|
267
|
+
*/
|
|
245
268
|
setPublicBaseUrl(url: string | undefined): void;
|
|
246
269
|
/** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
|
|
247
270
|
setTunnelStatus(status: TunnelStatus | undefined): void;
|
|
@@ -266,9 +289,11 @@ export declare class PairingService {
|
|
|
266
289
|
expiresAt: number;
|
|
267
290
|
};
|
|
268
291
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
292
|
+
* Redeem a token and bind a device session. The token is a bearer
|
|
293
|
+
* credential for its whole window, not a single-use nonce: it is refused
|
|
294
|
+
* only when unknown, past its expiry, or after stop(), and every successful
|
|
295
|
+
* call — including a repeat within the same window — mints a fresh device
|
|
296
|
+
* session (see the module doc for the mobile cookie-context rationale).
|
|
272
297
|
* @param token - the token secret from the QR link.
|
|
273
298
|
* @param userAgent - optional User-Agent header captured at accept.
|
|
274
299
|
* @returns the new device id, or a refusal code.
|
|
@@ -327,6 +352,13 @@ export declare class PairingService {
|
|
|
327
352
|
private isOnlineAt;
|
|
328
353
|
private notify;
|
|
329
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Canonical origin of a public base URL. An unparsable value is returned
|
|
357
|
+
* verbatim so the caller's malformed-base warning stays the single owner of
|
|
358
|
+
* that case.
|
|
359
|
+
* @param url - the configured public base.
|
|
360
|
+
*/
|
|
361
|
+
export declare function canonicalBaseUrl(url: string): string;
|
|
330
362
|
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
331
363
|
export declare function sanitizeUserAgent(raw: string | undefined): string | undefined;
|
|
332
364
|
//# sourceMappingURL=pairing.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAEnD,uDAAuD;AACvD,MAAM,MAAM,YAAY;AACtB,mEAAmE;AACjE,cAAc;AAChB,oEAAoE;GAClE,SAAS;AACX,4DAA4D;GAC1D,SAAS;AACX,sDAAsD;GACpD,WAAW;AACb,6EAA6E;GAC3E,cAAc,CAAA;AAElB,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAA;IACjB,
|
|
1
|
+
{"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
|
package/lib/types/pairing.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* Security invariants:
|
|
8
8
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
9
9
|
* immediately invalidates the previous link.
|
|
10
|
-
* - A token
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - A token is a bearer credential for its whole window: it is refused only
|
|
11
|
+
* when unknown, expired, or after `stop()`, and the same link may pair
|
|
12
|
+
* repeatedly within that window (each accept mints a fresh device session).
|
|
13
13
|
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
14
14
|
* in-app browser to the system browser), and the later context must be
|
|
15
15
|
* able to complete its own pairing from the same link.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* `idleExpireMs` are deleted on sweep, load, and the next gated request.
|
|
22
22
|
*/
|
|
23
23
|
import { randomBytes } from 'node:crypto';
|
|
24
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { dirname } from 'node:path';
|
|
26
26
|
/**
|
|
27
27
|
* Default idle-expiry window: 30 days without a heartbeat or a gated
|
|
@@ -62,6 +62,7 @@ export const defaultClock = {
|
|
|
62
62
|
export class PairingService {
|
|
63
63
|
config;
|
|
64
64
|
clock;
|
|
65
|
+
fs;
|
|
65
66
|
tokens = new Map();
|
|
66
67
|
devices = new Map();
|
|
67
68
|
listeners = new Set();
|
|
@@ -84,9 +85,10 @@ export class PairingService {
|
|
|
84
85
|
* the current one.
|
|
85
86
|
* @param clock - clock/entropy source (injectable for tests).
|
|
86
87
|
*/
|
|
87
|
-
constructor(config, clock = defaultClock) {
|
|
88
|
+
constructor(config, clock = defaultClock, fs = {}) {
|
|
88
89
|
this.config = config;
|
|
89
90
|
this.clock = clock;
|
|
91
|
+
this.fs = fs;
|
|
90
92
|
this.loadPersisted();
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
@@ -119,7 +121,7 @@ export class PairingService {
|
|
|
119
121
|
}
|
|
120
122
|
this.clampToMaxDevices();
|
|
121
123
|
if (this.evictIdle())
|
|
122
|
-
this.
|
|
124
|
+
this.persistRevocation();
|
|
123
125
|
}
|
|
124
126
|
catch {
|
|
125
127
|
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
@@ -160,7 +162,7 @@ export class PairingService {
|
|
|
160
162
|
persist() {
|
|
161
163
|
const file = this.config.devicesFile;
|
|
162
164
|
if (file === undefined)
|
|
163
|
-
return;
|
|
165
|
+
return true;
|
|
164
166
|
try {
|
|
165
167
|
mkdirSync(dirname(file), { recursive: true });
|
|
166
168
|
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
@@ -170,9 +172,37 @@ export class PairingService {
|
|
|
170
172
|
writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 });
|
|
171
173
|
renameSync(temp, file);
|
|
172
174
|
this.dirty = false;
|
|
175
|
+
return true;
|
|
173
176
|
}
|
|
174
177
|
catch (error) {
|
|
175
178
|
console.error('remote-web-ui: failed to persist paired devices', error);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Persist a table change that REVOKES access (stop, per-device revoke, idle
|
|
184
|
+
* eviction). A failed write would leave the revoked session on disk, and
|
|
185
|
+
* {@link loadPersisted} restores that file verbatim on the next start — the
|
|
186
|
+
* device's still-valid cookie would authorize again after an explicit
|
|
187
|
+
* revocation. When the write cannot be made durable, the stale store is
|
|
188
|
+
* removed instead (a missing file already loads as an empty table), so the
|
|
189
|
+
* failure costs a re-pair rather than silently undoing the revocation.
|
|
190
|
+
*/
|
|
191
|
+
persistRevocation() {
|
|
192
|
+
if (this.persist())
|
|
193
|
+
return;
|
|
194
|
+
const file = this.config.devicesFile;
|
|
195
|
+
if (file === undefined)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
if (this.fs.removeFile !== undefined)
|
|
199
|
+
this.fs.removeFile(file);
|
|
200
|
+
else
|
|
201
|
+
rmSync(file, { force: true });
|
|
202
|
+
console.error('remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it');
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
console.error('remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed', error);
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
208
|
/** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
|
|
@@ -196,9 +226,14 @@ export class PairingService {
|
|
|
196
226
|
get publicBaseUrl() {
|
|
197
227
|
return this.publicBase;
|
|
198
228
|
}
|
|
199
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* Set or clear the public base URL (a tunnel in front of this server). The
|
|
231
|
+
* value is canonicalized to its origin: a trailing slash (what a browser
|
|
232
|
+
* address-bar copy produces) or a path would mint a dead `//pair-accept`
|
|
233
|
+
* link, which WHATWG resolves as an authority rather than a path.
|
|
234
|
+
*/
|
|
200
235
|
setPublicBaseUrl(url) {
|
|
201
|
-
this.publicBase = url;
|
|
236
|
+
this.publicBase = url === undefined ? undefined : canonicalBaseUrl(url);
|
|
202
237
|
this.notify();
|
|
203
238
|
}
|
|
204
239
|
/** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
|
|
@@ -244,7 +279,6 @@ export class PairingService {
|
|
|
244
279
|
id: `t${this.tokenSerial}`,
|
|
245
280
|
issuedAt: now,
|
|
246
281
|
expiresAt: now + this.config.tokenTtlMs,
|
|
247
|
-
consumed: false,
|
|
248
282
|
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
249
283
|
...(address !== undefined ? { address } : {}),
|
|
250
284
|
});
|
|
@@ -252,21 +286,20 @@ export class PairingService {
|
|
|
252
286
|
return { token, expiresAt: now + this.config.tokenTtlMs };
|
|
253
287
|
}
|
|
254
288
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
289
|
+
* Redeem a token and bind a device session. The token is a bearer
|
|
290
|
+
* credential for its whole window, not a single-use nonce: it is refused
|
|
291
|
+
* only when unknown, past its expiry, or after stop(), and every successful
|
|
292
|
+
* call — including a repeat within the same window — mints a fresh device
|
|
293
|
+
* session (see the module doc for the mobile cookie-context rationale).
|
|
258
294
|
* @param token - the token secret from the QR link.
|
|
259
295
|
* @param userAgent - optional User-Agent header captured at accept.
|
|
260
296
|
* @returns the new device id, or a refusal code.
|
|
261
297
|
*/
|
|
262
298
|
accept(token, userAgent) {
|
|
263
299
|
const record = this.tokens.get(token);
|
|
264
|
-
// A consumed token stays a valid bearer credential until expiry or
|
|
265
|
-
// replacement (see the module doc): re-accept mints a fresh device.
|
|
266
300
|
if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
|
|
267
301
|
return { ok: false, code: 'invalid' };
|
|
268
302
|
}
|
|
269
|
-
record.consumed = true;
|
|
270
303
|
const deviceId = this.clock.randomToken();
|
|
271
304
|
const now = this.clock.now();
|
|
272
305
|
if (this.devices.size >= this.config.maxDevices) {
|
|
@@ -297,7 +330,7 @@ export class PairingService {
|
|
|
297
330
|
stop() {
|
|
298
331
|
this.tokens.clear();
|
|
299
332
|
this.devices.clear();
|
|
300
|
-
this.
|
|
333
|
+
this.persistRevocation();
|
|
301
334
|
this.stopped = true;
|
|
302
335
|
this.notify();
|
|
303
336
|
}
|
|
@@ -312,7 +345,7 @@ export class PairingService {
|
|
|
312
345
|
return false;
|
|
313
346
|
if (!this.devices.delete(deviceId))
|
|
314
347
|
return false;
|
|
315
|
-
this.
|
|
348
|
+
this.persistRevocation();
|
|
316
349
|
this.notify();
|
|
317
350
|
return true;
|
|
318
351
|
}
|
|
@@ -349,7 +382,9 @@ export class PairingService {
|
|
|
349
382
|
*/
|
|
350
383
|
sweep() {
|
|
351
384
|
const evicted = this.evictIdle();
|
|
352
|
-
if (evicted
|
|
385
|
+
if (evicted)
|
|
386
|
+
this.persistRevocation();
|
|
387
|
+
else if (this.dirty)
|
|
353
388
|
this.persist();
|
|
354
389
|
this.notify();
|
|
355
390
|
}
|
|
@@ -407,7 +442,7 @@ export class PairingService {
|
|
|
407
442
|
const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
|
|
408
443
|
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
409
444
|
this.devices.delete(deviceId);
|
|
410
|
-
this.
|
|
445
|
+
this.persistRevocation();
|
|
411
446
|
this.notify();
|
|
412
447
|
return undefined;
|
|
413
448
|
}
|
|
@@ -494,6 +529,20 @@ function relayEqual(a, b) {
|
|
|
494
529
|
function sameStrings(a, b) {
|
|
495
530
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
496
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Canonical origin of a public base URL. An unparsable value is returned
|
|
534
|
+
* verbatim so the caller's malformed-base warning stays the single owner of
|
|
535
|
+
* that case.
|
|
536
|
+
* @param url - the configured public base.
|
|
537
|
+
*/
|
|
538
|
+
export function canonicalBaseUrl(url) {
|
|
539
|
+
try {
|
|
540
|
+
return new URL(url).origin;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return url;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
497
546
|
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
498
547
|
export function sanitizeUserAgent(raw) {
|
|
499
548
|
if (raw === undefined)
|
|
@@ -180,4 +180,30 @@ describe('PairingService device persistence', () => {
|
|
|
180
180
|
const deviceId = result.ok ? result.deviceId : '';
|
|
181
181
|
expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone');
|
|
182
182
|
});
|
|
183
|
+
it('operator is not left with a revocation that a restart silently undoes', () => {
|
|
184
|
+
// Given a device store that cannot be written (its parent path is a file)
|
|
185
|
+
// while a stale, readable store sits beside it.
|
|
186
|
+
const blocker = join(dir, 'blocker');
|
|
187
|
+
writeFileSync(blocker, 'not a directory');
|
|
188
|
+
const file = join(blocker, 'devices.json');
|
|
189
|
+
const removed = [];
|
|
190
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock(), { removeFile: (path) => { removed.push(path); } });
|
|
191
|
+
// When the operator stops remote access and the write cannot be made durable.
|
|
192
|
+
service.stop();
|
|
193
|
+
// Then the stale store is handed to the removal step, so a restart cannot
|
|
194
|
+
// restore the revoked sessions from disk.
|
|
195
|
+
expect(removed).toEqual([file]);
|
|
196
|
+
});
|
|
197
|
+
it('operator keeps the device store when a revocation persist succeeds', () => {
|
|
198
|
+
// Given a writable device store with one paired device.
|
|
199
|
+
const file = join(dir, 'devices.json');
|
|
200
|
+
const removed = [];
|
|
201
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock(), { removeFile: (path) => { removed.push(path); } });
|
|
202
|
+
const deviceId = pairDevice(service);
|
|
203
|
+
// When the device is revoked and the write lands.
|
|
204
|
+
expect(service.revoke(deviceId)).toBe(true);
|
|
205
|
+
// Then the store stays (with the row gone) and nothing is removed.
|
|
206
|
+
expect(removed).toEqual([]);
|
|
207
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({});
|
|
208
|
+
});
|
|
183
209
|
});
|
package/lib/types/posture.d.ts
CHANGED
|
@@ -9,15 +9,23 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The probe issues loopback requests with a forged Host header — the exact
|
|
11
11
|
* shape a tunnel or LAN client produces — and treats anything other than a
|
|
12
|
-
* 403 as exposed
|
|
13
|
-
*
|
|
12
|
+
* 403 or a 401 as exposed. The fence is documented to refuse with 403; the
|
|
13
|
+
* harness answers 401 when the Host fence PASSED but the browser-auth cookie
|
|
14
|
+
* is missing (the connection plugin checks the fence first and the cookie
|
|
15
|
+
* second). A 401 therefore means the /api surface is still gated — reporting
|
|
16
|
+
* it as open produced a false CRITICAL on exactly the supported LAN
|
|
17
|
+
* deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
|
|
18
|
+
* one alarm that should mean a real exposure.
|
|
14
19
|
*/
|
|
15
20
|
import type { ClientRequest, ClientRequestArgs } from 'node:http';
|
|
16
21
|
/** One probed origin. */
|
|
17
22
|
export interface PostureHost {
|
|
18
23
|
/** The forged Host header value that was probed. */
|
|
19
24
|
host: string;
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* True when the probe reached the RPC bridge without any gate refusing it
|
|
27
|
+
* (a status that is neither the fence's 403 nor the browser-auth 401).
|
|
28
|
+
*/
|
|
21
29
|
exposed: boolean;
|
|
22
30
|
}
|
|
23
31
|
/** One probe round. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;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;;;OAGG;IACH,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"}
|
package/lib/types/posture.js
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The probe issues loopback requests with a forged Host header — the exact
|
|
11
11
|
* shape a tunnel or LAN client produces — and treats anything other than a
|
|
12
|
-
* 403 as exposed
|
|
13
|
-
*
|
|
12
|
+
* 403 or a 401 as exposed. The fence is documented to refuse with 403; the
|
|
13
|
+
* harness answers 401 when the Host fence PASSED but the browser-auth cookie
|
|
14
|
+
* is missing (the connection plugin checks the fence first and the cookie
|
|
15
|
+
* second). A 401 therefore means the /api surface is still gated — reporting
|
|
16
|
+
* it as open produced a false CRITICAL on exactly the supported LAN
|
|
17
|
+
* deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
|
|
18
|
+
* one alarm that should mean a real exposure.
|
|
14
19
|
*/
|
|
15
20
|
import http from 'node:http';
|
|
16
21
|
/** Whether any probed origin is currently exposed. */
|
|
@@ -75,7 +80,7 @@ async function probeHost(port, hostHeader, request, timeoutMs) {
|
|
|
75
80
|
path: '/api/session.list',
|
|
76
81
|
headers: { host: hostHeader, 'content-type': 'application/json' },
|
|
77
82
|
timeout: timeoutMs,
|
|
78
|
-
}, status => { finish(status !== 403); });
|
|
83
|
+
}, status => { finish(status !== 403 && status !== 401); });
|
|
79
84
|
const timer = setTimeout(() => { finish(false); }, timeoutMs + 1_000);
|
|
80
85
|
handle.on('error', () => { finish(false); });
|
|
81
86
|
handle.end('{}');
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* consult.
|
|
8
8
|
*
|
|
9
9
|
* Security model:
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* - Every request must carry a live paired-device credential, enforced before
|
|
11
|
+
* any bytes are forwarded and before any host call. This gate is
|
|
12
|
+
* unconditional and does NOT follow `requirePairingForLan`: the channel
|
|
13
|
+
* re-issues traffic with the process's own browser-auth credential, so it is
|
|
14
|
+
* an authentication amplifier — an unpaired caller admitted here would reach
|
|
15
|
+
* the full host API without ever holding that credential. The LAN policy
|
|
16
|
+
* governs the plain `/api` surface only (whether the harness fence's LAN
|
|
17
|
+
* auto-trust stands on its own); it can never widen this channel.
|
|
14
18
|
* - A paired remote desktop is a full-control credential: the browser half
|
|
15
19
|
* flips the official UI into host mode (the transport ownsHost hook), so
|
|
16
20
|
* the configuration plane (settings, credentials, presets, deliverables)
|
|
@@ -31,15 +35,8 @@ export { REMOTE_API_PREFIX } from './remote-methods.ts';
|
|
|
31
35
|
export interface RemoteApiDeps {
|
|
32
36
|
/** The pairing service (device gate + cookie name). */
|
|
33
37
|
service: PairingService;
|
|
34
|
-
/**
|
|
38
|
+
/** Local webServer port the loopback proxy connects to. */
|
|
35
39
|
port: number;
|
|
36
|
-
/**
|
|
37
|
-
* Live policy: whether the paired-device cookie gates the /remote channel.
|
|
38
|
-
* When false, requests are proxied without a cookie (the loopback-only
|
|
39
|
-
* denials still apply). A function is re-read per request, so a settings
|
|
40
|
-
* edit takes effect without a restart. Defaults to true.
|
|
41
|
-
*/
|
|
42
|
-
requirePairingForLan?: boolean | (() => boolean);
|
|
43
40
|
/**
|
|
44
41
|
* The process's inner browser-auth credential attached to re-issued
|
|
45
42
|
* requests (the connection plugin's /api route enforces that cookie and
|
|
@@ -68,7 +65,7 @@ export declare function loopbackOnlyDenial(innerPath: string): string | undefine
|
|
|
68
65
|
export declare function pairedDeviceIdOf(req: IncomingMessage, service: PairingService): string | undefined;
|
|
69
66
|
/**
|
|
70
67
|
* Build the remote desktop channel HTTP routes.
|
|
71
|
-
* @param deps - pairing service + local port +
|
|
68
|
+
* @param deps - pairing service + local port + inner credential.
|
|
72
69
|
* @returns the routes to register on webServer.
|
|
73
70
|
*/
|
|
74
71
|
export declare function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,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,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAwCnE;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,CAyCjF"}
|