@openchambery/web 1.17.1 → 1.18.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/bin/lib/DOCUMENTATION.md +1 -0
- package/dist/assets/{AgentAvatar-C6Hxu5_o.js → AgentAvatar-B2kZjgaf.js} +1 -1
- package/dist/assets/{AssistantView-CbnxMZU0.js → AssistantView-Dc9MhgcD.js} +1 -1
- package/dist/assets/{ChatContainer-DEDGosbz.js → ChatContainer-CA3RO1yP.js} +40 -40
- package/dist/assets/{DiagramView-Byxhaduz.js → DiagramView-CpT4q2Jg.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CvNA_aGD.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
- package/dist/assets/{Icon-BPJ_quEe.js → Icon-AXqa-fYB.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-Cv8V-Q5l.js → MarkdownRendererImpl-DLnXrjCS.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-lVtCe-5P.js → MobileDetailNavigation-BYX8jFRg.js} +1 -1
- package/dist/assets/MobileShareBridge-BJzGsvUa.js +4 -0
- package/dist/assets/MobileSurface-Cfe17dw4.js +2 -0
- package/dist/assets/{MultiRunWindow-1kkKTh4h.js → MultiRunWindow-DEYIMkSg.js} +1 -1
- package/dist/assets/{OnboardingScreen-CdvaEBtt.js → OnboardingScreen-CCfdEtW6.js} +1 -1
- package/dist/assets/{OverlayScrollbar-Dqk1zGJB.js → OverlayScrollbar-CpQvV-qY.js} +1 -1
- package/dist/assets/SettingsView-PjA5aLCR.js +25 -0
- package/dist/assets/{SettingsWindow-DMQMRgc3.js → SettingsWindow-DOe4sCjl.js} +1 -1
- package/dist/assets/{TerminalView-jYq0JQPc.js → TerminalView-Bf-2wMV0.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DJefd2N6.js → ToolOutputDialog-DuT5ne12.js} +1 -1
- package/dist/assets/{appThemeRegistry-BXeIggYO.js → appThemeRegistry--wNZ5yxf.js} +2 -2
- package/dist/assets/{button-BmNJBT13.js → button-CDvMVaiO.js} +1 -1
- package/dist/assets/{clipboard-CXWbHCAk.js → clipboard-Jdqe05db.js} +1 -1
- package/dist/assets/{collapsible-68IqWBM1.js → collapsible-C2aamDvD.js} +1 -1
- package/dist/assets/{context-menu-BbRpMBQh.js → context-menu-CS_u7xxB.js} +1 -1
- package/dist/assets/desktopHosts-CBoBpJ1Z.js +1 -0
- package/dist/assets/desktopRelayRestore-l2R9muux.js +1 -0
- package/dist/assets/{diagram-YN0GZ1Q0.js → diagram-CEon0BQs.js} +1 -1
- package/dist/assets/{dialog-BV1ENlLf.js → dialog-BzZ3cKxG.js} +1 -1
- package/dist/assets/es-C25PRSjo.js +15 -0
- package/dist/assets/{fileQueries-i4oziMnC.js → fileQueries-CE_DaZWQ.js} +1 -1
- package/dist/assets/fr-Cv8ZWQdW.js +13 -0
- package/dist/assets/{gitApi-Bs_2iBAm.js → gitApi-DSLGdswL.js} +3 -3
- package/dist/assets/gitApiHttp-D8j1qRH4.js +2 -0
- package/dist/assets/{input-BLVhPvhN.js → input-k9DGGyZ6.js} +1 -1
- package/dist/assets/insertionBoundaries-BkoTIR-0.js +1 -0
- package/dist/assets/{ja-Dcb0VtDE.js → ja-Ue9m9eSe.js} +3 -3
- package/dist/assets/{ko-CX71xNDX.js → ko-DWBUCCV6.js} +3 -3
- package/dist/assets/main-CTnxV3y3.js +172 -0
- package/dist/assets/{main-CT0DuuLm.js → main-D1Xze0xA.js} +2 -2
- package/dist/assets/{markdown-worker-Cq3Twp1A.js → markdown-worker-DkeWiER6.js} +1 -1
- package/dist/assets/{markdownTheme-CCnvI6L0.js → markdownTheme-DNT93i5L.js} +1 -1
- package/dist/assets/miniChat-Dxajshg_.js +2 -0
- package/dist/assets/mobile-CisSv-ca.js +2 -0
- package/dist/assets/multirun-BOb2dW9T.js +4 -0
- package/dist/assets/persistence-DPNMIbF5.js +3 -0
- package/dist/assets/{pl-mR_3Sqr8.js → pl-DaBaPCyE.js} +3 -3
- package/dist/assets/projectMeta-WTDhB__y.js +3 -0
- package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
- package/dist/assets/radio-DZwuHb7-.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-DpYFpBSy.js +2 -0
- package/dist/assets/renderMobileApp-DB1TXtLg.js +23 -0
- package/dist/assets/runtimeConfig-BtWi74Pm.js +1 -0
- package/dist/assets/runtimeEndpointReset-BnA1DoNG.js +145 -0
- package/dist/assets/{sortable-tabs-strip-oKJ700WG.js → sortable-tabs-strip-DADoz-JR.js} +1 -1
- package/dist/assets/src-oHrYIqHF.css +2 -0
- package/dist/assets/{store-oojlrF4d.js → store-SqV3zfE8.js} +4 -4
- package/dist/assets/{terminalApi-BYTA045F.js → terminalApi-tB3qLcfa.js} +1 -1
- package/dist/assets/{tooltip-BYGBBTc2.js → tooltip-DMNR5rN0.js} +1 -1
- package/dist/assets/uk-DJz_P8OZ.js +15 -0
- package/dist/assets/{url-CGHw6D-E.js → url-7MqoLs9X.js} +1 -1
- package/dist/assets/useAppFontEffects-CeS0e0FK.js +7 -0
- package/dist/assets/{useAssistantUIStore-ESTV1mEY.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-Cq5a2gcJ.js → useDesktopWindowControlsLayout-DwFMUFtC.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DG6uPzBG.js → useEffectiveDirectory-Dp8McufI.js} +1 -1
- package/dist/assets/{useFontPreferences-Bu4ME5hk.js → useFontPreferences-CIurCeNv.js} +1 -1
- package/dist/assets/useMobileNavigationStore-kWPnKBP1.js +475 -0
- package/dist/assets/useSessionAutoCleanup-BLiIZW0x.js +1 -0
- package/dist/assets/useUIStore-5agS8bsW.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-ADgu9578.js → useWorkerHighlightedLines-Cnxz7HaG.js} +1 -1
- package/dist/assets/{utils-DyUyiLje.js → utils-Co19EJEL.js} +1 -1
- package/dist/assets/zh-CN-DLAKDOM9.js +15 -0
- package/dist/assets/zh-TW-s3Jjx1_n.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.d.ts +4 -0
- package/server/index.js +29 -15
- package/server/lib/client-auth/pairing.js +7 -1
- package/server/lib/client-auth/pairing.test.js +45 -0
- package/server/lib/desktop-hosts/routes.js +157 -0
- package/server/lib/desktop-hosts/routes.test.js +227 -0
- package/server/lib/message-queue/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/routes.js +1 -1
- package/server/lib/message-queue/routes.test.js +1 -0
- package/server/lib/message-queue/service.js +12 -1
- package/server/lib/message-queue/service.test.js +30 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -4
- package/server/lib/opencode/bootstrap-runtime.js +7 -0
- package/server/lib/opencode/cli-entry-runtime.js +6 -0
- package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
- package/server/lib/opencode/core-routes.js +4 -1
- package/server/lib/opencode/core-routes.test.js +29 -0
- package/server/lib/opencode/openchamber-routes.js +12 -0
- package/server/lib/opencode/settings-runtime.js +1 -1
- package/server/lib/package-manager.js +3 -1
- package/server/lib/relay/DOCUMENTATION.md +44 -1
- package/server/lib/relay/host-client.js +4 -1
- package/server/lib/relay/service.js +16 -8
- package/server/lib/relay/service.test.js +21 -6
- package/server/lib/relay/tunnel-host.js +57 -4
- package/server/lib/relay/tunnel-host.test.js +250 -2
- package/dist/assets/MobileShareBridge-DAlvgjo1.js +0 -4
- package/dist/assets/MobileSurface-Bde8yLWo.js +0 -2
- package/dist/assets/SettingsView-D0dhTIur.js +0 -25
- package/dist/assets/desktopHosts-BpjUSDz4.js +0 -1
- package/dist/assets/desktopRelayRestore-BzUEk1i7.js +0 -1
- package/dist/assets/es-EXZUX1sB.js +0 -15
- package/dist/assets/fr-jWNPO91J.js +0 -13
- package/dist/assets/gitApiHttp-C1DaEMKR.js +0 -2
- package/dist/assets/insertionBoundaries-BAFtAotq.js +0 -1
- package/dist/assets/main-G8tKFcTV.js +0 -172
- package/dist/assets/miniChat-BP3OHqNF.js +0 -2
- package/dist/assets/mobile-BtwuDs-C.js +0 -2
- package/dist/assets/multirun-B-L3JH60.js +0 -4
- package/dist/assets/persistence-C_KCiMcY.js +0 -3
- package/dist/assets/projectMeta-Cp74cItv.js +0 -3
- package/dist/assets/pt-BR-D1RpVOvC.js +0 -15
- package/dist/assets/radio-Bs4mdPv5.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-dgirEOtR.js +0 -2
- package/dist/assets/renderMobileApp-DK9qzy0P.js +0 -23
- package/dist/assets/runtimeConfig-JSaYFA5x.js +0 -1
- package/dist/assets/runtimeEndpointReset-C0eCWIE6.js +0 -145
- package/dist/assets/src-8zziWB78.css +0 -2
- package/dist/assets/uk-UvnVqWv-.js +0 -15
- package/dist/assets/useAppFontEffects-BhLcUfRB.js +0 -7
- package/dist/assets/useMobileNavigationStore-1xHjnAsg.js +0 -475
- package/dist/assets/useSessionAutoCleanup-BFletAeV.js +0 -1
- package/dist/assets/useUIStore-D6BTB4va.js +0 -1
- package/dist/assets/zh-CN-9v-RC966.js +0 -15
- package/dist/assets/zh-TW-BZ_wZdC-.js +0 -15
|
@@ -54,12 +54,14 @@ const resolveBatchWindowMs = (option) => {
|
|
|
54
54
|
* identity: { serverId: string, hostEncPrivateKey: CryptoKey, signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string } },
|
|
55
55
|
* localPort?: number,
|
|
56
56
|
* getLocalPort?: () => number,
|
|
57
|
+
* getSshRoutingTable?: () => { id: string, localPort: number }[],
|
|
57
58
|
* onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
|
|
58
59
|
* logger?: Pick<Console, 'warn'>,
|
|
59
60
|
* }} options
|
|
60
61
|
*/
|
|
61
|
-
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
|
|
62
|
+
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, getSshRoutingTable = () => [], onStatus, logger = console, batchWindowMs, batch }) => {
|
|
62
63
|
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
|
64
|
+
const resolveSshRoutingTable = typeof getSshRoutingTable === 'function' ? getSshRoutingTable : () => [];
|
|
63
65
|
const localBatch = batch !== false;
|
|
64
66
|
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
|
65
67
|
|
|
@@ -188,6 +190,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
188
190
|
entry.tunnel = createTunnelHost({
|
|
189
191
|
connectionId,
|
|
190
192
|
getLocalPort: resolveLocalPort,
|
|
193
|
+
getSshRoutingTable: resolveSshRoutingTable,
|
|
191
194
|
getBufferedAmount: () => socket.bufferedAmount,
|
|
192
195
|
sendFrame: (plaintextFrame) => {
|
|
193
196
|
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// Host gate: only the Electron desktop runtime may run a relay host
|
|
10
10
|
// (OPENCHAMBER_RUNTIME=desktop, set before the in-process web server starts).
|
|
11
11
|
// Plain `node server`, `dev:web:hmr`, VS Code, CLI, and browser clients must
|
|
12
|
-
// never open the host-control socket
|
|
13
|
-
// They may still be relay *clients*
|
|
12
|
+
// never open the host-control socket, mint a relay identity, advertise a host
|
|
13
|
+
// pairing candidate, or probe the relay. They may still be relay *clients*
|
|
14
|
+
// when connecting to another host.
|
|
14
15
|
|
|
15
16
|
import express from 'express';
|
|
16
17
|
|
|
@@ -68,6 +69,7 @@ const envRelayUrlOverride = () => {
|
|
|
68
69
|
* readSettingsFromDiskMigrated: () => Promise<object>,
|
|
69
70
|
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
|
70
71
|
* getLocalPort: () => number,
|
|
72
|
+
* getSshRoutingTable?: () => { id: string, localPort: number }[],
|
|
71
73
|
* logger?: Pick<Console, 'warn'>,
|
|
72
74
|
* canHostRelay?: () => boolean,
|
|
73
75
|
* }} deps
|
|
@@ -80,6 +82,8 @@ export const createRelayService = ({
|
|
|
80
82
|
// regeneration — see identity.js/signing-key.js.
|
|
81
83
|
readSettingsStrict,
|
|
82
84
|
getLocalPort,
|
|
85
|
+
// Live SSH local-forward ports (Electron). Empty outside desktop.
|
|
86
|
+
getSshRoutingTable = () => [],
|
|
83
87
|
// Returns true when any paired device or pending pairing session uses the
|
|
84
88
|
// relay transport. The relay lifecycle is driven purely by this demand.
|
|
85
89
|
hasRelayDemand = async () => false,
|
|
@@ -207,6 +211,7 @@ export const createRelayService = ({
|
|
|
207
211
|
relayUrl,
|
|
208
212
|
identity,
|
|
209
213
|
getLocalPort,
|
|
214
|
+
getSshRoutingTable,
|
|
210
215
|
logger,
|
|
211
216
|
onStatus: (next) => {
|
|
212
217
|
status = next;
|
|
@@ -265,27 +270,27 @@ export const createRelayService = ({
|
|
|
265
270
|
// Stable server identity (base64url SHA-256 of the canonical public signing
|
|
266
271
|
// JWK). Derived from a public key, so it is not a secret; clients use it to
|
|
267
272
|
// verify that a learned/probed address belongs to this server before trusting
|
|
268
|
-
// it. Independent of whether the relay host is currently enabled.
|
|
273
|
+
// it. Independent of whether the relay host is currently enabled. Off
|
|
274
|
+
// desktop this is null — minting identity is a desktop-host concern.
|
|
269
275
|
const getServerId = async () => {
|
|
276
|
+
if (!hostAllowed()) return null;
|
|
270
277
|
const identity = await identityRuntime.getRelayIdentity();
|
|
271
278
|
return identity.serverId;
|
|
272
279
|
};
|
|
273
280
|
|
|
274
281
|
const getStatus = async () => {
|
|
275
|
-
const config = await readConfig();
|
|
276
|
-
const identity = await identityRuntime.getRelayIdentity();
|
|
277
282
|
if (!hostAllowed()) {
|
|
278
283
|
return {
|
|
279
284
|
enabled: false,
|
|
280
285
|
hostAllowed: false,
|
|
281
286
|
state: 'unavailable',
|
|
282
|
-
serverId:
|
|
287
|
+
serverId: null,
|
|
283
288
|
connectedClients: 0,
|
|
284
|
-
relayUrl: config.relayUrl,
|
|
285
|
-
relayUrlLocked: config.relayUrlLocked,
|
|
286
289
|
lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
|
|
287
290
|
};
|
|
288
291
|
}
|
|
292
|
+
const config = await readConfig();
|
|
293
|
+
const identity = await identityRuntime.getRelayIdentity();
|
|
289
294
|
const live = hostClient ? hostClient.getStatus() : status;
|
|
290
295
|
return {
|
|
291
296
|
enabled: config.enabled,
|
|
@@ -404,6 +409,9 @@ export const createRelayService = ({
|
|
|
404
409
|
|
|
405
410
|
app.post('/api/openchamber/relay/disable', async (_req, res) => {
|
|
406
411
|
try {
|
|
412
|
+
if (!hostAllowed()) {
|
|
413
|
+
return res.status(403).json({ error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
|
|
414
|
+
}
|
|
407
415
|
const current = await readConfig();
|
|
408
416
|
await writeConfig({ enabled: false, relayUrl: current.relayUrl });
|
|
409
417
|
stop();
|
|
@@ -5,14 +5,18 @@ import request from 'supertest';
|
|
|
5
5
|
const relayMocks = vi.hoisted(() => ({
|
|
6
6
|
starts: [],
|
|
7
7
|
stops: [],
|
|
8
|
+
identityReads: 0,
|
|
8
9
|
}));
|
|
9
10
|
|
|
10
11
|
vi.mock('./identity.js', () => ({
|
|
11
12
|
createRelayIdentityRuntime: () => ({
|
|
12
|
-
getRelayIdentity: async () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
getRelayIdentity: async () => {
|
|
14
|
+
relayMocks.identityReads += 1;
|
|
15
|
+
return {
|
|
16
|
+
serverId: 'server-test',
|
|
17
|
+
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
|
|
18
|
+
};
|
|
19
|
+
},
|
|
16
20
|
}),
|
|
17
21
|
}));
|
|
18
22
|
|
|
@@ -59,6 +63,7 @@ describe('relay service pairing endpoint', () => {
|
|
|
59
63
|
afterEach(() => {
|
|
60
64
|
relayMocks.starts.length = 0;
|
|
61
65
|
relayMocks.stops.length = 0;
|
|
66
|
+
relayMocks.identityReads = 0;
|
|
62
67
|
delete process.env.OPENCHAMBER_RELAY_URL;
|
|
63
68
|
});
|
|
64
69
|
|
|
@@ -142,9 +147,10 @@ describe('relay host desktop-only gate', () => {
|
|
|
142
147
|
afterEach(() => {
|
|
143
148
|
relayMocks.starts.length = 0;
|
|
144
149
|
relayMocks.stops.length = 0;
|
|
150
|
+
relayMocks.identityReads = 0;
|
|
145
151
|
});
|
|
146
152
|
|
|
147
|
-
it('does not start a host, claim, or rewrite settings when reconcile runs off desktop', async () => {
|
|
153
|
+
it('does not start a host, claim, mint identity, or rewrite settings when reconcile runs off desktop', async () => {
|
|
148
154
|
const write = vi.fn(async (next) => next);
|
|
149
155
|
const { service, settings } = createTestRelayService({
|
|
150
156
|
canHostRelay: false,
|
|
@@ -157,12 +163,16 @@ describe('relay host desktop-only gate', () => {
|
|
|
157
163
|
expect(relayMocks.starts).toEqual([]);
|
|
158
164
|
expect(write).not.toHaveBeenCalled();
|
|
159
165
|
expect(settings.current()).toEqual({});
|
|
160
|
-
await expect(service.getStatus()).resolves.
|
|
166
|
+
await expect(service.getStatus()).resolves.toEqual({
|
|
161
167
|
enabled: false,
|
|
162
168
|
hostAllowed: false,
|
|
163
169
|
state: 'unavailable',
|
|
170
|
+
serverId: null,
|
|
171
|
+
connectedClients: 0,
|
|
164
172
|
lastError: RELAY_HOST_DESKTOP_ONLY_MESSAGE,
|
|
165
173
|
});
|
|
174
|
+
await expect(service.getServerId()).resolves.toBeNull();
|
|
175
|
+
expect(relayMocks.identityReads).toBe(0);
|
|
166
176
|
});
|
|
167
177
|
|
|
168
178
|
it('refuses relay pairing and enable off desktop', async () => {
|
|
@@ -181,6 +191,11 @@ describe('relay host desktop-only gate', () => {
|
|
|
181
191
|
.send({})
|
|
182
192
|
.expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
|
|
183
193
|
|
|
194
|
+
await request(app)
|
|
195
|
+
.post('/api/openchamber/relay/disable')
|
|
196
|
+
.send({})
|
|
197
|
+
.expect(403, { error: RELAY_HOST_DESKTOP_ONLY_MESSAGE });
|
|
198
|
+
|
|
184
199
|
expect(relayMocks.starts).toEqual([]);
|
|
185
200
|
expect(settings.current()).toEqual({});
|
|
186
201
|
});
|
|
@@ -35,9 +35,14 @@ const ALLOWED_WS_PATHS = new Set([
|
|
|
35
35
|
'/api/dictation/ws',
|
|
36
36
|
]);
|
|
37
37
|
|
|
38
|
+
// Client-selected SSH local-forward target (decimal port string). Valid only
|
|
39
|
+
// when present in the injected routing table; never falls back to default port.
|
|
40
|
+
const TARGET_PORT_HEADER = 'x-openchamber-target-port';
|
|
41
|
+
|
|
38
42
|
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
|
39
43
|
// to the loopback origin. content-length is dropped too because the body is
|
|
40
44
|
// re-chunked through the tunnel and undici computes framing itself.
|
|
45
|
+
// Target-port is host-routing metadata and must not reach the loopback origin.
|
|
41
46
|
const STRIPPED_REQUEST_HEADERS = new Set([
|
|
42
47
|
'connection',
|
|
43
48
|
'keep-alive',
|
|
@@ -45,6 +50,7 @@ const STRIPPED_REQUEST_HEADERS = new Set([
|
|
|
45
50
|
'upgrade',
|
|
46
51
|
'host',
|
|
47
52
|
'content-length',
|
|
53
|
+
TARGET_PORT_HEADER,
|
|
48
54
|
]);
|
|
49
55
|
|
|
50
56
|
// Response framing headers that no longer apply once the body crosses the
|
|
@@ -75,14 +81,45 @@ const isWsOpenPayload = (parsed) =>
|
|
|
75
81
|
Boolean(parsed && typeof parsed === 'object'
|
|
76
82
|
&& typeof parsed.path === 'string'
|
|
77
83
|
&& typeof parsed.query === 'string'
|
|
78
|
-
&& (parsed.protocols === undefined || Array.isArray(parsed.protocols))
|
|
84
|
+
&& (parsed.protocols === undefined || Array.isArray(parsed.protocols))
|
|
85
|
+
&& (parsed.headers === undefined || (parsed.headers && typeof parsed.headers === 'object')));
|
|
79
86
|
|
|
80
87
|
const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object');
|
|
81
88
|
|
|
89
|
+
const readHeaderValue = (headers, name) => {
|
|
90
|
+
if (!headers || typeof headers !== 'object') return undefined;
|
|
91
|
+
const want = name.toLowerCase();
|
|
92
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
93
|
+
if (typeof key === 'string' && key.toLowerCase() === want) return value;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve loopback dial port from optional x-openchamber-target-port.
|
|
100
|
+
* No header → defaultPort. Header present → only a routingTable hit is accepted;
|
|
101
|
+
* miss/invalid returns null (caller must not fall back to defaultPort).
|
|
102
|
+
* @param {Record<string, string> | undefined} requestHeaders
|
|
103
|
+
* @param {number} defaultPort
|
|
104
|
+
* @param {() => { id?: string, localPort?: number }[]} getSshRoutingTable
|
|
105
|
+
* @returns {number | null}
|
|
106
|
+
*/
|
|
107
|
+
export const resolveTargetPort = (requestHeaders, defaultPort, getSshRoutingTable) => {
|
|
108
|
+
const raw = readHeaderValue(requestHeaders, TARGET_PORT_HEADER);
|
|
109
|
+
if (raw === undefined || raw === null || raw === '') return defaultPort;
|
|
110
|
+
const parsed = typeof raw === 'number' ? raw : Number.parseInt(String(raw).trim(), 10);
|
|
111
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) return null;
|
|
112
|
+
const table = typeof getSshRoutingTable === 'function' ? getSshRoutingTable() : [];
|
|
113
|
+
if (!Array.isArray(table)) return null;
|
|
114
|
+
const hit = table.some((entry) => Number(entry?.localPort) === parsed);
|
|
115
|
+
return hit ? parsed : null;
|
|
116
|
+
};
|
|
117
|
+
|
|
82
118
|
/**
|
|
83
119
|
* @param {{
|
|
84
120
|
* connectionId: string,
|
|
85
121
|
* getLocalPort: () => number,
|
|
122
|
+
* getSshRoutingTable?: () => { id: string, localPort: number }[],
|
|
86
123
|
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
|
87
124
|
* getBufferedAmount: () => number,
|
|
88
125
|
* createWebSocket?: (url: string, protocols: string[] | undefined, options: { headers: Record<string, string> }) => import('ws').WebSocket,
|
|
@@ -91,6 +128,7 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
|
|
|
91
128
|
export const createTunnelHost = ({
|
|
92
129
|
connectionId,
|
|
93
130
|
getLocalPort,
|
|
131
|
+
getSshRoutingTable = () => [],
|
|
94
132
|
sendFrame,
|
|
95
133
|
getBufferedAmount,
|
|
96
134
|
createWebSocket = (url, protocols, options) => new WebSocket(url, protocols, options),
|
|
@@ -197,6 +235,13 @@ export const createTunnelHost = ({
|
|
|
197
235
|
const stream = streams.get(streamId);
|
|
198
236
|
if (!stream || stream.kind !== 'http') return;
|
|
199
237
|
|
|
238
|
+
const targetPort = resolveTargetPort(request.headers, getLocalPort(), getSshRoutingTable);
|
|
239
|
+
if (targetPort === null) {
|
|
240
|
+
dropStream(streamId);
|
|
241
|
+
await syntheticResponse(streamId, 503, 'ssh-host-unreachable');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
200
245
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
201
246
|
let requestBody;
|
|
202
247
|
if (hasBody) {
|
|
@@ -210,7 +255,7 @@ export const createTunnelHost = ({
|
|
|
210
255
|
stream.noBody = true;
|
|
211
256
|
}
|
|
212
257
|
|
|
213
|
-
const url = `http://127.0.0.1:${
|
|
258
|
+
const url = `http://127.0.0.1:${targetPort}${request.path}${request.query ? `?${request.query}` : ''}`;
|
|
214
259
|
let response;
|
|
215
260
|
try {
|
|
216
261
|
response = await fetch(url, {
|
|
@@ -323,7 +368,15 @@ export const createTunnelHost = ({
|
|
|
323
368
|
return;
|
|
324
369
|
}
|
|
325
370
|
|
|
326
|
-
|
|
371
|
+
// Optional headers on WsOpen carry routing metadata (e.g. target-port).
|
|
372
|
+
// Same resolve rules as HTTP; miss refuses without falling back to default.
|
|
373
|
+
const targetPort = resolveTargetPort(open.headers, getLocalPort(), getSshRoutingTable);
|
|
374
|
+
if (targetPort === null) {
|
|
375
|
+
void sendAbort(streamId, 'ssh-host-unreachable');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const url = `ws://127.0.0.1:${targetPort}${open.path}${open.query ? `?${open.query}` : ''}`;
|
|
327
380
|
// Present the loopback origin we're actually dialing. The server derives this
|
|
328
381
|
// as a trusted same-origin candidate from the Host header (127.0.0.1:<port>),
|
|
329
382
|
// so the WS origin check passes reliably for every client platform. We do NOT
|
|
@@ -333,7 +386,7 @@ export const createTunnelHost = ({
|
|
|
333
386
|
// authenticated by the tunneled oc_url_token, not by this origin.
|
|
334
387
|
const dialHeaders = {
|
|
335
388
|
'x-openchamber-relay-connection': connectionId,
|
|
336
|
-
origin: `http://127.0.0.1:${
|
|
389
|
+
origin: `http://127.0.0.1:${targetPort}`,
|
|
337
390
|
};
|
|
338
391
|
let socket;
|
|
339
392
|
try {
|
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
// corrupted payload. This test must stay red until send serialization is fixed.
|
|
8
8
|
// Production send path is intentionally not changed here.
|
|
9
9
|
|
|
10
|
-
import { describe, expect, it } from 'bun:test';
|
|
10
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
MAX_TUNNEL_PAYLOAD_BYTES,
|
|
14
14
|
TunnelFrameType,
|
|
15
15
|
createFragmentAssembler,
|
|
16
|
+
decodeJsonPayload,
|
|
16
17
|
decodeTunnelFrame,
|
|
17
18
|
encodeJsonPayload,
|
|
18
19
|
encodeTunnelFrame,
|
|
19
20
|
} from './tunnel-codec.js';
|
|
20
|
-
import { createTunnelHost } from './tunnel-host.js';
|
|
21
|
+
import { createTunnelHost, resolveTargetPort } from './tunnel-host.js';
|
|
21
22
|
|
|
22
23
|
const textEncoder = new TextEncoder();
|
|
23
24
|
const textDecoder = new TextDecoder();
|
|
@@ -223,3 +224,250 @@ describe('createTunnelHost WS fragment ordering', () => {
|
|
|
223
224
|
}
|
|
224
225
|
});
|
|
225
226
|
});
|
|
227
|
+
|
|
228
|
+
const collectHttpExchange = async (outbound, streamId) => {
|
|
229
|
+
await waitFor(() => outbound.some((frame) => {
|
|
230
|
+
const decoded = decodeTunnelFrame(frame);
|
|
231
|
+
return decoded.streamId === streamId
|
|
232
|
+
&& (decoded.frameType === TunnelFrameType.StreamEnd
|
|
233
|
+
|| decoded.frameType === TunnelFrameType.StreamAbort);
|
|
234
|
+
}));
|
|
235
|
+
let status = null;
|
|
236
|
+
/** @type {Uint8Array[]} */
|
|
237
|
+
const bodyChunks = [];
|
|
238
|
+
for (const raw of outbound) {
|
|
239
|
+
const frame = decodeTunnelFrame(raw);
|
|
240
|
+
if (frame.streamId !== streamId) continue;
|
|
241
|
+
if (frame.frameType === TunnelFrameType.HttpResponse) {
|
|
242
|
+
status = decodeJsonPayload(frame.payload, (v) => v && typeof v.status === 'number').status;
|
|
243
|
+
} else if (frame.frameType === TunnelFrameType.HttpBody) {
|
|
244
|
+
bodyChunks.push(frame.payload);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
let body = null;
|
|
248
|
+
if (bodyChunks.length > 0) {
|
|
249
|
+
const total = bodyChunks.reduce((n, c) => n + c.length, 0);
|
|
250
|
+
const merged = new Uint8Array(total);
|
|
251
|
+
let off = 0;
|
|
252
|
+
for (const c of bodyChunks) {
|
|
253
|
+
merged.set(c, off);
|
|
254
|
+
off += c.length;
|
|
255
|
+
}
|
|
256
|
+
body = JSON.parse(textDecoder.decode(merged));
|
|
257
|
+
}
|
|
258
|
+
return { status, body };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
describe('createTunnelHost SSH target-port routing', () => {
|
|
262
|
+
/** @type {typeof globalThis.fetch | undefined} */
|
|
263
|
+
let originalFetch;
|
|
264
|
+
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
if (originalFetch) {
|
|
267
|
+
globalThis.fetch = originalFetch;
|
|
268
|
+
originalFetch = undefined;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const installFetchProbe = () => {
|
|
273
|
+
/** @type {{ url: string, headers: Record<string, string> }[]} */
|
|
274
|
+
const calls = [];
|
|
275
|
+
originalFetch = globalThis.fetch;
|
|
276
|
+
globalThis.fetch = async (url, init) => {
|
|
277
|
+
calls.push({
|
|
278
|
+
url: String(url),
|
|
279
|
+
headers: { ...(init?.headers || {}) },
|
|
280
|
+
});
|
|
281
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
282
|
+
status: 200,
|
|
283
|
+
headers: { 'content-type': 'application/json' },
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
return calls;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
it('dials the routing-table port when x-openchamber-target-port is allowed', async () => {
|
|
290
|
+
const fetchCalls = installFetchProbe();
|
|
291
|
+
/** @type {Uint8Array[]} */
|
|
292
|
+
const outbound = [];
|
|
293
|
+
const host = createTunnelHost({
|
|
294
|
+
connectionId: 'test-conn',
|
|
295
|
+
getLocalPort: () => 3000,
|
|
296
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
297
|
+
sendFrame: (frame) => { outbound.push(frame); },
|
|
298
|
+
getBufferedAmount: () => 0,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const streamId = 11;
|
|
302
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
303
|
+
TunnelFrameType.HttpRequest,
|
|
304
|
+
streamId,
|
|
305
|
+
encodeJsonPayload({
|
|
306
|
+
method: 'GET',
|
|
307
|
+
path: '/health',
|
|
308
|
+
query: '',
|
|
309
|
+
headers: { 'x-openchamber-target-port': '41234', accept: 'application/json' },
|
|
310
|
+
}),
|
|
311
|
+
));
|
|
312
|
+
|
|
313
|
+
const exchange = await collectHttpExchange(outbound, streamId);
|
|
314
|
+
expect(exchange.status).toBe(200);
|
|
315
|
+
expect(fetchCalls).toHaveLength(1);
|
|
316
|
+
expect(fetchCalls[0].url).toBe('http://127.0.0.1:41234/health');
|
|
317
|
+
host.close();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('returns 503 and does not fall back when target-port is unknown', async () => {
|
|
321
|
+
const fetchCalls = installFetchProbe();
|
|
322
|
+
/** @type {Uint8Array[]} */
|
|
323
|
+
const outbound = [];
|
|
324
|
+
const host = createTunnelHost({
|
|
325
|
+
connectionId: 'test-conn',
|
|
326
|
+
getLocalPort: () => 3000,
|
|
327
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
328
|
+
sendFrame: (frame) => { outbound.push(frame); },
|
|
329
|
+
getBufferedAmount: () => 0,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const streamId = 12;
|
|
333
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
334
|
+
TunnelFrameType.HttpRequest,
|
|
335
|
+
streamId,
|
|
336
|
+
encodeJsonPayload({
|
|
337
|
+
method: 'GET',
|
|
338
|
+
path: '/health',
|
|
339
|
+
query: '',
|
|
340
|
+
headers: { 'x-openchamber-target-port': '9999' },
|
|
341
|
+
}),
|
|
342
|
+
));
|
|
343
|
+
|
|
344
|
+
const exchange = await collectHttpExchange(outbound, streamId);
|
|
345
|
+
expect(exchange.status).toBe(503);
|
|
346
|
+
expect(exchange.body?.error).toBe('ssh-host-unreachable');
|
|
347
|
+
expect(fetchCalls).toHaveLength(0);
|
|
348
|
+
host.close();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('strips x-openchamber-target-port before forwarding to loopback', async () => {
|
|
352
|
+
const fetchCalls = installFetchProbe();
|
|
353
|
+
/** @type {Uint8Array[]} */
|
|
354
|
+
const outbound = [];
|
|
355
|
+
const host = createTunnelHost({
|
|
356
|
+
connectionId: 'test-conn',
|
|
357
|
+
getLocalPort: () => 3000,
|
|
358
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
359
|
+
sendFrame: (frame) => { outbound.push(frame); },
|
|
360
|
+
getBufferedAmount: () => 0,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const streamId = 13;
|
|
364
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
365
|
+
TunnelFrameType.HttpRequest,
|
|
366
|
+
streamId,
|
|
367
|
+
encodeJsonPayload({
|
|
368
|
+
method: 'GET',
|
|
369
|
+
path: '/api/version',
|
|
370
|
+
query: '',
|
|
371
|
+
headers: {
|
|
372
|
+
'x-openchamber-target-port': '41234',
|
|
373
|
+
authorization: 'Bearer client-token',
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
));
|
|
377
|
+
|
|
378
|
+
await collectHttpExchange(outbound, streamId);
|
|
379
|
+
expect(fetchCalls).toHaveLength(1);
|
|
380
|
+
expect(fetchCalls[0].headers['x-openchamber-target-port']).toBeUndefined();
|
|
381
|
+
expect(fetchCalls[0].headers.authorization).toBe('Bearer client-token');
|
|
382
|
+
expect(fetchCalls[0].headers['x-openchamber-relay-connection']).toBe('test-conn');
|
|
383
|
+
host.close();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('dials the default local port when target-port header is absent', async () => {
|
|
387
|
+
const fetchCalls = installFetchProbe();
|
|
388
|
+
/** @type {Uint8Array[]} */
|
|
389
|
+
const outbound = [];
|
|
390
|
+
const host = createTunnelHost({
|
|
391
|
+
connectionId: 'test-conn',
|
|
392
|
+
getLocalPort: () => 3000,
|
|
393
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
394
|
+
sendFrame: (frame) => { outbound.push(frame); },
|
|
395
|
+
getBufferedAmount: () => 0,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const streamId = 14;
|
|
399
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
400
|
+
TunnelFrameType.HttpRequest,
|
|
401
|
+
streamId,
|
|
402
|
+
encodeJsonPayload({
|
|
403
|
+
method: 'GET',
|
|
404
|
+
path: '/health',
|
|
405
|
+
query: '',
|
|
406
|
+
headers: { accept: 'application/json' },
|
|
407
|
+
}),
|
|
408
|
+
));
|
|
409
|
+
|
|
410
|
+
await collectHttpExchange(outbound, streamId);
|
|
411
|
+
expect(fetchCalls).toHaveLength(1);
|
|
412
|
+
expect(fetchCalls[0].url).toBe('http://127.0.0.1:3000/health');
|
|
413
|
+
host.close();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('WS open dials routing-table port and aborts on miss without fallback', async () => {
|
|
417
|
+
FakeWebSocket.reset();
|
|
418
|
+
/** @type {Uint8Array[]} */
|
|
419
|
+
const outbound = [];
|
|
420
|
+
const host = createTunnelHost({
|
|
421
|
+
connectionId: 'test-conn',
|
|
422
|
+
getLocalPort: () => 3000,
|
|
423
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
424
|
+
sendFrame: (frame) => { outbound.push(frame); },
|
|
425
|
+
getBufferedAmount: () => 0,
|
|
426
|
+
createWebSocket: (url, protocols) => new FakeWebSocket(url, protocols),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
430
|
+
TunnelFrameType.WsOpen,
|
|
431
|
+
21,
|
|
432
|
+
encodeJsonPayload({
|
|
433
|
+
path: '/api/event/ws',
|
|
434
|
+
query: '',
|
|
435
|
+
headers: { 'x-openchamber-target-port': '41234' },
|
|
436
|
+
}),
|
|
437
|
+
));
|
|
438
|
+
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
439
|
+
expect(FakeWebSocket.instances[0].url).toBe('ws://127.0.0.1:41234/api/event/ws');
|
|
440
|
+
|
|
441
|
+
await host.handleFrame(encodeTunnelFrame(
|
|
442
|
+
TunnelFrameType.WsOpen,
|
|
443
|
+
22,
|
|
444
|
+
encodeJsonPayload({
|
|
445
|
+
path: '/api/event/ws',
|
|
446
|
+
query: '',
|
|
447
|
+
headers: { 'x-openchamber-target-port': '9999' },
|
|
448
|
+
}),
|
|
449
|
+
));
|
|
450
|
+
// Miss must not open another socket (no fallback to 3000).
|
|
451
|
+
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
452
|
+
await waitFor(() => outbound.some((frame) => {
|
|
453
|
+
const decoded = decodeTunnelFrame(frame);
|
|
454
|
+
return decoded.streamId === 22 && decoded.frameType === TunnelFrameType.StreamAbort;
|
|
455
|
+
}));
|
|
456
|
+
const abort = outbound
|
|
457
|
+
.map((frame) => decodeTunnelFrame(frame))
|
|
458
|
+
.find((frame) => frame.streamId === 22 && frame.frameType === TunnelFrameType.StreamAbort);
|
|
459
|
+
expect(decodeJsonPayload(abort.payload, () => true).reason).toBe('ssh-host-unreachable');
|
|
460
|
+
host.close();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('resolveTargetPort', () => {
|
|
465
|
+
it('returns default when header is absent and null on table miss', () => {
|
|
466
|
+
const table = () => [{ id: 'ssh-1', localPort: 41234 }];
|
|
467
|
+
expect(resolveTargetPort({}, 3000, table)).toBe(3000);
|
|
468
|
+
expect(resolveTargetPort({ 'x-openchamber-target-port': '41234' }, 3000, table)).toBe(41234);
|
|
469
|
+
expect(resolveTargetPort({ 'X-OpenChamber-Target-Port': '41234' }, 3000, table)).toBe(41234);
|
|
470
|
+
expect(resolveTargetPort({ 'x-openchamber-target-port': '9999' }, 3000, table)).toBeNull();
|
|
471
|
+
expect(resolveTargetPort({ 'x-openchamber-target-port': 'not-a-port' }, 3000, table)).toBeNull();
|
|
472
|
+
});
|
|
473
|
+
});
|