dsh-cherry-bridge 0.1.11 → 0.2.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.en.md +11 -4
- package/README.md +10 -4
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +906 -144
- package/docs/e2e-checklist.md +2 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +181 -14
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
package/src/httpApi.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CONTRACT_DIFFERENCES, buildDiagnostics, readScopeSegment } from './diag
|
|
|
9
9
|
import type { DiagnosticsError, DiagnosticsStats, SubscriptionStat } from './diagnostics.js'
|
|
10
10
|
import type { ScopeDoc, ToolScopeState } from './toolScope.js'
|
|
11
11
|
import type { RefreshSummary } from './toolScopeHost.js'
|
|
12
|
+
import { failureText } from './text.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The plugin's same-origin HTTP face (spec §3.7): device management REST plus
|
|
@@ -114,6 +115,10 @@ export interface DeviceSummary {
|
|
|
114
115
|
enabled: boolean
|
|
115
116
|
hosts: string[]
|
|
116
117
|
port: number
|
|
118
|
+
/** The scheme each host is dialed with (issue #126); see `DeviceRecord`. */
|
|
119
|
+
security: Record<string, 'ws' | 'wss'>
|
|
120
|
+
/** The port each host is dialed with (issue #135); see `DeviceRecord`. */
|
|
121
|
+
ports: Record<string, number>
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/** The `device-state` SSE payload. */
|
|
@@ -432,6 +437,9 @@ function writeQuarantineRefusal(res: HttpResponseLike, route: RouteContext, erro
|
|
|
432
437
|
try {
|
|
433
438
|
route.deps.onArchiveQuarantined?.(error)
|
|
434
439
|
} catch (reportError) {
|
|
440
|
+
// LOCAL observer catch (issue #161 audit): the diagnostics ring is ours, so
|
|
441
|
+
// no peer text can reach this error — the object print (with its stack) is
|
|
442
|
+
// deliberate.
|
|
435
443
|
console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
|
|
436
444
|
}
|
|
437
445
|
writeJson(res, 503, {
|
|
@@ -538,9 +546,20 @@ interface DevicePatch {
|
|
|
538
546
|
}
|
|
539
547
|
|
|
540
548
|
/**
|
|
541
|
-
* Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract
|
|
542
|
-
*
|
|
543
|
-
* semantics
|
|
549
|
+
* Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
|
|
550
|
+
*
|
|
551
|
+
* The map semantics this face owes (the record's per-host maps are DERIVED,
|
|
552
|
+
* never patched directly — there is no `ports` field in this body):
|
|
553
|
+
*
|
|
554
|
+
* - neither `hosts` nor `port` patched → the maps are unchanged;
|
|
555
|
+
* - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
|
|
556
|
+
* host gets `ws` and the record-level port;
|
|
557
|
+
* - a CHANGED `port` (with or without `hosts`) → EVERY final host is
|
|
558
|
+
* re-pointed to it. The face has one port field, so leaving per-host
|
|
559
|
+
* entries would make the edit inert for every listed host that already had
|
|
560
|
+
* one. Sending the CURRENT value is a no-op for the maps — the settings page
|
|
561
|
+
* sends both fields on every save, and a mixed-port record must not be
|
|
562
|
+
* flattened by a save that did not change the port.
|
|
544
563
|
*/
|
|
545
564
|
function parsePatch(body: Record<string, unknown>): DevicePatch {
|
|
546
565
|
const patch: DevicePatch = {}
|
|
@@ -573,6 +592,12 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
|
|
|
573
592
|
if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
|
|
574
593
|
return normalizeHost(entry)
|
|
575
594
|
})
|
|
595
|
+
// Checked AFTER normalization: `[::1]` and `::1` collapse to one host. The
|
|
596
|
+
// scheme/port maps are keyed by host, so a duplicate has no single entry
|
|
597
|
+
// and silently last-wins — an ambiguous record, not an edit.
|
|
598
|
+
if (new Set(patch.hosts).size !== patch.hosts.length) {
|
|
599
|
+
throw new PatchError('hosts must not contain duplicates')
|
|
600
|
+
}
|
|
576
601
|
}
|
|
577
602
|
|
|
578
603
|
const port = body['port']
|
|
@@ -1053,6 +1078,8 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
|
|
|
1053
1078
|
enabled: device.enabled,
|
|
1054
1079
|
hosts: [...device.hosts],
|
|
1055
1080
|
port: device.port,
|
|
1081
|
+
security: { ...device.security },
|
|
1082
|
+
ports: { ...device.ports },
|
|
1056
1083
|
}))
|
|
1057
1084
|
writeJson(res, 200, devices)
|
|
1058
1085
|
}
|
|
@@ -1255,11 +1282,13 @@ function deviceById(deps: CherryHttpDeps, deviceId: string): DeviceRecord {
|
|
|
1255
1282
|
const found = deps.store.list().find((device) => device.deviceId === deviceId)
|
|
1256
1283
|
if (found !== undefined) return found
|
|
1257
1284
|
return {
|
|
1258
|
-
schemaVersion:
|
|
1285
|
+
schemaVersion: 4,
|
|
1259
1286
|
deviceId,
|
|
1260
1287
|
displayName: '',
|
|
1261
1288
|
hosts: [],
|
|
1262
1289
|
port: 0,
|
|
1290
|
+
security: {},
|
|
1291
|
+
ports: {},
|
|
1263
1292
|
desktopIdentity: '',
|
|
1264
1293
|
grants: [],
|
|
1265
1294
|
enabled: false,
|
|
@@ -1360,13 +1389,36 @@ async function patchDevice(
|
|
|
1360
1389
|
const previousEnabled = existing.enabled
|
|
1361
1390
|
const previousHosts = existing.hosts
|
|
1362
1391
|
const previousPort = existing.port
|
|
1392
|
+
const previousSecurity = existing.security
|
|
1393
|
+
const previousPorts = existing.ports
|
|
1394
|
+
// The per-host port map is DERIVED from the patch, never patched directly
|
|
1395
|
+
// (there is no `ports` field in the body):
|
|
1396
|
+
// - no address field → unchanged;
|
|
1397
|
+
// - `hosts` replaced → a staying host keeps its own port, a new host gets the
|
|
1398
|
+
// record-level `port`;
|
|
1399
|
+
// - a CHANGED `port` (with or without `hosts`) → EVERY final host is
|
|
1400
|
+
// re-pointed; one port field on this face, so leaving per-host entries
|
|
1401
|
+
// would make the edit inert. Sending the CURRENT value is a no-op for the
|
|
1402
|
+
// map (see `parsePatch`), because the settings page sends both fields on
|
|
1403
|
+
// every save.
|
|
1404
|
+
const nextPorts =
|
|
1405
|
+
patch.port !== undefined && patch.port !== previousPort
|
|
1406
|
+
? portsFor(patch.hosts ?? previousHosts, {}, patch.port)
|
|
1407
|
+
: patch.hosts === undefined
|
|
1408
|
+
? previousPorts
|
|
1409
|
+
: portsFor(patch.hosts, previousPorts, previousPort)
|
|
1363
1410
|
const next: DeviceRecord = {
|
|
1364
1411
|
...existing,
|
|
1365
1412
|
...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
|
|
1366
1413
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
1367
|
-
// hosts is WHOLE-REPLACE: the patch wins outright, never merges.
|
|
1368
|
-
|
|
1414
|
+
// hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
|
|
1415
|
+
// scheme map is keyed by host, so replacing the host list drops the
|
|
1416
|
+
// entries for hosts that are gone and defaults the new ones to `ws` —
|
|
1417
|
+
// keeping the OLD schemes indexed by the NEW list would be a stale
|
|
1418
|
+
// pairing of host to scheme (issue #126).
|
|
1419
|
+
...(patch.hosts === undefined ? {} : { hosts: patch.hosts, security: securityFor(patch.hosts, previousSecurity) }),
|
|
1369
1420
|
...(patch.port === undefined ? {} : { port: patch.port }),
|
|
1421
|
+
ports: nextPorts,
|
|
1370
1422
|
}
|
|
1371
1423
|
|
|
1372
1424
|
try {
|
|
@@ -1389,7 +1441,8 @@ async function patchDevice(
|
|
|
1389
1441
|
// `connect()` to rebuild from the record we just wrote.
|
|
1390
1442
|
const addressChanged =
|
|
1391
1443
|
(patch.hosts !== undefined && !sameHosts(patch.hosts, previousHosts)) ||
|
|
1392
|
-
(patch.port !== undefined && patch.port !== previousPort)
|
|
1444
|
+
(patch.port !== undefined && patch.port !== previousPort) ||
|
|
1445
|
+
!sameSecurity(next.security, previousSecurity)
|
|
1393
1446
|
const enabledChanged = patch.enabled !== undefined && patch.enabled !== previousEnabled
|
|
1394
1447
|
|
|
1395
1448
|
if (addressChanged) {
|
|
@@ -1409,6 +1462,11 @@ async function patchDevice(
|
|
|
1409
1462
|
try {
|
|
1410
1463
|
track(route.deps.connect(deviceId).connect(), warnConnect(deviceId))
|
|
1411
1464
|
} catch (error) {
|
|
1465
|
+
// LOCAL: only the synchronous construction failure lands here
|
|
1466
|
+
// (`connectionFor` cannot find the record, or the identity failed to
|
|
1467
|
+
// load). The DIAL's rejection carries peer text and is rendered by
|
|
1468
|
+
// `warnConnect` below; this arm keeps the object print and its stack
|
|
1469
|
+
// (issue #161's boundary).
|
|
1412
1470
|
console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error)
|
|
1413
1471
|
}
|
|
1414
1472
|
}
|
|
@@ -1428,12 +1486,23 @@ async function patchDevice(
|
|
|
1428
1486
|
enabled: next.enabled,
|
|
1429
1487
|
hosts: [...next.hosts],
|
|
1430
1488
|
port: next.port,
|
|
1489
|
+
security: { ...next.security },
|
|
1490
|
+
ports: { ...next.ports },
|
|
1431
1491
|
})
|
|
1432
1492
|
}
|
|
1433
1493
|
|
|
1434
1494
|
function warnConnect(deviceId: string): (error: unknown) => void {
|
|
1435
1495
|
return (error: unknown) => {
|
|
1436
|
-
|
|
1496
|
+
// ONE bounded STRING (issue #161). This is the arm that receives the
|
|
1497
|
+
// lifecycle rejection. The common case is `AllHostsUnreachableError`, whose
|
|
1498
|
+
// message embeds the last per-host error as `last: <message>` — a `ws` /
|
|
1499
|
+
// channel failure, or an RPC refusal's peer message (capped at 512 by the
|
|
1500
|
+
// failure schema). The terminal arms add only local wrappers (`identity
|
|
1501
|
+
// mismatch`, `protocol version incompatible`, `authenticate rejected
|
|
1502
|
+
// (<reason>)` — an enum). The old form handed the error OBJECT to the
|
|
1503
|
+
// console. Ceiling: `[cherry-remote] device ` (23) + deviceId +
|
|
1504
|
+
// ` lifecycle change failed: ` (26) + LOG_TEXT_MAX (240).
|
|
1505
|
+
console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`)
|
|
1437
1506
|
}
|
|
1438
1507
|
}
|
|
1439
1508
|
|
|
@@ -1442,6 +1511,56 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
|
|
|
1442
1511
|
return a.length === b.length && a.every((host, index) => host === b[index])
|
|
1443
1512
|
}
|
|
1444
1513
|
|
|
1514
|
+
/**
|
|
1515
|
+
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
1516
|
+
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
1517
|
+
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
1518
|
+
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
1519
|
+
* demote a TLS-proxied desktop to a plain dial.
|
|
1520
|
+
*/
|
|
1521
|
+
function securityFor(
|
|
1522
|
+
hosts: readonly string[],
|
|
1523
|
+
previous: Record<string, 'ws' | 'wss'>,
|
|
1524
|
+
): Record<string, 'ws' | 'wss'> {
|
|
1525
|
+
// `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
|
|
1526
|
+
// (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
|
|
1527
|
+
// Object.prototype and poison the map the store then rejects.
|
|
1528
|
+
return Object.fromEntries(
|
|
1529
|
+
hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : 'ws']),
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* The port map for a host list (issue #135): each named host keeps the port it
|
|
1535
|
+
* was dialed with, a host the list just gained defaults to `fallback` (the
|
|
1536
|
+
* record-level port), and hosts the list dropped leave no entry behind.
|
|
1537
|
+
*
|
|
1538
|
+
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
1539
|
+
* `port` patch): there is no remembered entry to keep, by design — one port
|
|
1540
|
+
* field on this face must move the whole list. Same `Object.hasOwn` rule as
|
|
1541
|
+
* `securityFor`: an inherited function is not a remembered port.
|
|
1542
|
+
*/
|
|
1543
|
+
function portsFor(
|
|
1544
|
+
hosts: readonly string[],
|
|
1545
|
+
previous: Record<string, number>,
|
|
1546
|
+
fallback: number,
|
|
1547
|
+
): Record<string, number> {
|
|
1548
|
+
return Object.fromEntries(
|
|
1549
|
+
hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]),
|
|
1550
|
+
)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Order-insensitive comparison for two scheme maps. */
|
|
1554
|
+
function sameSecurity(
|
|
1555
|
+
a: Record<string, 'ws' | 'wss'>,
|
|
1556
|
+
b: Record<string, 'ws' | 'wss'>,
|
|
1557
|
+
): boolean {
|
|
1558
|
+
const keys = Object.keys(a)
|
|
1559
|
+
if (keys.length !== Object.keys(b).length) return false
|
|
1560
|
+
return keys.every((key) => a[key] === b[key])
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
|
|
1445
1564
|
/** Drops the pooled connection, closing any live socket first. */
|
|
1446
1565
|
function evict(route: RouteContext, deviceId: string): void {
|
|
1447
1566
|
try {
|
|
@@ -1555,6 +1674,8 @@ async function startPairing(req: HttpRequestLike, res: HttpResponseLike, route:
|
|
|
1555
1674
|
try {
|
|
1556
1675
|
route.deps.onArchiveQuarantined?.(error)
|
|
1557
1676
|
} catch (reportError) {
|
|
1677
|
+
// LOCAL observer catch (issue #161 audit): same ring as the PATCH
|
|
1678
|
+
// route's, no peer text — the object print is deliberate.
|
|
1558
1679
|
console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
|
|
1559
1680
|
}
|
|
1560
1681
|
// The shared instruction VERBATIM, with only the two contextual paths
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { DeviceStore } from './deviceStore.js'
|
|
4
4
|
import { loadOrCreateIdentity } from './identity.js'
|
|
5
5
|
import { DeviceConnection } from './connection.js'
|
|
6
|
-
import type { DeviceState } from './connection.js'
|
|
6
|
+
import type { DeviceState, WsDialOptions } from './connection.js'
|
|
7
7
|
import { DEFAULT_PER_SESSION_CACHE_BYTES, DEFAULT_RATE_BUDGET_PER_SECOND } from './defaults.js'
|
|
8
8
|
import { pairWithPayload } from './pairing.js'
|
|
9
9
|
import { SessionSubscriptions } from './subscriptions.js'
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
} from './httpApi.js'
|
|
22
22
|
import type { DiagnosticsError } from './diagnostics.js'
|
|
23
23
|
import { truncateErrorMessage } from './diagnostics.js'
|
|
24
|
+
import { boundedJoin, failureText } from './text.js'
|
|
24
25
|
|
|
25
26
|
export const name = 'cherry-remote'
|
|
26
27
|
|
|
@@ -97,11 +98,25 @@ export interface CherryBridgeConfig {
|
|
|
97
98
|
* (not a protocol value). Omitted uses {@link DEFAULT_PER_SESSION_CACHE_BYTES}.
|
|
98
99
|
*/
|
|
99
100
|
perSessionCacheBytes?: number
|
|
101
|
+
/**
|
|
102
|
+
* FLAT `tls.connect` options for every `wss` dial (issue #133): the operator's
|
|
103
|
+
* TLS trust for a desktop behind a private CA or a self-signed TLS front.
|
|
104
|
+
* `ws` forwards this bag straight to Node's TLS layer, so `{ ca: <pem> }` and
|
|
105
|
+
* `{ rejectUnauthorized: false }` belong at the TOP level — the `{ tls: {…} }`
|
|
106
|
+
* shape (`https`' wrapper) is not recognised by `ws` and is refused here.
|
|
107
|
+
* Applied to BOTH dial sites: the steady-state pool and pairing.
|
|
108
|
+
*
|
|
109
|
+
* NEVER persisted in the record, never logged, never reported: a `ca` is PEM
|
|
110
|
+
* CONTENT, and trust is a grant the operator makes, not a fact the desktop
|
|
111
|
+
* asserts about itself.
|
|
112
|
+
*/
|
|
113
|
+
wsTls?: WsDialOptions
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/**
|
|
103
|
-
* The
|
|
104
|
-
* applied
|
|
117
|
+
* The plugin-config values that reach constructor arguments, resolved once: the
|
|
118
|
+
* two numeric budgets with their defaults applied, and the validated TLS trust
|
|
119
|
+
* bag (see {@link dialOptions}).
|
|
105
120
|
*
|
|
106
121
|
* Resolved ONCE per `apply` and threaded explicitly, rather than re-read at each
|
|
107
122
|
* construction site: a config surface that can change between two constructions
|
|
@@ -110,6 +125,7 @@ export interface CherryBridgeConfig {
|
|
|
110
125
|
export interface ResolvedBridgeConfig {
|
|
111
126
|
rateBudgetPerSecond: number
|
|
112
127
|
perSessionCacheBytes: number
|
|
128
|
+
wsTls: WsDialOptions | undefined
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
/**
|
|
@@ -130,11 +146,12 @@ export {
|
|
|
130
146
|
DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
131
147
|
} from './defaults.js'
|
|
132
148
|
|
|
133
|
-
/** Applies the documented fallbacks
|
|
149
|
+
/** Applies the documented fallbacks and validates the plugin config's keys. */
|
|
134
150
|
export function resolveBridgeConfig(config: CherryBridgeConfig = {}): ResolvedBridgeConfig {
|
|
135
151
|
return {
|
|
136
152
|
rateBudgetPerSecond: positive('rateBudgetPerSecond', config.rateBudgetPerSecond, DEFAULT_RATE_BUDGET_PER_SECOND),
|
|
137
153
|
perSessionCacheBytes: positive('perSessionCacheBytes', config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
|
|
154
|
+
wsTls: dialOptions(config.wsTls),
|
|
138
155
|
}
|
|
139
156
|
}
|
|
140
157
|
|
|
@@ -164,6 +181,104 @@ function positive(key: string, value: unknown, fallback: number): number {
|
|
|
164
181
|
return value
|
|
165
182
|
}
|
|
166
183
|
|
|
184
|
+
/** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
|
|
185
|
+
const WS_TLS_KEYS: ReadonlySet<string> = new Set(['rejectUnauthorized', 'ca'])
|
|
186
|
+
|
|
187
|
+
/** The shape a non-object config value arrived as, for a value-free warning. */
|
|
188
|
+
function shapeOf(value: unknown): string {
|
|
189
|
+
if (value === null) return 'null'
|
|
190
|
+
if (Array.isArray(value)) return 'array'
|
|
191
|
+
return typeof value
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
|
|
196
|
+
*
|
|
197
|
+
* A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
|
|
198
|
+
* would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
|
|
199
|
+
* would load bytes the operator never inspected. The header check is a SHAPE
|
|
200
|
+
* check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
|
|
201
|
+
* and an unparseable body fails the handshake loudly, which is the honest
|
|
202
|
+
* verdict. Buffers keep the non-empty test with no header requirement: DER and
|
|
203
|
+
* programmatically built PEM bytes are legitimate `ca` values.
|
|
204
|
+
*/
|
|
205
|
+
function certificateEntry(value: unknown): boolean {
|
|
206
|
+
if (typeof value === 'string') return value.trimStart().startsWith('-----BEGIN')
|
|
207
|
+
if (Buffer.isBuffer(value)) return value.length > 0
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A configured TLS trust bag, or `undefined` when absent/unusable.
|
|
213
|
+
*
|
|
214
|
+
* ABSENT is the documented default (Node's trust store) and says nothing —
|
|
215
|
+
* exactly like {@link positive}. A key that is PRESENT but unusable is a
|
|
216
|
+
* deployment mistake that silently changes which peers are reachable, so it is
|
|
217
|
+
* warned about by name rather than swallowed — but NEVER by value: a `ca` is
|
|
218
|
+
* PEM CONTENT, and a certificate must not reach a log.
|
|
219
|
+
*
|
|
220
|
+
* FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
|
|
221
|
+
* bag rather than forwarding the parts that happen to look right. Trust must be
|
|
222
|
+
* applied AS WRITTEN: a partially applied bag would reach some peers and not
|
|
223
|
+
* others with nothing telling the operator which half took effect, and their
|
|
224
|
+
* `ca` intention would be silently replaced by Node's default store. The
|
|
225
|
+
* fallback is "no override" — the same state as absent.
|
|
226
|
+
*/
|
|
227
|
+
function dialOptions(value: unknown): WsDialOptions | undefined {
|
|
228
|
+
if (value === undefined) return undefined
|
|
229
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options ` +
|
|
232
|
+
`(got ${shapeOf(value)}); ignoring it`,
|
|
233
|
+
)
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
const record = value as Record<string, unknown>
|
|
237
|
+
const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key))
|
|
238
|
+
if (unknownKeys.length > 0) {
|
|
239
|
+
// The `{ tls: {…} }` wrapper is the common mistake (`https`' shape, which
|
|
240
|
+
// `ws` ignores), so naming the offending keys points straight at it.
|
|
241
|
+
console.warn(
|
|
242
|
+
`[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(', ')}; ` +
|
|
243
|
+
'only flat rejectUnauthorized / ca are accepted — ignoring the whole object',
|
|
244
|
+
)
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rejectUnauthorized = record['rejectUnauthorized']
|
|
249
|
+
if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== 'boolean') {
|
|
250
|
+
// FIELD only — a config value never enters the message.
|
|
251
|
+
console.warn(
|
|
252
|
+
'[cherry-remote] config "wsTls.rejectUnauthorized" must be a boolean; ignoring the whole object',
|
|
253
|
+
)
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ca = record['ca']
|
|
258
|
+
if (ca !== undefined) {
|
|
259
|
+
const usable = Array.isArray(ca)
|
|
260
|
+
? ca.length > 0 && ca.every((entry) => certificateEntry(entry))
|
|
261
|
+
: certificateEntry(ca)
|
|
262
|
+
if (!usable) {
|
|
263
|
+
// FIELD only — never the value: a `ca` is certificate text.
|
|
264
|
+
console.warn(
|
|
265
|
+
'[cherry-remote] config "wsTls.ca" must be PEM CONTENT (the certificate text itself, ' +
|
|
266
|
+
'not a file path) — a "-----BEGIN" string, a non-empty Buffer, or a non-empty array ' +
|
|
267
|
+
'of those; ignoring the whole object',
|
|
268
|
+
)
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// `{}` (or an explicit `undefined` value) is the documented default written
|
|
274
|
+
// out: no override, and no warning.
|
|
275
|
+
if (rejectUnauthorized === undefined && ca === undefined) return undefined
|
|
276
|
+
return {
|
|
277
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
278
|
+
...(ca === undefined ? {} : { ca: ca as WsDialOptions['ca'] }),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
167
282
|
/** The context slice `apply` touches beyond the HTTP face's own services. */
|
|
168
283
|
export interface CherryBridgeContext extends CherryHttpContext, CherryToolsContext {
|
|
169
284
|
effect?(callback: () => () => void): unknown
|
|
@@ -213,6 +328,13 @@ interface AssemblyState {
|
|
|
213
328
|
*/
|
|
214
329
|
rateBudgetPerSecond: number
|
|
215
330
|
perSessionCacheBytes: number
|
|
331
|
+
/**
|
|
332
|
+
* The current apply's TLS trust bag (issue #133), handed to BOTH dial sites —
|
|
333
|
+
* the pooled connection and pairing's temporary channel. Held on the state for
|
|
334
|
+
* the same reason as the two numeric values: a re-apply must move both faces
|
|
335
|
+
* onto the new config. `undefined` = Node's default trust store.
|
|
336
|
+
*/
|
|
337
|
+
wsTls: WsDialOptions | undefined
|
|
216
338
|
readonly connections: Map<string, DeviceConnection>
|
|
217
339
|
/**
|
|
218
340
|
* One subscription layer per device, sharing nothing with its siblings: the
|
|
@@ -275,6 +397,7 @@ const state: AssemblyState = {
|
|
|
275
397
|
identityError: undefined,
|
|
276
398
|
rateBudgetPerSecond: DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
277
399
|
perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
|
|
400
|
+
wsTls: undefined,
|
|
278
401
|
connections: new Map(),
|
|
279
402
|
subscriptions: new Map(),
|
|
280
403
|
deviceStateListeners: new Set(),
|
|
@@ -363,6 +486,9 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
363
486
|
identity: requireIdentity(),
|
|
364
487
|
record,
|
|
365
488
|
rateBudgetPerSecond: state.rateBudgetPerSecond,
|
|
489
|
+
// Spread-if-present, never `wsOptions: undefined`: the option is optional,
|
|
490
|
+
// and an absent key is the honest "no override" (Node's trust store).
|
|
491
|
+
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
366
492
|
})
|
|
367
493
|
state.connectionsBuilt += 1
|
|
368
494
|
connection.onState((deviceState: DeviceState) => {
|
|
@@ -393,7 +519,18 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
393
519
|
try {
|
|
394
520
|
subsFor(deviceId).handleNotification(method, params)
|
|
395
521
|
} catch (error) {
|
|
396
|
-
|
|
522
|
+
// ONE bounded STRING (issue #161): the old form passed the error OBJECT,
|
|
523
|
+
// so the console printed the error's name/message/stack with nothing
|
|
524
|
+
// bounding it. The layer's handler throws for no schema-valid
|
|
525
|
+
// notification today, but THIS try also calls `subsFor(deviceId)` →
|
|
526
|
+
// `connectionFor()`, which does throw today (`no device …` when the
|
|
527
|
+
// record was removed mid-race) — and a future handler throw must not hand
|
|
528
|
+
// the console an object either. Ceiling: `[cherry-remote] notification "`
|
|
529
|
+
// (30) + the notification schema's `method` cap (128) + `" for ` (6) +
|
|
530
|
+
// deviceId + ` failed: ` (9) + LOG_TEXT_MAX (240).
|
|
531
|
+
console.warn(
|
|
532
|
+
`[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`,
|
|
533
|
+
)
|
|
397
534
|
}
|
|
398
535
|
})
|
|
399
536
|
state.connections.set(deviceId, connection)
|
|
@@ -526,6 +663,31 @@ function subsFor(deviceId: string): SessionSubscriptions {
|
|
|
526
663
|
return created
|
|
527
664
|
}
|
|
528
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Bounds the link-recovery line's session-id list (issue #155).
|
|
668
|
+
*
|
|
669
|
+
* The number of ENTRIES is bounded only by the peer-advertised hello
|
|
670
|
+
* `subscriptions` — a number the peer chooses, not one this assembly can size —
|
|
671
|
+
* and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
|
|
672
|
+
* `sessions.join(', ')` therefore emitted `entries × 258` characters into one
|
|
673
|
+
* info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
|
|
674
|
+
* ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
|
|
675
|
+
* text is peer-controlled (the ids are echoed back from what the peer accepted
|
|
676
|
+
* as subscriptions), so it needs a LOCAL bound like every other such log.
|
|
677
|
+
*
|
|
678
|
+
* `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
|
|
679
|
+
* 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
|
|
680
|
+
* own subscription cap fits WHOLE — the bound bites only on a peer that
|
|
681
|
+
* advertises more room, or on a malformed over-long id.
|
|
682
|
+
* `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
|
|
683
|
+
* is never clipped; only a malformed one is. A list that stops early names the
|
|
684
|
+
* count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
|
|
685
|
+
* silently. The bound is on the LOG only — the loop still re-ensures every
|
|
686
|
+
* session.
|
|
687
|
+
*/
|
|
688
|
+
const RECOVERY_SESSIONS_MAX = 320
|
|
689
|
+
const RECOVERY_SESSION_ID_MAX = 256
|
|
690
|
+
|
|
529
691
|
/**
|
|
530
692
|
* The reconnect recovery line (ruling 8): every session this device had
|
|
531
693
|
* subscribed is re-ensured at its LAST cursor, so the peer replays only what
|
|
@@ -546,10 +708,16 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
|
|
|
546
708
|
const sessions = subscriptions.activeSessions()
|
|
547
709
|
// INFO (spec §5 「订阅建立与恢复」): the recovery line announces what it is
|
|
548
710
|
// about to re-ensure, once per link recovery rather than once per session.
|
|
549
|
-
// Ids only — a session id is whitelisted, an event payload is not.
|
|
711
|
+
// Ids only — a session id is whitelisted, an event payload is not. The id
|
|
712
|
+
// list is bounded by {@link RECOVERY_SESSIONS_MAX} (issue #155); the
|
|
713
|
+
// `subscription(s)` count above it is the TRUE total, so a clipped list still
|
|
714
|
+
// says how many sessions are being recovered.
|
|
550
715
|
if (sessions.length > 0) {
|
|
551
716
|
console.info(
|
|
552
|
-
`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${
|
|
717
|
+
`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(
|
|
718
|
+
sessions,
|
|
719
|
+
{ max: RECOVERY_SESSIONS_MAX, itemMax: RECOVERY_SESSION_ID_MAX },
|
|
720
|
+
)}`,
|
|
553
721
|
)
|
|
554
722
|
}
|
|
555
723
|
|
|
@@ -566,7 +734,15 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
|
|
|
566
734
|
(cursor === undefined ? '' : ` at seq ${cursor.seq}`),
|
|
567
735
|
)
|
|
568
736
|
} catch (error) {
|
|
569
|
-
|
|
737
|
+
// ONE bounded STRING (issue #161). The catch is peer-triggered:
|
|
738
|
+
// `ensure` subscribes over the live link, and its failure message is the
|
|
739
|
+
// peer's JSON-RPC `error.message` verbatim (up to the 512-char failure
|
|
740
|
+
// schema cap) or a transport message. The old form handed the error
|
|
741
|
+
// OBJECT to the console, which then printed the stack plus every own
|
|
742
|
+
// property unbounded.
|
|
743
|
+
console.warn(
|
|
744
|
+
`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
|
|
745
|
+
)
|
|
570
746
|
}
|
|
571
747
|
}
|
|
572
748
|
}
|
|
@@ -603,6 +779,11 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
|
|
|
603
779
|
payload: request.payload,
|
|
604
780
|
store: state.store,
|
|
605
781
|
...(request.name === undefined ? {} : { name: request.name }),
|
|
782
|
+
// The same operator TLS trust the steady-state dial uses (issue #133):
|
|
783
|
+
// a desktop behind a private CA must be pairABLE over `wss` as well as
|
|
784
|
+
// reconnectable. Read from the assembly state, never from `request`: a
|
|
785
|
+
// caller cannot supply trust.
|
|
786
|
+
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
606
787
|
onVerificationCode: request.onVerificationCode,
|
|
607
788
|
// Pair bridging (ruling 10): the call site's stages reach the assembly's
|
|
608
789
|
// progress fan-out, which is what the SSE route consumes.
|
|
@@ -715,6 +896,7 @@ export function apply(ctx: CherryBridgeContext, config: CherryBridgeConfig = {})
|
|
|
715
896
|
const bridgeConfig = resolveBridgeConfig(config)
|
|
716
897
|
state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond
|
|
717
898
|
state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes
|
|
899
|
+
state.wsTls = bridgeConfig.wsTls
|
|
718
900
|
state.store = new DeviceStore(join(dataDir, 'devices.json'))
|
|
719
901
|
|
|
720
902
|
// A re-apply is a fresh assembly: the previous run's error summaries and
|